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/// A single corpus file on disk.
18pub 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
43/// Iterate corpus files in `path`, ignoring entries with unparsable names.
44pub 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
73/// Reads every parseable corpus entry while surfacing directory and file-type failures.
74pub(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
93/// Reads corpus files from a file, corpus directory, worker corpus directory, or generated corpus
94/// root such as `<root>/<contract>/<test>/worker0/corpus`.
95pub 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
186/// Strips a trailing `suffix` from `name`, comparing case-insensitively.
187fn 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
195/// Parses a corpus filename of the form `<uuid>-<timestamp>.json[.gz]`.
196///
197/// The `.json` / `.gz` extensions are matched case-insensitively so corpus files
198/// written with upper-case extensions are still discovered.
199pub 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
207/// Returns every `worker*/corpus/` under `root`, or `[root]` if none exist.
208pub 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}