1use eyre::{Result, eyre};
4use foundry_evm_fuzz::BasicTxDetails;
5use std::{
6 collections::HashSet,
7 path::{Path, PathBuf},
8};
9use uuid::Uuid;
10
11const WORKER_DIR_PREFIX: &str = "worker";
12const CORPUS_SUBDIR: &str = "corpus";
13const MAX_CORPUS_TREE_DEPTH: usize = 64;
14const MAX_CORPUS_TREE_DIRS: usize = 10_000;
15const MAX_CORPUS_ENTRIES: usize = 1_000_000;
16
17pub struct CorpusDirEntry {
19 pub path: PathBuf,
20 pub uuid: Uuid,
21 pub timestamp: u64,
22}
23
24impl CorpusDirEntry {
25 pub fn name(&self) -> &str {
26 self.path.file_name().unwrap().to_str().unwrap()
27 }
28
29 pub fn read_tx_seq(&self) -> foundry_common::fs::Result<Vec<BasicTxDetails>> {
30 if self
31 .path
32 .extension()
33 .and_then(|extension| extension.to_str())
34 .is_some_and(|extension| extension.eq_ignore_ascii_case("gz"))
35 {
36 foundry_common::fs::read_json_gzip_file(&self.path)
37 } else {
38 foundry_common::fs::read_json_file(&self.path)
39 }
40 }
41}
42
43pub fn read_corpus_dir(path: &Path) -> impl Iterator<Item = CorpusDirEntry> {
45 let dir = match std::fs::read_dir(path) {
46 Ok(dir) => dir,
47 Err(err) => {
48 debug!(%err, ?path, "failed to read corpus directory");
49 return vec![].into_iter();
50 }
51 };
52
53 dir.filter_map(|res| {
54 let entry =
55 res.inspect_err(|err| debug!(%err, "failed to read corpus directory entry")).ok()?;
56 let path = entry.path();
57 if !entry.file_type().ok()?.is_file() {
58 return None;
59 }
60 let name = path.file_name()?.to_str()?;
61 match parse_corpus_filename(name) {
62 Ok((uuid, timestamp)) => Some(CorpusDirEntry { path, uuid, timestamp }),
63 Err(_) => {
64 debug!(target: "corpus", ?path, "failed to parse corpus filename");
65 None
66 }
67 }
68 })
69 .collect::<Vec<_>>()
70 .into_iter()
71}
72
73pub(crate) fn read_corpus_dir_strict(path: &Path) -> Result<Vec<CorpusDirEntry>> {
75 let mut entries = Vec::new();
76 for entry in std::fs::read_dir(path)? {
77 let entry = entry?;
78 let path = entry.path();
79 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
80 continue;
81 };
82 let Ok((uuid, timestamp)) = parse_corpus_filename(name) else {
83 continue;
84 };
85 if !entry.file_type()?.is_file() {
86 return Err(eyre!("corpus entry is not a regular file: {}", path.display()));
87 }
88 entries.push(CorpusDirEntry { path, uuid, timestamp });
89 }
90 Ok(entries)
91}
92
93pub fn read_corpus_tree(path: &Path) -> Result<Vec<CorpusDirEntry>> {
96 let metadata = std::fs::symlink_metadata(path)
97 .map_err(|_| eyre!("corpus path does not exist or is not readable: {}", path.display()))?;
98 let file_type = metadata.file_type();
99 if file_type.is_symlink() {
100 return Err(eyre!("corpus path must not be a symlink: {}", path.display()));
101 }
102 if file_type.is_file() {
103 let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
104 let (uuid, timestamp) = parse_corpus_filename(name).unwrap_or((Uuid::nil(), 0));
105 return Ok(vec![CorpusDirEntry { path: path.to_path_buf(), uuid, timestamp }]);
106 }
107
108 if !file_type.is_dir() {
109 return Err(eyre!("corpus path does not exist or is not readable: {}", path.display()));
110 }
111
112 let mut seen_entries = HashSet::new();
113 let mut visited_dirs = HashSet::new();
114 let mut entries = Vec::new();
115 let mut stack = vec![(path.to_path_buf(), 0usize)];
116 while let Some((dir, depth)) = stack.pop() {
117 let canonical = match dir.canonicalize() {
118 Ok(canonical) => canonical,
119 Err(err) => {
120 debug!(%err, ?dir, "failed to canonicalize corpus tree directory");
121 continue;
122 }
123 };
124 if !visited_dirs.insert(canonical) {
125 continue;
126 }
127 if visited_dirs.len() > MAX_CORPUS_TREE_DIRS {
128 return Err(eyre!(
129 "corpus tree exceeds directory limit of {MAX_CORPUS_TREE_DIRS}: {}",
130 path.display()
131 ));
132 }
133
134 for replay_dir in canonical_replay_dirs(&dir) {
135 for entry in read_corpus_dir(&replay_dir) {
136 if seen_entries.insert((entry.uuid, entry.timestamp)) {
137 entries.push(entry);
138 if entries.len() > MAX_CORPUS_ENTRIES {
139 return Err(eyre!(
140 "corpus tree exceeds entry limit of {MAX_CORPUS_ENTRIES}: {}",
141 path.display()
142 ));
143 }
144 }
145 }
146 }
147
148 if depth >= MAX_CORPUS_TREE_DEPTH {
149 debug!(?dir, "skipping corpus tree directory beyond depth limit");
150 continue;
151 }
152 let children = match std::fs::read_dir(&dir) {
153 Ok(children) => children,
154 Err(err) => {
155 debug!(%err, ?dir, "failed to read corpus tree directory");
156 continue;
157 }
158 };
159 for child in children {
160 let Ok(child) =
161 child.inspect_err(|err| debug!(%err, "failed to read corpus tree entry"))
162 else {
163 continue;
164 };
165 let child_path = child.path();
166 let Ok(child_type) = child
167 .file_type()
168 .inspect_err(|err| debug!(%err, "failed to read corpus tree entry type"))
169 else {
170 continue;
171 };
172 if child_type.is_dir() {
173 stack.push((child_path, depth + 1));
174 }
175 }
176 }
177
178 entries.sort_by(|a, b| a.path.cmp(&b.path));
179 Ok(entries)
180}
181
182fn is_dir_no_symlink(path: &Path) -> bool {
183 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
184}
185
186fn strip_suffix_ci<'a>(name: &'a str, suffix: &str) -> Option<&'a str> {
188 let split = name.len().checked_sub(suffix.len())?;
189 name.is_char_boundary(split)
190 .then(|| name.split_at(split))
191 .filter(|(_, tail)| tail.eq_ignore_ascii_case(suffix))
192 .map(|(head, _)| head)
193}
194
195pub fn parse_corpus_filename(name: &str) -> Result<(Uuid, u64)> {
200 let name = strip_suffix_ci(name, ".gz").unwrap_or(name);
201 let name = strip_suffix_ci(name, ".json").unwrap_or(name);
202 let (uuid_str, timestamp_str) =
203 name.rsplit_once('-').ok_or_else(|| eyre!("invalid corpus filename format: {name}"))?;
204 Ok((Uuid::parse_str(uuid_str)?, timestamp_str.parse()?))
205}
206
207pub fn canonical_replay_dirs(root: &Path) -> Vec<PathBuf> {
209 let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
210 .into_iter()
211 .flatten()
212 .flatten()
213 .filter_map(|e| {
214 let p = e.path();
215 let name = p.file_name()?.to_str()?;
216 (e.file_type().ok()?.is_dir() && name.starts_with(WORKER_DIR_PREFIX))
217 .then(|| p.join(CORPUS_SUBDIR))
218 .filter(|d| is_dir_no_symlink(d))
219 })
220 .collect();
221 dirs.sort();
222 if dirs.is_empty() {
223 dirs.push(root.to_path_buf());
224 }
225 dirs
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn temp_dir() -> PathBuf {
233 let dir = std::env::temp_dir().join(format!("foundry-corpus-io-{}", Uuid::new_v4()));
234 std::fs::create_dir_all(&dir).unwrap();
235 dir
236 }
237
238 #[test]
239 fn read_corpus_tree_finds_generated_layout() {
240 let dir = temp_dir();
241 let corpus = dir.join("ExampleTest").join("testFuzz_value").join("worker0").join("corpus");
242 std::fs::create_dir_all(&corpus).unwrap();
243 let entry = corpus.join("00000000-0000-0000-0000-000000000001-1.json");
244 std::fs::write(&entry, "[]").unwrap();
245
246 let entries = read_corpus_tree(&dir).unwrap();
247 assert_eq!(entries.len(), 1);
248 assert_eq!(entries[0].path, entry);
249 }
250
251 #[test]
252 fn read_corpus_tree_dedups_worker_entries_by_uuid() {
253 let dir = temp_dir();
254 let name = "00000000-0000-0000-0000-000000000001-1.json";
255 for worker in ["worker0", "worker1"] {
256 let corpus = dir.join("ExampleTest").join("testFuzz_value").join(worker).join("corpus");
257 std::fs::create_dir_all(&corpus).unwrap();
258 std::fs::write(corpus.join(name), "[]").unwrap();
259 }
260
261 let entries = read_corpus_tree(&dir).unwrap();
262 assert_eq!(entries.len(), 1);
263 }
264
265 #[test]
266 fn read_corpus_tree_keeps_same_uuid_with_different_timestamps() {
267 let dir = temp_dir();
268 for timestamp in [1, 2] {
269 let name = format!("00000000-0000-0000-0000-000000000001-{timestamp}.json");
270 std::fs::write(dir.join(name), "[]").unwrap();
271 }
272
273 let entries = read_corpus_tree(&dir).unwrap();
274 assert_eq!(entries.len(), 2);
275 assert_eq!(entries[0].timestamp, 1);
276 assert_eq!(entries[1].timestamp, 2);
277 }
278
279 #[cfg(unix)]
280 #[test]
281 fn read_corpus_tree_rejects_top_level_symlink() {
282 let dir = temp_dir();
283 let corpus = dir.join("corpus");
284 let link = dir.join("link");
285 std::fs::create_dir_all(&corpus).unwrap();
286 std::os::unix::fs::symlink(&corpus, &link).unwrap();
287
288 let err = match read_corpus_tree(&link) {
289 Ok(_) => panic!("top-level symlink should be rejected"),
290 Err(err) => err.to_string(),
291 };
292 assert!(err.contains("must not be a symlink"), "{err}");
293 }
294
295 #[cfg(unix)]
296 #[test]
297 fn read_corpus_tree_skips_symlinked_directories() {
298 let dir = temp_dir();
299 let corpus = dir.join("corpus");
300 let outside = dir.join("outside");
301 std::fs::create_dir_all(&corpus).unwrap();
302 std::fs::create_dir_all(&outside).unwrap();
303 let outside_entry = outside.join("00000000-0000-0000-0000-000000000001-1.json");
304 std::fs::write(&outside_entry, "[]").unwrap();
305 std::os::unix::fs::symlink(&outside, corpus.join("link")).unwrap();
306
307 let entries = read_corpus_tree(&corpus).unwrap();
308 assert!(
309 entries.is_empty(),
310 "{:?}",
311 entries.iter().map(|entry| &entry.path).collect::<Vec<_>>()
312 );
313 }
314
315 #[test]
316 fn parse_corpus_filename_is_case_insensitive_for_extensions() {
317 let uuid = "00000000-0000-0000-0000-000000000001";
318 let (parsed_uuid, ts) = parse_corpus_filename(&format!("{uuid}-7.JSON.GZ")).unwrap();
319 assert_eq!(parsed_uuid, Uuid::parse_str(uuid).unwrap());
320 assert_eq!(ts, 7);
321
322 let (parsed_uuid, ts) = parse_corpus_filename(&format!("{uuid}-9.Json")).unwrap();
323 assert_eq!(parsed_uuid, Uuid::parse_str(uuid).unwrap());
324 assert_eq!(ts, 9);
325 }
326
327 #[test]
328 fn read_corpus_tree_discovers_uppercase_extensions() {
329 let dir = temp_dir();
330 let corpus = dir.join("ExampleTest").join("testFuzz_value").join("worker0").join("corpus");
331 std::fs::create_dir_all(&corpus).unwrap();
332 let entry = corpus.join("00000000-0000-0000-0000-000000000001-1.JSON.GZ");
333 std::fs::write(&entry, "[]").unwrap();
334
335 let entries = read_corpus_tree(&dir).unwrap();
336 assert_eq!(entries.len(), 1);
337 assert_eq!(entries[0].path, entry);
338 }
339
340 #[test]
341 fn read_corpus_tree_accepts_explicit_single_file_with_arbitrary_name() {
342 let dir = temp_dir();
343 let entry = dir.join("min.json");
344 std::fs::write(&entry, "[]").unwrap();
345
346 let entries = read_corpus_tree(&entry).unwrap();
347 assert_eq!(entries.len(), 1);
348 assert_eq!(entries[0].path, entry);
349 assert_eq!(entries[0].uuid, Uuid::nil());
350 assert_eq!(entries[0].timestamp, 0);
351 }
352}