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 corpus files from a file, corpus directory, worker corpus directory, or generated corpus
95/// root such as `<root>/<contract>/<test>/worker0/corpus`.
96pub fn read_corpus_tree(path: &Path) -> Result<Vec<CorpusDirEntry>> {
97    let metadata = std::fs::symlink_metadata(path)
98        .map_err(|_| eyre!("corpus path does not exist or is not readable: {}", path.display()))?;
99    let file_type = metadata.file_type();
100    if file_type.is_symlink() {
101        return Err(eyre!("corpus path must not be a symlink: {}", path.display()));
102    }
103    if file_type.is_file() {
104        let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
105        let (uuid, timestamp) = parse_corpus_filename(name).unwrap_or((Uuid::nil(), 0));
106        return Ok(vec![CorpusDirEntry { path: path.to_path_buf(), uuid, timestamp }]);
107    }
108
109    if !file_type.is_dir() {
110        return Err(eyre!("corpus path does not exist or is not readable: {}", path.display()));
111    }
112
113    let mut seen_entries = HashSet::new();
114    let mut visited_dirs = HashSet::new();
115    let mut entries = Vec::new();
116    let mut stack = vec![(path.to_path_buf(), 0usize)];
117    while let Some((dir, depth)) = stack.pop() {
118        let canonical = match dir.canonicalize() {
119            Ok(canonical) => canonical,
120            Err(err) => {
121                debug!(%err, ?dir, "failed to canonicalize corpus tree directory");
122                continue;
123            }
124        };
125        if !visited_dirs.insert(canonical) {
126            continue;
127        }
128        if visited_dirs.len() > MAX_CORPUS_TREE_DIRS {
129            return Err(eyre!(
130                "corpus tree exceeds directory limit of {MAX_CORPUS_TREE_DIRS}: {}",
131                path.display()
132            ));
133        }
134
135        for replay_dir in canonical_replay_dirs(&dir) {
136            for entry in read_corpus_dir(&replay_dir) {
137                if seen_entries.insert((entry.uuid, entry.timestamp)) {
138                    entries.push(entry);
139                    if entries.len() > MAX_CORPUS_ENTRIES {
140                        return Err(eyre!(
141                            "corpus tree exceeds entry limit of {MAX_CORPUS_ENTRIES}: {}",
142                            path.display()
143                        ));
144                    }
145                }
146            }
147        }
148
149        if depth >= MAX_CORPUS_TREE_DEPTH {
150            debug!(?dir, "skipping corpus tree directory beyond depth limit");
151            continue;
152        }
153        let children = match std::fs::read_dir(&dir) {
154            Ok(children) => children,
155            Err(err) => {
156                debug!(%err, ?dir, "failed to read corpus tree directory");
157                continue;
158            }
159        };
160        for child in children {
161            let Ok(child) =
162                child.inspect_err(|err| debug!(%err, "failed to read corpus tree entry"))
163            else {
164                continue;
165            };
166            let child_path = child.path();
167            let Ok(child_type) = child
168                .file_type()
169                .inspect_err(|err| debug!(%err, "failed to read corpus tree entry type"))
170            else {
171                continue;
172            };
173            if child_type.is_dir() {
174                stack.push((child_path, depth + 1));
175            }
176        }
177    }
178
179    entries.sort_by(|a, b| a.path.cmp(&b.path));
180    Ok(entries)
181}
182
183fn is_dir_no_symlink(path: &Path) -> bool {
184    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
185}
186
187/// Strips a trailing `suffix` from `name`, comparing case-insensitively.
188fn strip_suffix_ci<'a>(name: &'a str, suffix: &str) -> Option<&'a str> {
189    let split = name.len().checked_sub(suffix.len())?;
190    name.is_char_boundary(split)
191        .then(|| name.split_at(split))
192        .filter(|(_, tail)| tail.eq_ignore_ascii_case(suffix))
193        .map(|(head, _)| head)
194}
195
196/// Parses a corpus filename of the form `<uuid>-<timestamp>.json[.gz]`.
197///
198/// The `.json` / `.gz` extensions are matched case-insensitively so corpus files
199/// written with upper-case extensions are still discovered.
200pub fn parse_corpus_filename(name: &str) -> Result<(Uuid, u64)> {
201    let name = strip_suffix_ci(name, ".gz").unwrap_or(name);
202    let name = strip_suffix_ci(name, ".json").unwrap_or(name);
203    let (uuid_str, timestamp_str) =
204        name.rsplit_once('-').ok_or_else(|| eyre!("invalid corpus filename format: {name}"))?;
205    Ok((Uuid::parse_str(uuid_str)?, timestamp_str.parse()?))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn temp_dir() -> PathBuf {
213        let dir = std::env::temp_dir().join(format!("foundry-corpus-io-{}", Uuid::new_v4()));
214        std::fs::create_dir_all(&dir).unwrap();
215        dir
216    }
217
218    #[test]
219    fn read_corpus_tree_finds_generated_layout() {
220        let dir = temp_dir();
221        let corpus = dir.join("ExampleTest").join("testFuzz_value").join("worker0").join("corpus");
222        std::fs::create_dir_all(&corpus).unwrap();
223        let entry = corpus.join("00000000-0000-0000-0000-000000000001-1.json");
224        std::fs::write(&entry, "[]").unwrap();
225
226        let entries = read_corpus_tree(&dir).unwrap();
227        assert_eq!(entries.len(), 1);
228        assert_eq!(entries[0].path, entry);
229    }
230
231    #[test]
232    fn read_corpus_tree_dedups_worker_entries_by_uuid() {
233        let dir = temp_dir();
234        let name = "00000000-0000-0000-0000-000000000001-1.json";
235        for worker in ["worker0", "worker1"] {
236            let corpus = dir.join("ExampleTest").join("testFuzz_value").join(worker).join("corpus");
237            std::fs::create_dir_all(&corpus).unwrap();
238            std::fs::write(corpus.join(name), "[]").unwrap();
239        }
240
241        let entries = read_corpus_tree(&dir).unwrap();
242        assert_eq!(entries.len(), 1);
243    }
244
245    #[test]
246    fn read_corpus_tree_keeps_same_uuid_with_different_timestamps() {
247        let dir = temp_dir();
248        for timestamp in [1, 2] {
249            let name = format!("00000000-0000-0000-0000-000000000001-{timestamp}.json");
250            std::fs::write(dir.join(name), "[]").unwrap();
251        }
252
253        let entries = read_corpus_tree(&dir).unwrap();
254        assert_eq!(entries.len(), 2);
255        assert_eq!(entries[0].timestamp, 1);
256        assert_eq!(entries[1].timestamp, 2);
257    }
258
259    #[cfg(unix)]
260    #[test]
261    fn read_corpus_tree_rejects_top_level_symlink() {
262        let dir = temp_dir();
263        let corpus = dir.join("corpus");
264        let link = dir.join("link");
265        std::fs::create_dir_all(&corpus).unwrap();
266        std::os::unix::fs::symlink(&corpus, &link).unwrap();
267
268        let err = match read_corpus_tree(&link) {
269            Ok(_) => panic!("top-level symlink should be rejected"),
270            Err(err) => err.to_string(),
271        };
272        assert!(err.contains("must not be a symlink"), "{err}");
273    }
274
275    #[cfg(unix)]
276    #[test]
277    fn read_corpus_tree_skips_symlinked_directories() {
278        let dir = temp_dir();
279        let corpus = dir.join("corpus");
280        let outside = dir.join("outside");
281        std::fs::create_dir_all(&corpus).unwrap();
282        std::fs::create_dir_all(&outside).unwrap();
283        let outside_entry = outside.join("00000000-0000-0000-0000-000000000001-1.json");
284        std::fs::write(&outside_entry, "[]").unwrap();
285        std::os::unix::fs::symlink(&outside, corpus.join("link")).unwrap();
286
287        let entries = read_corpus_tree(&corpus).unwrap();
288        assert!(
289            entries.is_empty(),
290            "{:?}",
291            entries.iter().map(|entry| &entry.path).collect::<Vec<_>>()
292        );
293    }
294
295    #[test]
296    fn parse_corpus_filename_is_case_insensitive_for_extensions() {
297        let uuid = "00000000-0000-0000-0000-000000000001";
298        let (parsed_uuid, ts) = parse_corpus_filename(&format!("{uuid}-7.JSON.GZ")).unwrap();
299        assert_eq!(parsed_uuid, Uuid::parse_str(uuid).unwrap());
300        assert_eq!(ts, 7);
301
302        let (parsed_uuid, ts) = parse_corpus_filename(&format!("{uuid}-9.Json")).unwrap();
303        assert_eq!(parsed_uuid, Uuid::parse_str(uuid).unwrap());
304        assert_eq!(ts, 9);
305    }
306
307    #[test]
308    fn read_corpus_tree_discovers_uppercase_extensions() {
309        let dir = temp_dir();
310        let corpus = dir.join("ExampleTest").join("testFuzz_value").join("worker0").join("corpus");
311        std::fs::create_dir_all(&corpus).unwrap();
312        let entry = corpus.join("00000000-0000-0000-0000-000000000001-1.JSON.GZ");
313        std::fs::write(&entry, "[]").unwrap();
314
315        let entries = read_corpus_tree(&dir).unwrap();
316        assert_eq!(entries.len(), 1);
317        assert_eq!(entries[0].path, entry);
318    }
319
320    #[test]
321    fn read_corpus_tree_accepts_explicit_single_file_with_arbitrary_name() {
322        let dir = temp_dir();
323        let entry = dir.join("min.json");
324        std::fs::write(&entry, "[]").unwrap();
325
326        let entries = read_corpus_tree(&entry).unwrap();
327        assert_eq!(entries.len(), 1);
328        assert_eq!(entries[0].path, entry);
329        assert_eq!(entries[0].uuid, Uuid::nil());
330        assert_eq!(entries[0].timestamp, 0);
331    }
332}