Skip to main content

foundry_evm/executors/
corpus_io.rs

1//! Shared helpers for reading on-disk corpus directories.
2
3use 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
17/// Returns every `worker*/corpus/` under `root`, or `[root]` if none exist.
18pub 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
38/// A single corpus file on disk.
39pub 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
64/// Iterate corpus files in `path`, ignoring entries with unparsable names.
65pub 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
94/// Reads every parseable corpus entry while surfacing directory and file-type failures.
95pub(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
114/// Reads corpus files from a file, corpus directory, worker corpus directory, or generated corpus
115/// root such as `<root>/<contract>/<test>/worker0/corpus`.
116pub 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
207/// Strips a trailing `suffix` from `name`, comparing case-insensitively.
208fn 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
216/// Parses a corpus filename of the form `<uuid>-<timestamp>.json[.gz]`.
217///
218/// The `.json` / `.gz` extensions are matched case-insensitively so corpus files
219/// written with upper-case extensions are still discovered.
220pub 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}