1use crate::executors::{Executor, RawCallResult, invariant::execute_tx};
38use alloy_dyn_abi::JsonAbiExt;
39use alloy_json_abi::Function;
40use alloy_primitives::{Bytes, I256};
41use eyre::{Result, eyre};
42use foundry_common::sh_warn;
43use foundry_config::FuzzCorpusConfig;
44use foundry_evm_core::evm::FoundryEvmNetwork;
45use foundry_evm_fuzz::{
46 BasicTxDetails,
47 invariant::FuzzRunIdentifiedContracts,
48 strategies::{EvmFuzzState, mutate_param_value},
49};
50use proptest::{
51 prelude::{Just, Rng, Strategy},
52 prop_oneof,
53 strategy::{BoxedStrategy, ValueTree},
54 test_runner::TestRunner,
55};
56use serde::{Deserialize, Serialize};
57use std::{
58 fmt,
59 path::{Path, PathBuf},
60 sync::{
61 Arc,
62 atomic::{AtomicUsize, Ordering},
63 },
64 time::{SystemTime, UNIX_EPOCH},
65};
66use uuid::Uuid;
67
68const WORKER: &str = "worker";
69const CORPUS_DIR: &str = "corpus";
70const SYNC_DIR: &str = "sync";
71const OPTIMIZATION_BEST_FILE: &str = "optimization_best.json";
72
73const FAVORABILITY_THRESHOLD: f64 = 0.3;
74const COVERAGE_MAP_SIZE: usize = 65536;
75
76const GZIP_THRESHOLD: usize = 4 * 1024;
79
80#[derive(Debug, Clone)]
82enum MutationType {
83 Splice,
85 Repeat,
87 Interleave,
89 Prefix,
91 Suffix,
93 Abi,
95}
96
97#[derive(Clone, Serialize, Deserialize)]
99struct OptimizationState {
100 best_value: I256,
101 best_sequence: Vec<BasicTxDetails>,
102}
103
104#[derive(Clone, Serialize)]
106struct CorpusEntry {
107 uuid: Uuid,
109 total_mutations: usize,
111 new_finds_produced: usize,
113 #[serde(skip_serializing)]
115 tx_seq: Vec<BasicTxDetails>,
116 is_favored: bool,
119 #[serde(skip_serializing)]
121 timestamp: u64,
122}
123
124impl CorpusEntry {
125 pub fn new(tx_seq: Vec<BasicTxDetails>) -> Self {
127 Self::new_with(tx_seq, Uuid::new_v4())
128 }
129
130 pub fn new_existing(tx_seq: Vec<BasicTxDetails>, path: PathBuf) -> Result<Self> {
133 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
134 eyre::bail!("invalid corpus file path: {path:?}");
135 };
136 let uuid = parse_corpus_filename(name)?.0;
137 Ok(Self::new_with(tx_seq, uuid))
138 }
139
140 pub fn new_with(tx_seq: Vec<BasicTxDetails>, uuid: Uuid) -> Self {
142 Self {
143 uuid,
144 total_mutations: 0,
145 new_finds_produced: 0,
146 tx_seq,
147 is_favored: false,
148 timestamp: SystemTime::now()
149 .duration_since(UNIX_EPOCH)
150 .expect("time went backwards")
151 .as_secs(),
152 }
153 }
154
155 fn write_to_disk_in(&self, dir: &Path, can_gzip: bool) -> foundry_common::fs::Result<()> {
156 let file_name = self.file_name(can_gzip);
157 let path = dir.join(file_name);
158 if self.should_gzip(can_gzip) {
159 foundry_common::fs::write_json_gzip_file(&path, &self.tx_seq)
160 } else {
161 foundry_common::fs::write_json_file(&path, &self.tx_seq)
162 }
163 }
164
165 fn file_name(&self, can_gzip: bool) -> String {
166 let ext = if self.should_gzip(can_gzip) { ".json.gz" } else { ".json" };
167 format!("{}-{}{ext}", self.uuid, self.timestamp)
168 }
169
170 fn should_gzip(&self, can_gzip: bool) -> bool {
171 if !can_gzip {
172 return false;
173 }
174 let size: usize = self.tx_seq.iter().map(|tx| tx.estimate_serialized_size()).sum();
175 size > GZIP_THRESHOLD
176 }
177}
178
179#[derive(Default)]
180pub(crate) struct GlobalCorpusMetrics {
181 cumulative_edges_seen: AtomicUsize,
183 cumulative_features_seen: AtomicUsize,
185 corpus_count: AtomicUsize,
187 favored_items: AtomicUsize,
189}
190
191impl fmt::Display for GlobalCorpusMetrics {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 self.load().fmt(f)
194 }
195}
196
197impl GlobalCorpusMetrics {
198 pub(crate) fn load(&self) -> CorpusMetrics {
199 CorpusMetrics {
200 cumulative_edges_seen: self.cumulative_edges_seen.load(Ordering::Relaxed),
201 cumulative_features_seen: self.cumulative_features_seen.load(Ordering::Relaxed),
202 corpus_count: self.corpus_count.load(Ordering::Relaxed),
203 favored_items: self.favored_items.load(Ordering::Relaxed),
204 }
205 }
206}
207
208#[derive(Serialize, Default, Clone)]
209pub(crate) struct CorpusMetrics {
210 cumulative_edges_seen: usize,
212 cumulative_features_seen: usize,
214 corpus_count: usize,
216 favored_items: usize,
218}
219
220impl fmt::Display for CorpusMetrics {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 writeln!(f)?;
223 writeln!(f, " - cumulative edges seen: {}", self.cumulative_edges_seen)?;
224 writeln!(f, " - cumulative features seen: {}", self.cumulative_features_seen)?;
225 writeln!(f, " - corpus count: {}", self.corpus_count)?;
226 write!(f, " - favored items: {}", self.favored_items)?;
227 Ok(())
228 }
229}
230
231impl CorpusMetrics {
232 pub fn update_seen(&mut self, is_edge: bool) {
234 if is_edge {
235 self.cumulative_edges_seen += 1;
236 } else {
237 self.cumulative_features_seen += 1;
238 }
239 }
240
241 pub fn update_favored(&mut self, is_favored: bool, corpus_favored: bool) {
243 if is_favored && !corpus_favored {
244 self.favored_items += 1;
245 } else if !is_favored && corpus_favored {
246 self.favored_items -= 1;
247 }
248 }
249}
250
251pub struct WorkerCorpus {
253 id: usize,
255 in_memory_corpus: Vec<CorpusEntry>,
258 history_map: Vec<u8>,
260 pub(crate) failed_replays: usize,
262 pub(crate) metrics: CorpusMetrics,
264 tx_generator: BoxedStrategy<BasicTxDetails>,
266 mutation_generator: BoxedStrategy<MutationType>,
268 current_mutated: Option<Uuid>,
270 config: Arc<FuzzCorpusConfig>,
272 new_entry_indices: Vec<usize>,
274 last_sync_timestamp: u64,
276 worker_dir: Option<PathBuf>,
279 last_sync_metrics: CorpusMetrics,
281 optimization_best_value: Option<I256>,
283 optimization_best_sequence: Vec<BasicTxDetails>,
285}
286
287impl WorkerCorpus {
288 pub fn new<FEN: FoundryEvmNetwork>(
289 id: usize,
290 config: FuzzCorpusConfig,
291 tx_generator: BoxedStrategy<BasicTxDetails>,
292 executor: Option<&Executor<FEN>>,
294 fuzzed_function: Option<&Function>,
295 fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
296 ) -> Result<Self> {
297 let mutation_generator = prop_oneof![
298 Just(MutationType::Splice),
299 Just(MutationType::Repeat),
300 Just(MutationType::Interleave),
301 Just(MutationType::Prefix),
302 Just(MutationType::Suffix),
303 Just(MutationType::Abi),
304 ]
305 .boxed();
306
307 let worker_dir = config.corpus_dir.as_ref().map(|corpus_dir| {
308 let worker_dir = corpus_dir.join(format!("{WORKER}{id}"));
309 let worker_corpus = worker_dir.join(CORPUS_DIR);
310 let sync_dir = worker_dir.join(SYNC_DIR);
311
312 let _ = foundry_common::fs::create_dir_all(&worker_corpus);
314 let _ = foundry_common::fs::create_dir_all(&sync_dir);
315
316 worker_dir
317 });
318
319 let mut in_memory_corpus = vec![];
320 let mut history_map = vec![0u8; COVERAGE_MAP_SIZE];
321 let mut metrics = CorpusMetrics::default();
322 let mut failed_replays = 0;
323 let mut optimization_best_value = None;
324 let mut optimization_best_sequence = vec![];
325
326 if id == 0
327 && let Some(corpus_dir) = &config.corpus_dir
328 {
329 let opt_path = corpus_dir.join(OPTIMIZATION_BEST_FILE);
331 if opt_path.is_file() {
332 match foundry_common::fs::read_json_file::<OptimizationState>(&opt_path) {
333 Ok(state) => {
334 debug!(
335 target: "corpus",
336 "loaded optimization best value {} with sequence len {}",
337 state.best_value,
338 state.best_sequence.len()
339 );
340 optimization_best_value = Some(state.best_value);
341 optimization_best_sequence = state.best_sequence;
342 }
343 Err(err) => {
344 let _ = sh_warn!(
345 "failed to load optimization state from {}: {err}; starting without persisted optimization seed",
346 opt_path.display()
347 );
348 }
349 }
350 }
351
352 if !optimization_best_sequence.is_empty() {
355 in_memory_corpus.push(CorpusEntry::new(optimization_best_sequence.clone()));
356 metrics.corpus_count += 1;
357 }
358
359 let executor = executor.expect("Executor required for master worker");
362 'corpus_replay: for entry in read_corpus_dir(corpus_dir) {
363 let tx_seq = entry.read_tx_seq()?;
364 if tx_seq.is_empty() {
365 continue;
366 }
367 let mut executor = executor.clone();
369 for tx in &tx_seq {
370 if Self::can_replay_tx(tx, fuzzed_function, fuzzed_contracts) {
371 let mut call_result = execute_tx(&mut executor, tx)?;
372 let (new_coverage, is_edge) =
373 call_result.merge_edge_coverage(&mut history_map);
374 if new_coverage {
375 metrics.update_seen(is_edge);
376 }
377
378 if fuzzed_contracts.is_some() {
380 executor.commit(&mut call_result);
381 }
382 } else {
383 failed_replays += 1;
384
385 if fuzzed_function.is_some() {
388 continue 'corpus_replay;
389 }
390 }
391 }
392
393 metrics.corpus_count += 1;
394
395 debug!(
396 target: "corpus",
397 "load sequence with len {} from corpus file {}",
398 tx_seq.len(),
399 entry.path.display()
400 );
401
402 in_memory_corpus.push(CorpusEntry::new_with(tx_seq, entry.uuid));
404 }
405 }
406
407 Ok(Self {
408 id,
409 in_memory_corpus,
410 history_map,
411 failed_replays,
412 metrics,
413 tx_generator,
414 mutation_generator,
415 current_mutated: None,
416 config: config.into(),
417 new_entry_indices: Default::default(),
418 last_sync_timestamp: 0,
419 worker_dir,
420 last_sync_metrics: Default::default(),
421 optimization_best_value,
422 optimization_best_sequence,
423 })
424 }
425
426 #[instrument(skip_all)]
430 pub fn process_inputs(
431 &mut self,
432 inputs: &[BasicTxDetails],
433 new_coverage: bool,
434 optimization: Option<(I256, Vec<BasicTxDetails>)>,
435 ) {
436 let Some(worker_corpus) = &self.worker_dir else {
437 return;
438 };
439 let worker_corpus = worker_corpus.join(CORPUS_DIR);
440
441 let improved_optimization = optimization.as_ref().is_some_and(|(value, _)| {
443 self.optimization_best_value.is_none_or(|best| *value > best)
444 });
445
446 if let Some(uuid) = &self.current_mutated {
448 let should_credit = new_coverage || improved_optimization;
449 if let Some(corpus) =
450 self.in_memory_corpus.iter_mut().find(|corpus| corpus.uuid == *uuid)
451 {
452 corpus.total_mutations += 1;
453 if should_credit {
454 corpus.new_finds_produced += 1
455 }
456 let is_favored = (corpus.new_finds_produced as f64 / corpus.total_mutations as f64)
457 > FAVORABILITY_THRESHOLD;
458 self.metrics.update_favored(is_favored, corpus.is_favored);
459 corpus.is_favored = is_favored;
460
461 trace!(
462 target: "corpus",
463 "updated corpus {}, total mutations: {}, new finds: {}",
464 corpus.uuid, corpus.total_mutations, corpus.new_finds_produced
465 );
466 }
467
468 self.current_mutated = None;
469 }
470
471 if let Some((value, best_seq)) = optimization
473 && improved_optimization
474 {
475 self.optimization_best_value = Some(value);
476 self.optimization_best_sequence = best_seq;
477 self.persist_optimization_state();
478 }
479
480 if !new_coverage && !improved_optimization {
482 return;
483 }
484
485 assert!(!inputs.is_empty());
489 let corpus_inputs = if improved_optimization && !new_coverage {
490 self.optimization_best_sequence.clone()
491 } else {
492 inputs.to_vec()
493 };
494 let corpus = CorpusEntry::new(corpus_inputs);
495
496 let write_result = corpus.write_to_disk_in(&worker_corpus, self.config.corpus_gzip);
498 if let Err(err) = write_result {
499 debug!(target: "corpus", %err, "failed to record call sequence {:?}", corpus.tx_seq);
500 } else {
501 trace!(
502 target: "corpus",
503 "persisted {} inputs for new coverage for {} corpus",
504 corpus.tx_seq.len(),
505 corpus.uuid,
506 );
507 }
508
509 let new_index = self.in_memory_corpus.len();
511 self.new_entry_indices.push(new_index);
512
513 self.metrics.corpus_count += 1;
516 self.in_memory_corpus.push(corpus);
517 }
518
519 pub fn optimization_initial_state(&self) -> (Option<I256>, Vec<BasicTxDetails>) {
521 (self.optimization_best_value, self.optimization_best_sequence.clone())
522 }
523
524 fn persist_optimization_state(&self) {
526 let Some(value) = self.optimization_best_value else {
527 return;
528 };
529 let Some(corpus_dir) = &self.config.corpus_dir else {
530 return;
531 };
532 let state = OptimizationState {
533 best_value: value,
534 best_sequence: self.optimization_best_sequence.clone(),
535 };
536 let path = corpus_dir.join(OPTIMIZATION_BEST_FILE);
537 if let Err(err) = foundry_common::fs::write_json_file(&path, &state) {
538 debug!(target: "corpus", %err, "failed to persist optimization state");
539 } else {
540 trace!(
541 target: "corpus",
542 "persisted optimization best value {} with sequence len {}",
543 value,
544 self.optimization_best_sequence.len()
545 );
546 }
547 }
548
549 pub fn merge_edge_coverage<FEN: FoundryEvmNetwork>(
551 &mut self,
552 call_result: &mut RawCallResult<FEN>,
553 ) -> bool {
554 if !self.config.collect_edge_coverage() {
555 return false;
556 }
557
558 let (new_coverage, is_edge) = call_result.merge_edge_coverage(&mut self.history_map);
559 if new_coverage {
560 self.metrics.update_seen(is_edge);
561 }
562 new_coverage
563 }
564
565 #[instrument(skip_all)]
568 pub fn new_inputs(
569 &mut self,
570 test_runner: &mut TestRunner,
571 fuzz_state: &EvmFuzzState,
572 targeted_contracts: &FuzzRunIdentifiedContracts,
573 ) -> Result<Vec<BasicTxDetails>> {
574 let mut new_seq = vec![];
575
576 if !self.config.is_coverage_guided() {
579 new_seq.push(self.new_tx(test_runner)?);
580 return Ok(new_seq);
581 };
582
583 if !self.in_memory_corpus.is_empty() {
584 self.evict_oldest_corpus()?;
585
586 let mutation_type = self
587 .mutation_generator
588 .new_tree(test_runner)
589 .map_err(|err| eyre!("Could not generate mutation type {err}"))?
590 .current();
591
592 let rng = test_runner.rng();
593 let corpus_len = self.in_memory_corpus.len();
594 let primary = &self.in_memory_corpus[rng.random_range(0..corpus_len)];
595 let secondary = &self.in_memory_corpus[rng.random_range(0..corpus_len)];
596
597 match mutation_type {
598 MutationType::Splice => {
599 trace!(target: "corpus", "splice {} and {}", primary.uuid, secondary.uuid);
600
601 self.current_mutated = Some(primary.uuid);
602
603 let start1 = rng.random_range(0..primary.tx_seq.len());
604 let end1 = rng.random_range(start1..primary.tx_seq.len());
605
606 let start2 = rng.random_range(0..secondary.tx_seq.len());
607 let end2 = rng.random_range(start2..secondary.tx_seq.len());
608
609 for tx in primary.tx_seq.iter().take(end1).skip(start1) {
610 new_seq.push(tx.clone());
611 }
612 for tx in secondary.tx_seq.iter().take(end2).skip(start2) {
613 new_seq.push(tx.clone());
614 }
615 }
616 MutationType::Repeat => {
617 let corpus = if rng.random::<bool>() { primary } else { secondary };
618 trace!(target: "corpus", "repeat {}", corpus.uuid);
619
620 self.current_mutated = Some(corpus.uuid);
621
622 new_seq = corpus.tx_seq.clone();
623 let start = rng.random_range(0..corpus.tx_seq.len());
624 let end = rng.random_range(start..corpus.tx_seq.len());
625 let item_idx = rng.random_range(0..corpus.tx_seq.len());
626 let repeated = vec![new_seq[item_idx].clone(); end - start];
627 new_seq.splice(start..end, repeated);
628 }
629 MutationType::Interleave => {
630 trace!(target: "corpus", "interleave {} with {}", primary.uuid, secondary.uuid);
631
632 self.current_mutated = Some(primary.uuid);
633
634 for (tx1, tx2) in primary.tx_seq.iter().zip(secondary.tx_seq.iter()) {
635 let tx = if rng.random::<bool>() { tx1.clone() } else { tx2.clone() };
637 new_seq.push(tx);
638 }
639 }
640 MutationType::Prefix => {
641 let corpus = if rng.random::<bool>() { primary } else { secondary };
642 trace!(target: "corpus", "overwrite prefix of {}", corpus.uuid);
643
644 self.current_mutated = Some(corpus.uuid);
645
646 new_seq = corpus.tx_seq.clone();
647 for i in 0..rng.random_range(0..=new_seq.len()) {
648 new_seq[i] = self.new_tx(test_runner)?;
649 }
650 }
651 MutationType::Suffix => {
652 let corpus = if rng.random::<bool>() { primary } else { secondary };
653 trace!(target: "corpus", "overwrite suffix of {}", corpus.uuid);
654
655 self.current_mutated = Some(corpus.uuid);
656
657 new_seq = corpus.tx_seq.clone();
658 for i in new_seq.len() - rng.random_range(0..new_seq.len())..corpus.tx_seq.len()
659 {
660 new_seq[i] = self.new_tx(test_runner)?;
661 }
662 }
663 MutationType::Abi => {
664 let targets = targeted_contracts.targets.lock();
665 let corpus = if rng.random::<bool>() { primary } else { secondary };
666 trace!(target: "corpus", "ABI mutate args of {}", corpus.uuid);
667
668 self.current_mutated = Some(corpus.uuid);
669
670 new_seq = corpus.tx_seq.clone();
671
672 let idx = rng.random_range(0..new_seq.len());
673 let tx = new_seq.get_mut(idx).unwrap();
674 if let (_, Some(function)) = targets.fuzzed_artifacts(tx) {
675 if !function.inputs.is_empty() {
678 self.abi_mutate(tx, function, test_runner, fuzz_state)?;
679 }
680 }
681 }
682 }
683 }
684
685 if new_seq.is_empty() {
687 new_seq.push(self.new_tx(test_runner)?);
688 }
689 trace!(target: "corpus", "new sequence of {} calls generated", new_seq.len());
690
691 Ok(new_seq)
692 }
693
694 #[instrument(skip_all)]
697 pub fn new_input(
698 &mut self,
699 test_runner: &mut TestRunner,
700 fuzz_state: &EvmFuzzState,
701 function: &Function,
702 ) -> Result<Bytes> {
703 if !self.config.is_coverage_guided() {
705 return Ok(self.new_tx(test_runner)?.call_details.calldata);
706 }
707
708 self.evict_oldest_corpus()?;
709
710 let tx = if self.in_memory_corpus.is_empty() {
711 self.new_tx(test_runner)?
712 } else {
713 let corpus = &self.in_memory_corpus
714 [test_runner.rng().random_range(0..self.in_memory_corpus.len())];
715 self.current_mutated = Some(corpus.uuid);
716 let mut tx = corpus.tx_seq.first().unwrap().clone();
717 self.abi_mutate(&mut tx, function, test_runner, fuzz_state)?;
718 tx
719 };
720
721 Ok(tx.call_details.calldata)
722 }
723
724 pub fn new_tx(&self, test_runner: &mut TestRunner) -> Result<BasicTxDetails> {
726 Ok(self
727 .tx_generator
728 .new_tree(test_runner)
729 .map_err(|_| eyre!("Could not generate case"))?
730 .current())
731 }
732
733 pub fn generate_next_input(
740 &mut self,
741 test_runner: &mut TestRunner,
742 sequence: &[BasicTxDetails],
743 discarded: bool,
744 depth: usize,
745 ) -> Result<BasicTxDetails> {
746 if self.config.corpus_dir.is_none() || discarded {
749 return self.new_tx(test_runner);
750 }
751
752 if depth > sequence.len().saturating_sub(1) || test_runner.rng().random_ratio(1, 10) {
755 return self.new_tx(test_runner);
756 }
757
758 Ok(sequence[depth].clone())
760 }
761
762 fn evict_oldest_corpus(&mut self) -> Result<()> {
765 if self.in_memory_corpus.len() > self.config.corpus_min_size.max(1)
766 && let Some(index) = self.in_memory_corpus.iter().position(|corpus| {
767 corpus.total_mutations > self.config.corpus_min_mutations && !corpus.is_favored
768 })
769 {
770 let corpus = &self.in_memory_corpus[index];
771
772 trace!(target: "corpus", corpus=%serde_json::to_string(&corpus).unwrap(), "evict corpus");
773
774 self.in_memory_corpus.remove(index);
776
777 self.new_entry_indices.retain_mut(|i| {
779 if *i > index {
780 *i -= 1; true } else {
783 *i != index }
785 });
786 }
787 Ok(())
788 }
789
790 fn abi_mutate(
793 &self,
794 tx: &mut BasicTxDetails,
795 function: &Function,
796 test_runner: &mut TestRunner,
797 fuzz_state: &EvmFuzzState,
798 ) -> Result<()> {
799 let mut arg_mutation_rounds =
801 test_runner.rng().random_range(0..=function.inputs.len()).max(1);
802 let round_arg_idx: Vec<usize> = if function.inputs.len() <= 1 {
803 vec![0]
804 } else {
805 (0..arg_mutation_rounds)
806 .map(|_| test_runner.rng().random_range(0..function.inputs.len()))
807 .collect()
808 };
809 let mut prev_inputs = function
810 .abi_decode_input(&tx.call_details.calldata[4..])
811 .map_err(|err| eyre!("failed to load previous inputs: {err}"))?;
812
813 while arg_mutation_rounds > 0 {
814 let idx = round_arg_idx[arg_mutation_rounds - 1];
815 prev_inputs[idx] = mutate_param_value(
816 &function
817 .inputs
818 .get(idx)
819 .expect("Could not get input to mutate")
820 .selector_type()
821 .parse()?,
822 prev_inputs[idx].clone(),
823 test_runner,
824 fuzz_state,
825 );
826 arg_mutation_rounds -= 1;
827 }
828
829 tx.call_details.calldata =
830 function.abi_encode_input(&prev_inputs).map_err(|e| eyre!(e.to_string()))?.into();
831 Ok(())
832 }
833
834 fn load_sync_corpus(&self) -> Result<Vec<(CorpusDirEntry, Vec<BasicTxDetails>)>> {
839 let Some(worker_dir) = &self.worker_dir else {
840 return Ok(vec![]);
841 };
842
843 let sync_dir = worker_dir.join(SYNC_DIR);
844 if !sync_dir.is_dir() {
845 return Ok(vec![]);
846 }
847
848 let mut imports = vec![];
849 for entry in read_corpus_dir(&sync_dir) {
850 if entry.timestamp <= self.last_sync_timestamp {
851 continue;
852 }
853 let tx_seq = entry.read_tx_seq()?;
854 if tx_seq.is_empty() {
855 warn!(target: "corpus", "skipping empty corpus entry: {}", entry.path.display());
856 continue;
857 }
858 imports.push((entry, tx_seq));
859 }
860
861 if !imports.is_empty() {
862 debug!(target: "corpus", "imported {} new corpus entries", imports.len());
863 }
864
865 Ok(imports)
866 }
867
868 #[instrument(skip_all)]
871 fn calibrate<FEN: FoundryEvmNetwork>(
872 &mut self,
873 executor: &Executor<FEN>,
874 fuzzed_function: Option<&Function>,
875 fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
876 ) -> Result<()> {
877 let Some(worker_dir) = &self.worker_dir else {
878 return Ok(());
879 };
880 let corpus_dir = worker_dir.join(CORPUS_DIR);
881
882 let mut executor = executor.clone();
883 for (entry, tx_seq) in self.load_sync_corpus()? {
884 let mut new_coverage_on_sync = false;
885 for tx in &tx_seq {
886 if !Self::can_replay_tx(tx, fuzzed_function, fuzzed_contracts) {
887 continue;
888 }
889
890 let mut call_result = execute_tx(&mut executor, tx)?;
891
892 let (new_coverage, is_edge) =
894 call_result.merge_edge_coverage(&mut self.history_map);
895
896 if new_coverage {
897 self.metrics.update_seen(is_edge);
898 new_coverage_on_sync = true;
899 }
900
901 if fuzzed_contracts.is_some() {
903 executor.commit(&mut call_result);
904 }
905
906 trace!(
907 target: "corpus",
908 %new_coverage,
909 ?tx,
910 "replayed tx for syncing",
911 );
912 }
913
914 let sync_path = &entry.path;
915 if new_coverage_on_sync {
916 let corpus_path = corpus_dir.join(sync_path.components().next_back().unwrap());
918 if let Err(err) = std::fs::rename(sync_path, &corpus_path) {
919 debug!(target: "corpus", %err, "failed to move synced corpus from {sync_path:?} to {corpus_path:?} dir");
920 continue;
921 }
922
923 debug!(
924 target: "corpus",
925 name=%entry.name(),
926 "moved synced corpus to corpus dir",
927 );
928
929 let corpus_entry = CorpusEntry::new_existing(tx_seq.clone(), entry.path.clone())?;
930 self.in_memory_corpus.push(corpus_entry);
931 } else {
932 if let Err(err) = std::fs::remove_file(&entry.path) {
934 debug!(target: "corpus", %err, "failed to remove synced corpus from {sync_path:?}");
935 continue;
936 }
937 trace!(target: "corpus", "removed synced corpus from {sync_path:?}");
938 }
939 }
940
941 Ok(())
942 }
943
944 #[instrument(skip_all)]
946 fn export_to_master(&self) -> Result<()> {
947 assert_ne!(self.id, 0, "non-master only");
949
950 if self.new_entry_indices.is_empty() || self.worker_dir.is_none() {
952 return Ok(());
953 }
954
955 let worker_dir = self.worker_dir.as_ref().unwrap();
956 let Some(master_sync_dir) = self
957 .config
958 .corpus_dir
959 .as_ref()
960 .map(|dir| dir.join(format!("{WORKER}0")).join(SYNC_DIR))
961 else {
962 return Ok(());
963 };
964
965 let mut exported = 0;
966 let corpus_dir = worker_dir.join(CORPUS_DIR);
967
968 for &index in &self.new_entry_indices {
969 let Some(corpus) = self.in_memory_corpus.get(index) else { continue };
970 let file_name = corpus.file_name(self.config.corpus_gzip);
971 let file_path = corpus_dir.join(&file_name);
972 let sync_path = master_sync_dir.join(&file_name);
973 if let Err(err) = std::fs::hard_link(&file_path, &sync_path) {
974 debug!(target: "corpus", %err, "failed to export corpus {}", corpus.uuid);
975 continue;
976 }
977 exported += 1;
978 }
979
980 debug!(target: "corpus", "exported {exported} new corpus entries");
981
982 Ok(())
983 }
984
985 #[instrument(skip_all)]
987 fn export_to_workers(&mut self, num_workers: usize) -> Result<()> {
988 assert_eq!(self.id, 0, "master worker only");
989 if self.worker_dir.is_none() {
990 return Ok(());
991 }
992
993 let worker_dir = self.worker_dir.as_ref().unwrap();
994 let master_corpus_dir = worker_dir.join(CORPUS_DIR);
995 let filtered_master_corpus = read_corpus_dir(&master_corpus_dir)
996 .filter(|entry| entry.timestamp > self.last_sync_timestamp)
997 .collect::<Vec<_>>();
998 let mut any_distributed = false;
999 for target_worker in 1..num_workers {
1000 let target_dir = self
1001 .config
1002 .corpus_dir
1003 .as_ref()
1004 .unwrap()
1005 .join(format!("{WORKER}{target_worker}"))
1006 .join(SYNC_DIR);
1007
1008 if !target_dir.is_dir() {
1009 foundry_common::fs::create_dir_all(&target_dir)?;
1010 }
1011
1012 for entry in &filtered_master_corpus {
1013 let name = entry.name();
1014 let sync_path = target_dir.join(name);
1015 if let Err(err) = std::fs::hard_link(&entry.path, &sync_path) {
1016 debug!(target: "corpus", %err, from=?entry.path, to=?sync_path, "failed to distribute corpus");
1017 continue;
1018 }
1019 any_distributed = true;
1020 trace!(target: "corpus", %name, ?target_dir, "distributed corpus");
1021 }
1022 }
1023
1024 debug!(target: "corpus", %any_distributed, "distributed master corpus to all workers");
1025
1026 Ok(())
1027 }
1028
1029 pub(crate) fn sync_metrics(&mut self, global_corpus_metrics: &GlobalCorpusMetrics) {
1032 let edges_delta = self
1034 .metrics
1035 .cumulative_edges_seen
1036 .saturating_sub(self.last_sync_metrics.cumulative_edges_seen);
1037 let features_delta = self
1038 .metrics
1039 .cumulative_features_seen
1040 .saturating_sub(self.last_sync_metrics.cumulative_features_seen);
1041 let corpus_count_delta =
1043 self.metrics.corpus_count as isize - self.last_sync_metrics.corpus_count as isize;
1044 let favored_delta =
1045 self.metrics.favored_items as isize - self.last_sync_metrics.favored_items as isize;
1046
1047 if edges_delta > 0 {
1050 global_corpus_metrics.cumulative_edges_seen.fetch_add(edges_delta, Ordering::Relaxed);
1051 }
1052 if features_delta > 0 {
1053 global_corpus_metrics
1054 .cumulative_features_seen
1055 .fetch_add(features_delta, Ordering::Relaxed);
1056 }
1057
1058 if corpus_count_delta > 0 {
1059 global_corpus_metrics
1060 .corpus_count
1061 .fetch_add(corpus_count_delta as usize, Ordering::Relaxed);
1062 } else if corpus_count_delta < 0 {
1063 global_corpus_metrics
1064 .corpus_count
1065 .fetch_sub((-corpus_count_delta) as usize, Ordering::Relaxed);
1066 }
1067
1068 if favored_delta > 0 {
1069 global_corpus_metrics
1070 .favored_items
1071 .fetch_add(favored_delta as usize, Ordering::Relaxed);
1072 } else if favored_delta < 0 {
1073 global_corpus_metrics
1074 .favored_items
1075 .fetch_sub((-favored_delta) as usize, Ordering::Relaxed);
1076 }
1077
1078 self.last_sync_metrics = self.metrics.clone();
1080 }
1081
1082 #[instrument(skip_all)]
1084 pub fn sync<FEN: FoundryEvmNetwork>(
1085 &mut self,
1086 num_workers: usize,
1087 executor: &Executor<FEN>,
1088 fuzzed_function: Option<&Function>,
1089 fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
1090 global_corpus_metrics: &GlobalCorpusMetrics,
1091 ) -> Result<()> {
1092 trace!(target: "corpus", "syncing");
1093
1094 self.sync_metrics(global_corpus_metrics);
1095
1096 self.calibrate(executor, fuzzed_function, fuzzed_contracts)?;
1097 if self.id == 0 {
1098 self.export_to_workers(num_workers)?;
1099 } else {
1100 self.export_to_master()?;
1101 }
1102
1103 let last_sync = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
1104 self.last_sync_timestamp = last_sync;
1105
1106 self.new_entry_indices.clear();
1107
1108 debug!(target: "corpus", last_sync, "synced");
1109
1110 Ok(())
1111 }
1112
1113 fn can_replay_tx(
1115 tx: &BasicTxDetails,
1116 fuzzed_function: Option<&Function>,
1117 fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
1118 ) -> bool {
1119 fuzzed_contracts.is_some_and(|contracts| contracts.targets.lock().can_replay(tx))
1120 || fuzzed_function.is_some_and(|function| {
1121 tx.call_details
1122 .calldata
1123 .get(..4)
1124 .is_some_and(|selector| function.selector() == selector)
1125 })
1126 }
1127}
1128
1129fn read_corpus_dir(path: &Path) -> impl Iterator<Item = CorpusDirEntry> {
1130 let dir = match std::fs::read_dir(path) {
1131 Ok(dir) => dir,
1132 Err(err) => {
1133 debug!(%err, ?path, "failed to read corpus directory");
1134 return vec![].into_iter();
1135 }
1136 };
1137 dir.filter_map(|res| match res {
1138 Ok(entry) => {
1139 let path = entry.path();
1140 if !path.is_file() {
1141 return None;
1142 }
1143 let name = if path.is_file()
1144 && let Some(name) = path.file_name()
1145 && let Some(name) = name.to_str()
1146 {
1147 name
1148 } else {
1149 return None;
1150 };
1151
1152 if let Ok((uuid, timestamp)) = parse_corpus_filename(name) {
1153 Some(CorpusDirEntry { path, uuid, timestamp })
1154 } else {
1155 debug!(target: "corpus", ?path, "failed to parse corpus filename");
1156 None
1157 }
1158 }
1159 Err(err) => {
1160 debug!(%err, "failed to read corpus directory entry");
1161 None
1162 }
1163 })
1164 .collect::<Vec<_>>()
1165 .into_iter()
1166}
1167
1168struct CorpusDirEntry {
1169 path: PathBuf,
1170 uuid: Uuid,
1171 timestamp: u64,
1172}
1173
1174impl CorpusDirEntry {
1175 fn name(&self) -> &str {
1176 self.path.file_name().unwrap().to_str().unwrap()
1177 }
1178
1179 fn read_tx_seq(&self) -> foundry_common::fs::Result<Vec<BasicTxDetails>> {
1180 let path = &self.path;
1181 if path.extension() == Some("gz".as_ref()) {
1182 foundry_common::fs::read_json_gzip_file(path)
1183 } else {
1184 foundry_common::fs::read_json_file(path)
1185 }
1186 }
1187}
1188
1189fn parse_corpus_filename(name: &str) -> Result<(Uuid, u64)> {
1191 let name = name.trim_end_matches(".gz").trim_end_matches(".json");
1192
1193 let (uuid_str, timestamp_str) =
1194 name.rsplit_once('-').ok_or_else(|| eyre!("invalid corpus filename format: {name}"))?;
1195
1196 let uuid = Uuid::parse_str(uuid_str)?;
1197 let timestamp = timestamp_str.parse()?;
1198
1199 Ok((uuid, timestamp))
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204 use super::*;
1205 use alloy_primitives::Address;
1206 use std::fs;
1207
1208 fn basic_tx() -> BasicTxDetails {
1209 BasicTxDetails {
1210 warp: None,
1211 roll: None,
1212 sender: Address::ZERO,
1213 call_details: foundry_evm_fuzz::CallDetails {
1214 target: Address::ZERO,
1215 calldata: Bytes::new(),
1216 },
1217 }
1218 }
1219
1220 fn temp_corpus_dir() -> PathBuf {
1221 let dir = std::env::temp_dir().join(format!("foundry-corpus-tests-{}", Uuid::new_v4()));
1222 let _ = fs::create_dir_all(&dir);
1223 dir
1224 }
1225
1226 fn new_manager_with_single_corpus() -> (WorkerCorpus, Uuid) {
1227 let tx_gen = Just(basic_tx()).boxed();
1228 let config = FuzzCorpusConfig {
1229 corpus_dir: Some(temp_corpus_dir()),
1230 corpus_gzip: false,
1231 corpus_min_mutations: 0,
1232 corpus_min_size: 0,
1233 ..Default::default()
1234 };
1235
1236 let tx_seq = vec![basic_tx()];
1237 let corpus = CorpusEntry::new(tx_seq);
1238 let seed_uuid = corpus.uuid;
1239
1240 let corpus_root = config.corpus_dir.clone().unwrap();
1242 let worker_subdir = corpus_root.join("worker0");
1243 let _ = fs::create_dir_all(&worker_subdir);
1244
1245 let manager = WorkerCorpus {
1246 id: 0,
1247 tx_generator: tx_gen,
1248 mutation_generator: Just(MutationType::Repeat).boxed(),
1249 config: config.into(),
1250 in_memory_corpus: vec![corpus],
1251 current_mutated: Some(seed_uuid),
1252 failed_replays: 0,
1253 history_map: vec![0u8; COVERAGE_MAP_SIZE],
1254 metrics: CorpusMetrics::default(),
1255 new_entry_indices: Default::default(),
1256 last_sync_timestamp: 0,
1257 worker_dir: Some(corpus_root),
1258 last_sync_metrics: CorpusMetrics::default(),
1259 optimization_best_value: None,
1260 optimization_best_sequence: vec![],
1261 };
1262
1263 (manager, seed_uuid)
1264 }
1265
1266 #[test]
1267 fn favored_sets_true_and_metrics_increment_when_ratio_gt_threshold() {
1268 let (mut manager, uuid) = new_manager_with_single_corpus();
1269 let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
1270 corpus.total_mutations = 4;
1271 corpus.new_finds_produced = 2; corpus.is_favored = false;
1273
1274 assert_eq!(manager.metrics.favored_items, 0);
1276
1277 manager.current_mutated = Some(uuid);
1279 manager.process_inputs(&[basic_tx()], true, None);
1280
1281 let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
1282 assert!(corpus.is_favored, "expected favored to be true when ratio > threshold");
1283 assert_eq!(
1284 manager.metrics.favored_items, 1,
1285 "favored_items should increment on false→true"
1286 );
1287 }
1288
1289 #[test]
1290 fn favored_sets_false_and_metrics_decrement_when_ratio_lt_threshold() {
1291 let (mut manager, uuid) = new_manager_with_single_corpus();
1292 let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
1293 corpus.total_mutations = 9;
1294 corpus.new_finds_produced = 3; corpus.is_favored = true; manager.metrics.favored_items = 1;
1298
1299 manager.current_mutated = Some(uuid);
1301 manager.process_inputs(&[basic_tx()], false, None);
1302
1303 let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
1304 assert!(!corpus.is_favored, "expected favored to be false when ratio < threshold");
1305 assert_eq!(
1306 manager.metrics.favored_items, 0,
1307 "favored_items should decrement on true→false"
1308 );
1309 }
1310
1311 #[test]
1312 fn favored_is_false_on_ratio_equal_threshold() {
1313 let (mut manager, uuid) = new_manager_with_single_corpus();
1314 let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
1315 corpus.total_mutations = 9;
1317 corpus.new_finds_produced = 2;
1318 corpus.is_favored = false;
1319
1320 manager.current_mutated = Some(uuid);
1321 manager.process_inputs(&[basic_tx()], true, None);
1322
1323 let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
1324 assert!(
1325 !(corpus.is_favored),
1326 "with strict '>' comparison, favored must be false when ratio == threshold"
1327 );
1328 }
1329
1330 #[test]
1331 fn eviction_skips_favored_and_evicts_non_favored() {
1332 let tx_gen = Just(basic_tx()).boxed();
1334 let config = FuzzCorpusConfig {
1335 corpus_dir: Some(temp_corpus_dir()),
1336 corpus_min_mutations: 0,
1337 corpus_min_size: 0,
1338 ..Default::default()
1339 };
1340
1341 let mut favored = CorpusEntry::new(vec![basic_tx()]);
1342 favored.total_mutations = 2;
1343 favored.is_favored = true;
1344
1345 let mut non_favored = CorpusEntry::new(vec![basic_tx()]);
1346 non_favored.total_mutations = 2;
1347 non_favored.is_favored = false;
1348 let non_favored_uuid = non_favored.uuid;
1349
1350 let corpus_root = temp_corpus_dir();
1351 let worker_subdir = corpus_root.join("worker0");
1352 fs::create_dir_all(&worker_subdir).unwrap();
1353
1354 let mut manager = WorkerCorpus {
1355 id: 0,
1356 tx_generator: tx_gen,
1357 mutation_generator: Just(MutationType::Repeat).boxed(),
1358 config: config.into(),
1359 in_memory_corpus: vec![favored, non_favored],
1360 current_mutated: None,
1361 failed_replays: 0,
1362 history_map: vec![0u8; COVERAGE_MAP_SIZE],
1363 metrics: CorpusMetrics::default(),
1364 new_entry_indices: Default::default(),
1365 last_sync_timestamp: 0,
1366 worker_dir: Some(corpus_root),
1367 last_sync_metrics: CorpusMetrics::default(),
1368 optimization_best_value: None,
1369 optimization_best_sequence: vec![],
1370 };
1371
1372 manager.evict_oldest_corpus().unwrap();
1374 assert_eq!(manager.in_memory_corpus.len(), 1);
1375 assert!(manager.in_memory_corpus.iter().all(|c| c.is_favored));
1376
1377 manager.evict_oldest_corpus().unwrap();
1379 assert_eq!(manager.in_memory_corpus.len(), 1, "favored corpus must not be evicted");
1380
1381 assert!(manager.in_memory_corpus.iter().all(|c| c.uuid != non_favored_uuid));
1383 }
1384}