foundry_evm/executors/
corpus_io.rs1use 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 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
187fn 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
196pub 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}