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 const 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 const 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 sancov_history_map: Vec<u8>,
262 pub(crate) failed_replays: usize,
264 pub(crate) metrics: CorpusMetrics,
266 tx_generator: BoxedStrategy<BasicTxDetails>,
268 mutation_generator: BoxedStrategy<MutationType>,
270 current_mutated: Option<Uuid>,
272 config: Arc<FuzzCorpusConfig>,
274 new_entry_indices: Vec<usize>,
276 last_sync_timestamp: u64,
278 worker_dir: Option<PathBuf>,
281 last_sync_metrics: CorpusMetrics,
283 optimization_best_value: Option<I256>,
285 optimization_best_sequence: Vec<BasicTxDetails>,
287}
288
289impl WorkerCorpus {
290 pub fn new<FEN: FoundryEvmNetwork>(
291 id: usize,
292 config: FuzzCorpusConfig,
293 tx_generator: BoxedStrategy<BasicTxDetails>,
294 executor: Option<&Executor<FEN>>,
296 fuzzed_function: Option<&Function>,
297 fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
298 ) -> Result<Self> {
299 let mutation_generator = prop_oneof![
300 Just(MutationType::Splice),
301 Just(MutationType::Repeat),
302 Just(MutationType::Interleave),
303 Just(MutationType::Prefix),
304 Just(MutationType::Suffix),
305 Just(MutationType::Abi),
306 ]
307 .boxed();
308
309 let worker_dir = config.corpus_dir.as_ref().map(|corpus_dir| {
310 let worker_dir = corpus_dir.join(format!("{WORKER}{id}"));
311 let worker_corpus = worker_dir.join(CORPUS_DIR);
312 let sync_dir = worker_dir.join(SYNC_DIR);
313
314 let _ = foundry_common::fs::create_dir_all(&worker_corpus);
316 let _ = foundry_common::fs::create_dir_all(&sync_dir);
317
318 worker_dir
319 });
320
321 let mut in_memory_corpus = vec![];
322 let mut history_map = vec![0u8; COVERAGE_MAP_SIZE];
323 let mut sancov_history_map = vec![0u8; COVERAGE_MAP_SIZE];
324 let mut metrics = CorpusMetrics::default();
325 let mut failed_replays = 0;
326 let mut optimization_best_value = None;
327 let mut optimization_best_sequence = vec![];
328
329 if id == 0
330 && let Some(corpus_dir) = &config.corpus_dir
331 {
332 let opt_path = corpus_dir.join(OPTIMIZATION_BEST_FILE);
334 if opt_path.is_file() {
335 match foundry_common::fs::read_json_file::<OptimizationState>(&opt_path) {
336 Ok(state) => {
337 debug!(
338 target: "corpus",
339 "loaded optimization best value {} with sequence len {}",
340 state.best_value,
341 state.best_sequence.len()
342 );
343 optimization_best_value = Some(state.best_value);
344 optimization_best_sequence = state.best_sequence;
345 }
346 Err(err) => {
347 let _ = sh_warn!(
348 "failed to load optimization state from {}: {err}; starting without persisted optimization seed",
349 opt_path.display()
350 );
351 }
352 }
353 }
354
355 if !optimization_best_sequence.is_empty() {
358 in_memory_corpus.push(CorpusEntry::new(optimization_best_sequence.clone()));
359 metrics.corpus_count += 1;
360 }
361
362 let executor = executor.expect("Executor required for master worker");
365 'corpus_replay: for entry in read_corpus_dir(corpus_dir) {
366 let tx_seq = entry.read_tx_seq()?;
367 if tx_seq.is_empty() {
368 continue;
369 }
370 let mut executor = executor.clone();
372 for tx in &tx_seq {
373 if Self::can_replay_tx(tx, fuzzed_function, fuzzed_contracts) {
374 let mut call_result = execute_tx(&mut executor, tx)?;
375 let (new_coverage, is_edge) = call_result
376 .merge_all_coverage(&mut history_map, &mut sancov_history_map);
377 if new_coverage {
378 metrics.update_seen(is_edge);
379 }
380
381 if fuzzed_contracts.is_some() {
383 executor.commit(&mut call_result);
384 }
385 } else {
386 failed_replays += 1;
387
388 if fuzzed_function.is_some() {
391 continue 'corpus_replay;
392 }
393 }
394 }
395
396 metrics.corpus_count += 1;
397
398 debug!(
399 target: "corpus",
400 "load sequence with len {} from corpus file {}",
401 tx_seq.len(),
402 entry.path.display()
403 );
404
405 in_memory_corpus.push(CorpusEntry::new_with(tx_seq, entry.uuid));
407 }
408 }
409
410 Ok(Self {
411 id,
412 in_memory_corpus,
413 history_map,
414 sancov_history_map,
415 failed_replays,
416 metrics,
417 tx_generator,
418 mutation_generator,
419 current_mutated: None,
420 config: config.into(),
421 new_entry_indices: Default::default(),
422 last_sync_timestamp: 0,
423 worker_dir,
424 last_sync_metrics: Default::default(),
425 optimization_best_value,
426 optimization_best_sequence,
427 })
428 }
429
430 #[instrument(skip_all)]
434 pub fn process_inputs(
435 &mut self,
436 inputs: &[BasicTxDetails],
437 new_coverage: bool,
438 optimization: Option<(I256, Vec<BasicTxDetails>)>,
439 ) {
440 let Some(worker_corpus) = &self.worker_dir else {
441 return;
442 };
443 let worker_corpus = worker_corpus.join(CORPUS_DIR);
444
445 let improved_optimization = optimization.as_ref().is_some_and(|(value, _)| {
447 self.optimization_best_value.is_none_or(|best| *value > best)
448 });
449
450 if let Some(uuid) = &self.current_mutated {
452 let should_credit = new_coverage || improved_optimization;
453 if let Some(corpus) =
454 self.in_memory_corpus.iter_mut().find(|corpus| corpus.uuid == *uuid)
455 {
456 corpus.total_mutations += 1;
457 if should_credit {
458 corpus.new_finds_produced += 1
459 }
460 let is_favored = (corpus.new_finds_produced as f64 / corpus.total_mutations as f64)
461 > FAVORABILITY_THRESHOLD;
462 self.metrics.update_favored(is_favored, corpus.is_favored);
463 corpus.is_favored = is_favored;
464
465 trace!(
466 target: "corpus",
467 "updated corpus {}, total mutations: {}, new finds: {}",
468 corpus.uuid, corpus.total_mutations, corpus.new_finds_produced
469 );
470 }
471
472 self.current_mutated = None;
473 }
474
475 if let Some((value, best_seq)) = optimization
477 && improved_optimization
478 {
479 self.optimization_best_value = Some(value);
480 self.optimization_best_sequence = best_seq;
481 self.persist_optimization_state();
482 }
483
484 if !new_coverage && !improved_optimization {
486 return;
487 }
488
489 assert!(!inputs.is_empty());
493 let corpus_inputs = if improved_optimization && !new_coverage {
494 self.optimization_best_sequence.clone()
495 } else {
496 inputs.to_vec()
497 };
498 let corpus = CorpusEntry::new(corpus_inputs);
499
500 let write_result = corpus.write_to_disk_in(&worker_corpus, self.config.corpus_gzip);
502 if let Err(err) = write_result {
503 debug!(target: "corpus", %err, "failed to record call sequence {:?}", corpus.tx_seq);
504 } else {
505 trace!(
506 target: "corpus",
507 "persisted {} inputs for new coverage for {} corpus",
508 corpus.tx_seq.len(),
509 corpus.uuid,
510 );
511 }
512
513 let new_index = self.in_memory_corpus.len();
515 self.new_entry_indices.push(new_index);
516
517 self.metrics.corpus_count += 1;
520 self.in_memory_corpus.push(corpus);
521 }
522
523 pub fn optimization_initial_state(&self) -> (Option<I256>, Vec<BasicTxDetails>) {
525 (self.optimization_best_value, self.optimization_best_sequence.clone())
526 }
527
528 fn persist_optimization_state(&self) {
530 let Some(value) = self.optimization_best_value else {
531 return;
532 };
533 let Some(corpus_dir) = &self.config.corpus_dir else {
534 return;
535 };
536 let state = OptimizationState {
537 best_value: value,
538 best_sequence: self.optimization_best_sequence.clone(),
539 };
540 let path = corpus_dir.join(OPTIMIZATION_BEST_FILE);
541 if let Err(err) = foundry_common::fs::write_json_file(&path, &state) {
542 debug!(target: "corpus", %err, "failed to persist optimization state");
543 } else {
544 trace!(
545 target: "corpus",
546 "persisted optimization best value {} with sequence len {}",
547 value,
548 self.optimization_best_sequence.len()
549 );
550 }
551 }
552
553 pub fn merge_edge_coverage<FEN: FoundryEvmNetwork>(
555 &mut self,
556 call_result: &mut RawCallResult<FEN>,
557 ) -> bool {
558 if !self.config.collect_edge_coverage() {
559 return false;
560 }
561
562 let (new_coverage, is_edge) =
563 call_result.merge_all_coverage(&mut self.history_map, &mut self.sancov_history_map);
564 if new_coverage {
565 self.metrics.update_seen(is_edge);
566 }
567 new_coverage
568 }
569
570 #[instrument(skip_all)]
573 pub fn new_inputs(
574 &mut self,
575 test_runner: &mut TestRunner,
576 fuzz_state: &EvmFuzzState,
577 targeted_contracts: &FuzzRunIdentifiedContracts,
578 ) -> Result<Vec<BasicTxDetails>> {
579 let mut new_seq = vec![];
580
581 if !self.config.is_coverage_guided() {
584 new_seq.push(self.new_tx(test_runner)?);
585 return Ok(new_seq);
586 };
587
588 if !self.in_memory_corpus.is_empty() {
589 self.evict_oldest_corpus()?;
590
591 let mutation_type = self
592 .mutation_generator
593 .new_tree(test_runner)
594 .map_err(|err| eyre!("Could not generate mutation type {err}"))?
595 .current();
596
597 let rng = test_runner.rng();
598 let corpus_len = self.in_memory_corpus.len();
599 let primary = &self.in_memory_corpus[rng.random_range(0..corpus_len)];
600 let secondary = &self.in_memory_corpus[rng.random_range(0..corpus_len)];
601
602 match mutation_type {
603 MutationType::Splice => {
604 trace!(target: "corpus", "splice {} and {}", primary.uuid, secondary.uuid);
605
606 self.current_mutated = Some(primary.uuid);
607
608 let start1 = rng.random_range(0..primary.tx_seq.len());
609 let end1 = rng.random_range(start1..primary.tx_seq.len());
610
611 let start2 = rng.random_range(0..secondary.tx_seq.len());
612 let end2 = rng.random_range(start2..secondary.tx_seq.len());
613
614 for tx in primary.tx_seq.iter().take(end1).skip(start1) {
615 new_seq.push(tx.clone());
616 }
617 for tx in secondary.tx_seq.iter().take(end2).skip(start2) {
618 new_seq.push(tx.clone());
619 }
620 }
621 MutationType::Repeat => {
622 let corpus = if rng.random::<bool>() { primary } else { secondary };
623 trace!(target: "corpus", "repeat {}", corpus.uuid);
624
625 self.current_mutated = Some(corpus.uuid);
626
627 new_seq = corpus.tx_seq.clone();
628 let start = rng.random_range(0..corpus.tx_seq.len());
629 let end = rng.random_range(start..corpus.tx_seq.len());
630 let item_idx = rng.random_range(0..corpus.tx_seq.len());
631 let repeated = vec![new_seq[item_idx].clone(); end - start];
632 new_seq.splice(start..end, repeated);
633 }
634 MutationType::Interleave => {
635 trace!(target: "corpus", "interleave {} with {}", primary.uuid, secondary.uuid);
636
637 self.current_mutated = Some(primary.uuid);
638
639 for (tx1, tx2) in primary.tx_seq.iter().zip(secondary.tx_seq.iter()) {
640 let tx = if rng.random::<bool>() { tx1.clone() } else { tx2.clone() };
642 new_seq.push(tx);
643 }
644 }
645 MutationType::Prefix => {
646 let corpus = if rng.random::<bool>() { primary } else { secondary };
647 trace!(target: "corpus", "overwrite prefix of {}", corpus.uuid);
648
649 self.current_mutated = Some(corpus.uuid);
650
651 new_seq = corpus.tx_seq.clone();
652 for i in 0..rng.random_range(0..=new_seq.len()) {
653 new_seq[i] = self.new_tx(test_runner)?;
654 }
655 }
656 MutationType::Suffix => {
657 let corpus = if rng.random::<bool>() { primary } else { secondary };
658 trace!(target: "corpus", "overwrite suffix of {}", corpus.uuid);
659
660 self.current_mutated = Some(corpus.uuid);
661
662 new_seq = corpus.tx_seq.clone();
663 for i in new_seq.len() - rng.random_range(0..new_seq.len())..corpus.tx_seq.len()
664 {
665 new_seq[i] = self.new_tx(test_runner)?;
666 }
667 }
668 MutationType::Abi => {
669 let targets = targeted_contracts.targets.lock();
670 let corpus = if rng.random::<bool>() { primary } else { secondary };
671 trace!(target: "corpus", "ABI mutate args of {}", corpus.uuid);
672
673 self.current_mutated = Some(corpus.uuid);
674
675 new_seq = corpus.tx_seq.clone();
676
677 let idx = rng.random_range(0..new_seq.len());
678 let tx = new_seq.get_mut(idx).unwrap();
679 if let (_, Some(function)) = targets.fuzzed_artifacts(tx) {
680 if !function.inputs.is_empty() {
683 self.abi_mutate(tx, function, test_runner, fuzz_state)?;
684 }
685 }
686 }
687 }
688 }
689
690 if new_seq.is_empty() {
692 new_seq.push(self.new_tx(test_runner)?);
693 }
694 trace!(target: "corpus", "new sequence of {} calls generated", new_seq.len());
695
696 Ok(new_seq)
697 }
698
699 #[instrument(skip_all)]
702 pub fn new_input(
703 &mut self,
704 test_runner: &mut TestRunner,
705 fuzz_state: &EvmFuzzState,
706 function: &Function,
707 ) -> Result<Bytes> {
708 if !self.config.is_coverage_guided() {
710 return Ok(self.new_tx(test_runner)?.call_details.calldata);
711 }
712
713 self.evict_oldest_corpus()?;
714
715 let tx = if self.in_memory_corpus.is_empty() {
716 self.new_tx(test_runner)?
717 } else {
718 let corpus = &self.in_memory_corpus
719 [test_runner.rng().random_range(0..self.in_memory_corpus.len())];
720 self.current_mutated = Some(corpus.uuid);
721 let mut tx = corpus.tx_seq.first().unwrap().clone();
722 self.abi_mutate(&mut tx, function, test_runner, fuzz_state)?;
723 tx
724 };
725
726 Ok(tx.call_details.calldata)
727 }
728
729 pub fn new_tx(&self, test_runner: &mut TestRunner) -> Result<BasicTxDetails> {
731 Ok(self
732 .tx_generator
733 .new_tree(test_runner)
734 .map_err(|_| eyre!("Could not generate case"))?
735 .current())
736 }
737
738 pub fn generate_next_input(
745 &mut self,
746 test_runner: &mut TestRunner,
747 sequence: &[BasicTxDetails],
748 discarded: bool,
749 depth: usize,
750 ) -> Result<BasicTxDetails> {
751 if self.config.corpus_dir.is_none() || discarded {
754 return self.new_tx(test_runner);
755 }
756
757 if depth > sequence.len().saturating_sub(1) || test_runner.rng().random_ratio(1, 10) {
760 return self.new_tx(test_runner);
761 }
762
763 Ok(sequence[depth].clone())
765 }
766
767 fn evict_oldest_corpus(&mut self) -> Result<()> {
770 if self.in_memory_corpus.len() > self.config.corpus_min_size.max(1)
771 && let Some(index) = self.in_memory_corpus.iter().position(|corpus| {
772 corpus.total_mutations > self.config.corpus_min_mutations && !corpus.is_favored
773 })
774 {
775 let corpus = &self.in_memory_corpus[index];
776
777 trace!(target: "corpus", corpus=%serde_json::to_string(&corpus).unwrap(), "evict corpus");
778
779 self.in_memory_corpus.remove(index);
781
782 self.new_entry_indices.retain_mut(|i| {
784 if *i > index {
785 *i -= 1; true } else {
788 *i != index }
790 });
791 }
792 Ok(())
793 }
794
795 fn abi_mutate(
798 &self,
799 tx: &mut BasicTxDetails,
800 function: &Function,
801 test_runner: &mut TestRunner,
802 fuzz_state: &EvmFuzzState,
803 ) -> Result<()> {
804 let mut arg_mutation_rounds =
806 test_runner.rng().random_range(0..=function.inputs.len()).max(1);
807 let round_arg_idx: Vec<usize> = if function.inputs.len() <= 1 {
808 vec![0]
809 } else {
810 (0..arg_mutation_rounds)
811 .map(|_| test_runner.rng().random_range(0..function.inputs.len()))
812 .collect()
813 };
814 let mut prev_inputs = function
815 .abi_decode_input(&tx.call_details.calldata[4..])
816 .map_err(|err| eyre!("failed to load previous inputs: {err}"))?;
817
818 while arg_mutation_rounds > 0 {
819 let idx = round_arg_idx[arg_mutation_rounds - 1];
820 prev_inputs[idx] = mutate_param_value(
821 &function
822 .inputs
823 .get(idx)
824 .expect("Could not get input to mutate")
825 .selector_type()
826 .parse()?,
827 prev_inputs[idx].clone(),
828 test_runner,
829 fuzz_state,
830 );
831 arg_mutation_rounds -= 1;
832 }
833
834 tx.call_details.calldata =
835 function.abi_encode_input(&prev_inputs).map_err(|e| eyre!(e.to_string()))?.into();
836 Ok(())
837 }
838
839 fn load_sync_corpus(&self) -> Result<Vec<(CorpusDirEntry, Vec<BasicTxDetails>)>> {
844 let Some(worker_dir) = &self.worker_dir else {
845 return Ok(vec![]);
846 };
847
848 let sync_dir = worker_dir.join(SYNC_DIR);
849 if !sync_dir.is_dir() {
850 return Ok(vec![]);
851 }
852
853 let mut imports = vec![];
854 for entry in read_corpus_dir(&sync_dir) {
855 if entry.timestamp <= self.last_sync_timestamp {
856 continue;
857 }
858 let tx_seq = entry.read_tx_seq()?;
859 if tx_seq.is_empty() {
860 warn!(target: "corpus", "skipping empty corpus entry: {}", entry.path.display());
861 continue;
862 }
863 imports.push((entry, tx_seq));
864 }
865
866 if !imports.is_empty() {
867 debug!(target: "corpus", "imported {} new corpus entries", imports.len());
868 }
869
870 Ok(imports)
871 }
872
873 #[instrument(skip_all)]
876 fn calibrate<FEN: FoundryEvmNetwork>(
877 &mut self,
878 executor: &Executor<FEN>,
879 fuzzed_function: Option<&Function>,
880 fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
881 ) -> Result<()> {
882 let Some(worker_dir) = &self.worker_dir else {
883 return Ok(());
884 };
885 let corpus_dir = worker_dir.join(CORPUS_DIR);
886
887 let mut executor = executor.clone();
888 for (entry, tx_seq) in self.load_sync_corpus()? {
889 let mut new_coverage_on_sync = false;
890 for tx in &tx_seq {
891 if !Self::can_replay_tx(tx, fuzzed_function, fuzzed_contracts) {
892 continue;
893 }
894
895 let mut call_result = execute_tx(&mut executor, tx)?;
896
897 let (new_coverage, is_edge) = call_result
899 .merge_all_coverage(&mut self.history_map, &mut self.sancov_history_map);
900
901 if new_coverage {
902 self.metrics.update_seen(is_edge);
903 new_coverage_on_sync = true;
904 }
905
906 if fuzzed_contracts.is_some() {
908 executor.commit(&mut call_result);
909 }
910
911 trace!(
912 target: "corpus",
913 %new_coverage,
914 ?tx,
915 "replayed tx for syncing",
916 );
917 }
918
919 let sync_path = &entry.path;
920 if new_coverage_on_sync {
921 let corpus_path = corpus_dir.join(sync_path.components().next_back().unwrap());
923 if let Err(err) = std::fs::rename(sync_path, &corpus_path) {
924 debug!(target: "corpus", %err, "failed to move synced corpus from {sync_path:?} to {corpus_path:?} dir");
925 continue;
926 }
927
928 debug!(
929 target: "corpus",
930 name=%entry.name(),
931 "moved synced corpus to corpus dir",
932 );
933
934 let corpus_entry = CorpusEntry::new_existing(tx_seq.clone(), entry.path.clone())?;
935 self.in_memory_corpus.push(corpus_entry);
936 } else {
937 if let Err(err) = std::fs::remove_file(&entry.path) {
939 debug!(target: "corpus", %err, "failed to remove synced corpus from {sync_path:?}");
940 continue;
941 }
942 trace!(target: "corpus", "removed synced corpus from {sync_path:?}");
943 }
944 }
945
946 Ok(())
947 }
948
949 #[instrument(skip_all)]
951 fn export_to_master(&self) -> Result<()> {
952 assert_ne!(self.id, 0, "non-master only");
954
955 if self.new_entry_indices.is_empty() || self.worker_dir.is_none() {
957 return Ok(());
958 }
959
960 let worker_dir = self.worker_dir.as_ref().unwrap();
961 let Some(master_sync_dir) = self
962 .config
963 .corpus_dir
964 .as_ref()
965 .map(|dir| dir.join(format!("{WORKER}0")).join(SYNC_DIR))
966 else {
967 return Ok(());
968 };
969
970 let mut exported = 0;
971 let corpus_dir = worker_dir.join(CORPUS_DIR);
972
973 for &index in &self.new_entry_indices {
974 let Some(corpus) = self.in_memory_corpus.get(index) else { continue };
975 let file_name = corpus.file_name(self.config.corpus_gzip);
976 let file_path = corpus_dir.join(&file_name);
977 let sync_path = master_sync_dir.join(&file_name);
978 if let Err(err) = std::fs::hard_link(&file_path, &sync_path) {
979 debug!(target: "corpus", %err, "failed to export corpus {}", corpus.uuid);
980 continue;
981 }
982 exported += 1;
983 }
984
985 debug!(target: "corpus", "exported {exported} new corpus entries");
986
987 Ok(())
988 }
989
990 #[instrument(skip_all)]
992 fn export_to_workers(&mut self, num_workers: usize) -> Result<()> {
993 assert_eq!(self.id, 0, "master worker only");
994 if self.worker_dir.is_none() {
995 return Ok(());
996 }
997
998 let worker_dir = self.worker_dir.as_ref().unwrap();
999 let master_corpus_dir = worker_dir.join(CORPUS_DIR);
1000 let filtered_master_corpus = read_corpus_dir(&master_corpus_dir)
1001 .filter(|entry| entry.timestamp > self.last_sync_timestamp)
1002 .collect::<Vec<_>>();
1003 let mut any_distributed = false;
1004 for target_worker in 1..num_workers {
1005 let target_dir = self
1006 .config
1007 .corpus_dir
1008 .as_ref()
1009 .unwrap()
1010 .join(format!("{WORKER}{target_worker}"))
1011 .join(SYNC_DIR);
1012
1013 if !target_dir.is_dir() {
1014 foundry_common::fs::create_dir_all(&target_dir)?;
1015 }
1016
1017 for entry in &filtered_master_corpus {
1018 let name = entry.name();
1019 let sync_path = target_dir.join(name);
1020 if let Err(err) = std::fs::hard_link(&entry.path, &sync_path) {
1021 debug!(target: "corpus", %err, from=?entry.path, to=?sync_path, "failed to distribute corpus");
1022 continue;
1023 }
1024 any_distributed = true;
1025 trace!(target: "corpus", %name, ?target_dir, "distributed corpus");
1026 }
1027 }
1028
1029 debug!(target: "corpus", %any_distributed, "distributed master corpus to all workers");
1030
1031 Ok(())
1032 }
1033
1034 pub(crate) fn sync_metrics(&mut self, global_corpus_metrics: &GlobalCorpusMetrics) {
1037 let edges_delta = self
1039 .metrics
1040 .cumulative_edges_seen
1041 .saturating_sub(self.last_sync_metrics.cumulative_edges_seen);
1042 let features_delta = self
1043 .metrics
1044 .cumulative_features_seen
1045 .saturating_sub(self.last_sync_metrics.cumulative_features_seen);
1046 let corpus_count_delta =
1048 self.metrics.corpus_count as isize - self.last_sync_metrics.corpus_count as isize;
1049 let favored_delta =
1050 self.metrics.favored_items as isize - self.last_sync_metrics.favored_items as isize;
1051
1052 if edges_delta > 0 {
1055 global_corpus_metrics.cumulative_edges_seen.fetch_add(edges_delta, Ordering::Relaxed);
1056 }
1057 if features_delta > 0 {
1058 global_corpus_metrics
1059 .cumulative_features_seen
1060 .fetch_add(features_delta, Ordering::Relaxed);
1061 }
1062
1063 if corpus_count_delta > 0 {
1064 global_corpus_metrics
1065 .corpus_count
1066 .fetch_add(corpus_count_delta as usize, Ordering::Relaxed);
1067 } else if corpus_count_delta < 0 {
1068 global_corpus_metrics
1069 .corpus_count
1070 .fetch_sub((-corpus_count_delta) as usize, Ordering::Relaxed);
1071 }
1072
1073 if favored_delta > 0 {
1074 global_corpus_metrics
1075 .favored_items
1076 .fetch_add(favored_delta as usize, Ordering::Relaxed);
1077 } else if favored_delta < 0 {
1078 global_corpus_metrics
1079 .favored_items
1080 .fetch_sub((-favored_delta) as usize, Ordering::Relaxed);
1081 }
1082
1083 self.last_sync_metrics = self.metrics.clone();
1085 }
1086
1087 #[instrument(skip_all)]
1089 pub fn sync<FEN: FoundryEvmNetwork>(
1090 &mut self,
1091 num_workers: usize,
1092 executor: &Executor<FEN>,
1093 fuzzed_function: Option<&Function>,
1094 fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
1095 global_corpus_metrics: &GlobalCorpusMetrics,
1096 ) -> Result<()> {
1097 trace!(target: "corpus", "syncing");
1098
1099 self.sync_metrics(global_corpus_metrics);
1100
1101 self.calibrate(executor, fuzzed_function, fuzzed_contracts)?;
1102 if self.id == 0 {
1103 self.export_to_workers(num_workers)?;
1104 } else {
1105 self.export_to_master()?;
1106 }
1107
1108 let last_sync = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
1109 self.last_sync_timestamp = last_sync;
1110
1111 self.new_entry_indices.clear();
1112
1113 debug!(target: "corpus", last_sync, "synced");
1114
1115 Ok(())
1116 }
1117
1118 fn can_replay_tx(
1120 tx: &BasicTxDetails,
1121 fuzzed_function: Option<&Function>,
1122 fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
1123 ) -> bool {
1124 fuzzed_contracts.is_some_and(|contracts| contracts.targets.lock().can_replay(tx))
1125 || fuzzed_function.is_some_and(|function| {
1126 tx.call_details
1127 .calldata
1128 .get(..4)
1129 .is_some_and(|selector| function.selector() == selector)
1130 })
1131 }
1132}
1133
1134fn read_corpus_dir(path: &Path) -> impl Iterator<Item = CorpusDirEntry> {
1135 let dir = match std::fs::read_dir(path) {
1136 Ok(dir) => dir,
1137 Err(err) => {
1138 debug!(%err, ?path, "failed to read corpus directory");
1139 return vec![].into_iter();
1140 }
1141 };
1142 dir.filter_map(|res| match res {
1143 Ok(entry) => {
1144 let path = entry.path();
1145 if !path.is_file() {
1146 return None;
1147 }
1148 let name = if path.is_file()
1149 && let Some(name) = path.file_name()
1150 && let Some(name) = name.to_str()
1151 {
1152 name
1153 } else {
1154 return None;
1155 };
1156
1157 if let Ok((uuid, timestamp)) = parse_corpus_filename(name) {
1158 Some(CorpusDirEntry { path, uuid, timestamp })
1159 } else {
1160 debug!(target: "corpus", ?path, "failed to parse corpus filename");
1161 None
1162 }
1163 }
1164 Err(err) => {
1165 debug!(%err, "failed to read corpus directory entry");
1166 None
1167 }
1168 })
1169 .collect::<Vec<_>>()
1170 .into_iter()
1171}
1172
1173struct CorpusDirEntry {
1174 path: PathBuf,
1175 uuid: Uuid,
1176 timestamp: u64,
1177}
1178
1179impl CorpusDirEntry {
1180 fn name(&self) -> &str {
1181 self.path.file_name().unwrap().to_str().unwrap()
1182 }
1183
1184 fn read_tx_seq(&self) -> foundry_common::fs::Result<Vec<BasicTxDetails>> {
1185 let path = &self.path;
1186 if path.extension() == Some("gz".as_ref()) {
1187 foundry_common::fs::read_json_gzip_file(path)
1188 } else {
1189 foundry_common::fs::read_json_file(path)
1190 }
1191 }
1192}
1193
1194fn parse_corpus_filename(name: &str) -> Result<(Uuid, u64)> {
1196 let name = name.trim_end_matches(".gz").trim_end_matches(".json");
1197
1198 let (uuid_str, timestamp_str) =
1199 name.rsplit_once('-').ok_or_else(|| eyre!("invalid corpus filename format: {name}"))?;
1200
1201 let uuid = Uuid::parse_str(uuid_str)?;
1202 let timestamp = timestamp_str.parse()?;
1203
1204 Ok((uuid, timestamp))
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use alloy_primitives::Address;
1211 use std::fs;
1212
1213 fn basic_tx() -> BasicTxDetails {
1214 BasicTxDetails {
1215 warp: None,
1216 roll: None,
1217 sender: Address::ZERO,
1218 call_details: foundry_evm_fuzz::CallDetails {
1219 target: Address::ZERO,
1220 calldata: Bytes::new(),
1221 },
1222 }
1223 }
1224
1225 fn temp_corpus_dir() -> PathBuf {
1226 let dir = std::env::temp_dir().join(format!("foundry-corpus-tests-{}", Uuid::new_v4()));
1227 let _ = fs::create_dir_all(&dir);
1228 dir
1229 }
1230
1231 fn new_manager_with_single_corpus() -> (WorkerCorpus, Uuid) {
1232 let tx_gen = Just(basic_tx()).boxed();
1233 let config = FuzzCorpusConfig {
1234 corpus_dir: Some(temp_corpus_dir()),
1235 corpus_gzip: false,
1236 corpus_min_mutations: 0,
1237 corpus_min_size: 0,
1238 ..Default::default()
1239 };
1240
1241 let tx_seq = vec![basic_tx()];
1242 let corpus = CorpusEntry::new(tx_seq);
1243 let seed_uuid = corpus.uuid;
1244
1245 let corpus_root = config.corpus_dir.clone().unwrap();
1247 let worker_subdir = corpus_root.join("worker0");
1248 let _ = fs::create_dir_all(&worker_subdir);
1249
1250 let manager = WorkerCorpus {
1251 id: 0,
1252 tx_generator: tx_gen,
1253 mutation_generator: Just(MutationType::Repeat).boxed(),
1254 config: config.into(),
1255 in_memory_corpus: vec![corpus],
1256 current_mutated: Some(seed_uuid),
1257 failed_replays: 0,
1258 history_map: vec![0u8; COVERAGE_MAP_SIZE],
1259 sancov_history_map: vec![0u8; COVERAGE_MAP_SIZE],
1260 metrics: CorpusMetrics::default(),
1261 new_entry_indices: Default::default(),
1262 last_sync_timestamp: 0,
1263 worker_dir: Some(corpus_root),
1264 last_sync_metrics: CorpusMetrics::default(),
1265 optimization_best_value: None,
1266 optimization_best_sequence: vec![],
1267 };
1268
1269 (manager, seed_uuid)
1270 }
1271
1272 #[test]
1273 fn favored_sets_true_and_metrics_increment_when_ratio_gt_threshold() {
1274 let (mut manager, uuid) = new_manager_with_single_corpus();
1275 let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
1276 corpus.total_mutations = 4;
1277 corpus.new_finds_produced = 2; corpus.is_favored = false;
1279
1280 assert_eq!(manager.metrics.favored_items, 0);
1282
1283 manager.current_mutated = Some(uuid);
1285 manager.process_inputs(&[basic_tx()], true, None);
1286
1287 let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
1288 assert!(corpus.is_favored, "expected favored to be true when ratio > threshold");
1289 assert_eq!(
1290 manager.metrics.favored_items, 1,
1291 "favored_items should increment on false→true"
1292 );
1293 }
1294
1295 #[test]
1296 fn favored_sets_false_and_metrics_decrement_when_ratio_lt_threshold() {
1297 let (mut manager, uuid) = new_manager_with_single_corpus();
1298 let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
1299 corpus.total_mutations = 9;
1300 corpus.new_finds_produced = 3; corpus.is_favored = true; manager.metrics.favored_items = 1;
1304
1305 manager.current_mutated = Some(uuid);
1307 manager.process_inputs(&[basic_tx()], false, None);
1308
1309 let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
1310 assert!(!corpus.is_favored, "expected favored to be false when ratio < threshold");
1311 assert_eq!(
1312 manager.metrics.favored_items, 0,
1313 "favored_items should decrement on true→false"
1314 );
1315 }
1316
1317 #[test]
1318 fn favored_is_false_on_ratio_equal_threshold() {
1319 let (mut manager, uuid) = new_manager_with_single_corpus();
1320 let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
1321 corpus.total_mutations = 9;
1323 corpus.new_finds_produced = 2;
1324 corpus.is_favored = false;
1325
1326 manager.current_mutated = Some(uuid);
1327 manager.process_inputs(&[basic_tx()], true, None);
1328
1329 let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
1330 assert!(
1331 !(corpus.is_favored),
1332 "with strict '>' comparison, favored must be false when ratio == threshold"
1333 );
1334 }
1335
1336 #[test]
1337 fn eviction_skips_favored_and_evicts_non_favored() {
1338 let tx_gen = Just(basic_tx()).boxed();
1340 let config = FuzzCorpusConfig {
1341 corpus_dir: Some(temp_corpus_dir()),
1342 corpus_min_mutations: 0,
1343 corpus_min_size: 0,
1344 ..Default::default()
1345 };
1346
1347 let mut favored = CorpusEntry::new(vec![basic_tx()]);
1348 favored.total_mutations = 2;
1349 favored.is_favored = true;
1350
1351 let mut non_favored = CorpusEntry::new(vec![basic_tx()]);
1352 non_favored.total_mutations = 2;
1353 non_favored.is_favored = false;
1354 let non_favored_uuid = non_favored.uuid;
1355
1356 let corpus_root = temp_corpus_dir();
1357 let worker_subdir = corpus_root.join("worker0");
1358 fs::create_dir_all(&worker_subdir).unwrap();
1359
1360 let mut manager = WorkerCorpus {
1361 id: 0,
1362 tx_generator: tx_gen,
1363 mutation_generator: Just(MutationType::Repeat).boxed(),
1364 config: config.into(),
1365 in_memory_corpus: vec![favored, non_favored],
1366 current_mutated: None,
1367 failed_replays: 0,
1368 history_map: vec![0u8; COVERAGE_MAP_SIZE],
1369 sancov_history_map: vec![0u8; COVERAGE_MAP_SIZE],
1370 metrics: CorpusMetrics::default(),
1371 new_entry_indices: Default::default(),
1372 last_sync_timestamp: 0,
1373 worker_dir: Some(corpus_root),
1374 last_sync_metrics: CorpusMetrics::default(),
1375 optimization_best_value: None,
1376 optimization_best_sequence: vec![],
1377 };
1378
1379 manager.evict_oldest_corpus().unwrap();
1381 assert_eq!(manager.in_memory_corpus.len(), 1);
1382 assert!(manager.in_memory_corpus.iter().all(|c| c.is_favored));
1383
1384 manager.evict_oldest_corpus().unwrap();
1386 assert_eq!(manager.in_memory_corpus.len(), 1, "favored corpus must not be evicted");
1387
1388 assert!(manager.in_memory_corpus.iter().all(|c| c.uuid != non_favored_uuid));
1390 }
1391}