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 fn canonical_replay_dirs(root: &Path) -> Vec<PathBuf> {
19 let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
20 .into_iter()
21 .flatten()
22 .flatten()
23 .filter_map(|e| {
24 let p = e.path();
25 let name = p.file_name()?.to_str()?;
26 (e.file_type().ok()?.is_dir() && name.starts_with(WORKER_DIR_PREFIX))
27 .then(|| p.join(CORPUS_SUBDIR))
28 .filter(|d| is_dir_no_symlink(d))
29 })
30 .collect();
31 dirs.sort();
32 if dirs.is_empty() {
33 dirs.push(root.to_path_buf());
34 }
35 dirs
36}
37
38pub struct CorpusDirEntry {
40 pub path: PathBuf,
41 pub uuid: Uuid,
42 pub timestamp: u64,
43}
44
45impl CorpusDirEntry {
46 pub fn name(&self) -> &str {
47 self.path.file_name().unwrap().to_str().unwrap()
48 }
49
50 pub fn read_tx_seq(&self) -> foundry_common::fs::Result<Vec<BasicTxDetails>> {
51 if self
52 .path
53 .extension()
54 .and_then(|extension| extension.to_str())
55 .is_some_and(|extension| extension.eq_ignore_ascii_case("gz"))
56 {
57 foundry_common::fs::read_json_gzip_file(&self.path)
58 } else {
59 foundry_common::fs::read_json_file(&self.path)
60 }
61 }
62}
63
64pub fn read_corpus_dir(path: &Path) -> impl Iterator<Item = CorpusDirEntry> {
66 let dir = match std::fs::read_dir(path) {
67 Ok(dir) => dir,
68 Err(err) => {
69 debug!(%err, ?path, "failed to read corpus directory");
70 return vec![].into_iter();
71 }
72 };
73
74 dir.filter_map(|res| {
75 let entry =
76 res.inspect_err(|err| debug!(%err, "failed to read corpus directory entry")).ok()?;
77 let path = entry.path();
78 if !entry.file_type().ok()?.is_file() {
79 return None;
80 }
81 let name = path.file_name()?.to_str()?;
82 match parse_corpus_filename(name) {
83 Ok((uuid, timestamp)) => Some(CorpusDirEntry { path, uuid, timestamp }),
84 Err(_) => {
85 debug!(target: "corpus", ?path, "failed to parse corpus filename");
86 None
87 }
88 }
89 })
90 .collect::<Vec<_>>()
91 .into_iter()
92}
93
94pub(crate) fn read_corpus_dir_strict(path: &Path) -> Result<Vec<CorpusDirEntry>> {
96 let mut entries = Vec::new();
97 for entry in std::fs::read_dir(path)? {
98 let entry = entry?;
99 let path = entry.path();
100 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
101 continue;
102 };
103 let Ok((uuid, timestamp)) = parse_corpus_filename(name) else {
104 continue;
105 };
106 if !entry.file_type()?.is_file() {
107 return Err(eyre!("corpus entry is not a regular file: {}", path.display()));
108 }
109 entries.push(CorpusDirEntry { path, uuid, timestamp });
110 }
111 Ok(entries)
112}
113
114pub fn read_corpus_tree(path: &Path) -> Result<Vec<CorpusDirEntry>> {
117 let metadata = std::fs::symlink_metadata(path)
118 .map_err(|_| eyre!("corpus path does not exist or is not readable: {}", path.display()))?;
119 let file_type = metadata.file_type();
120 if file_type.is_symlink() {
121 return Err(eyre!("corpus path must not be a symlink: {}", path.display()));
122 }
123 if file_type.is_file() {
124 let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
125 let (uuid, timestamp) = parse_corpus_filename(name).unwrap_or((Uuid::nil(), 0));
126 return Ok(vec![CorpusDirEntry { path: path.to_path_buf(), uuid, timestamp }]);
127 }
128
129 if !file_type.is_dir() {
130 return Err(eyre!("corpus path does not exist or is not readable: {}", path.display()));
131 }
132
133 let mut seen_entries = HashSet::new();
134 let mut visited_dirs = HashSet::new();
135 let mut entries = Vec::new();
136 let mut stack = vec![(path.to_path_buf(), 0usize)];
137 while let Some((dir, depth)) = stack.pop() {
138 let canonical = match dir.canonicalize() {
139 Ok(canonical) => canonical,
140 Err(err) => {
141 debug!(%err, ?dir, "failed to canonicalize corpus tree directory");
142 continue;
143 }
144 };
145 if !visited_dirs.insert(canonical) {
146 continue;
147 }
148 if visited_dirs.len() > MAX_CORPUS_TREE_DIRS {
149 return Err(eyre!(
150 "corpus tree exceeds directory limit of {MAX_CORPUS_TREE_DIRS}: {}",
151 path.display()
152 ));
153 }
154
155 for replay_dir in canonical_replay_dirs(&dir) {
156 for entry in read_corpus_dir(&replay_dir) {
157 if seen_entries.insert((entry.uuid, entry.timestamp)) {
158 entries.push(entry);
159 if entries.len() > MAX_CORPUS_ENTRIES {
160 return Err(eyre!(
161 "corpus tree exceeds entry limit of {MAX_CORPUS_ENTRIES}: {}",
162 path.display()
163 ));
164 }
165 }
166 }
167 }
168
169 if depth >= MAX_CORPUS_TREE_DEPTH {
170 debug!(?dir, "skipping corpus tree directory beyond depth limit");
171 continue;
172 }
173 let children = match std::fs::read_dir(&dir) {
174 Ok(children) => children,
175 Err(err) => {
176 debug!(%err, ?dir, "failed to read corpus tree directory");
177 continue;
178 }
179 };
180 for child in children {
181 let Ok(child) =
182 child.inspect_err(|err| debug!(%err, "failed to read corpus tree entry"))
183 else {
184 continue;
185 };
186 let child_path = child.path();
187 let Ok(child_type) = child
188 .file_type()
189 .inspect_err(|err| debug!(%err, "failed to read corpus tree entry type"))
190 else {
191 continue;
192 };
193 if child_type.is_dir() {
194 stack.push((child_path, depth + 1));
195 }
196 }
197 }
198
199 entries.sort_by(|a, b| a.path.cmp(&b.path));
200 Ok(entries)
201}
202
203fn is_dir_no_symlink(path: &Path) -> bool {
204 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
205}
206
207fn strip_suffix_ci<'a>(name: &'a str, suffix: &str) -> Option<&'a str> {
209 let split = name.len().checked_sub(suffix.len())?;
210 name.is_char_boundary(split)
211 .then(|| name.split_at(split))
212 .filter(|(_, tail)| tail.eq_ignore_ascii_case(suffix))
213 .map(|(head, _)| head)
214}
215
216pub fn parse_corpus_filename(name: &str) -> Result<(Uuid, u64)> {
221 let name = strip_suffix_ci(name, ".gz").unwrap_or(name);
222 let name = strip_suffix_ci(name, ".json").unwrap_or(name);
223 let (uuid_str, timestamp_str) =
224 name.rsplit_once('-').ok_or_else(|| eyre!("invalid corpus filename format: {name}"))?;
225 Ok((Uuid::parse_str(uuid_str)?, timestamp_str.parse()?))
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}