Skip to main content

foundry_evm/executors/
corpus.rs

1//! Corpus management for parallel fuzzing with coverage-guided mutation.
2//!
3//! This module implements a corpus-based fuzzing system that stores, mutates, and shares
4//! transaction sequences across multiple fuzzing workers. Each corpus entry represents a
5//! sequence of transactions that has produced interesting coverage, and can be mutated to
6//! discover new execution paths.
7//!
8//! ## File System Structure
9//!
10//! The corpus is organized on disk as follows:
11//!
12//! ```text
13//! <corpus_dir>/
14//! ├── worker0/                  # Master (worker 0) directory
15//! │   ├── corpus/               # Master's corpus entries
16//! │   │   ├── <uuid>-<timestamp>.json          # Corpus entry (if small)
17//! │   │   ├── <uuid>-<timestamp>.json.gz       # Corpus entry (if large, compressed)
18//! │   └── sync/                 # Directory where other workers export new findings
19//! │       └── <uuid>-<timestamp>.json          # New entries from other workers
20//! └── workerN/                  # Worker N's directory
21//!     ├── corpus/               # Worker N's local corpus
22//!     │   └── ...
23//!     └── sync/                 # Worker 2's sync directory
24//!         └── ...
25//! ```
26//!
27//! ## Workflow
28//!
29//! - Each worker maintains its own local corpus with entries stored as JSON files
30//! - Workers export new interesting entries to the master's sync directory via hard links
31//! - The master (worker0) imports new entries from its sync directory and exports them to all the
32//!   other workers
33//! - Workers sync with the master to receive new corpus entries from other workers
34//! - This all happens periodically, there is no clear order in which workers export or import
35//!   entries since it doesn't matter as long as the corpus eventually syncs across all workers
36
37use 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
76/// Threshold for compressing corpus entries.
77/// 4KiB is usually the minimum file size on popular file systems.
78const GZIP_THRESHOLD: usize = 4 * 1024;
79
80/// Possible mutation strategies to apply on a call sequence.
81#[derive(Debug, Clone)]
82enum MutationType {
83    /// Splice original call sequence.
84    Splice,
85    /// Repeat selected call several times.
86    Repeat,
87    /// Interleave calls from two random call sequences.
88    Interleave,
89    /// Replace prefix of the original call sequence with new calls.
90    Prefix,
91    /// Replace suffix of the original call sequence with new calls.
92    Suffix,
93    /// ABI mutate random args of selected call in sequence.
94    Abi,
95}
96
97/// Persisted optimization state: the best value found and the sequence that produced it.
98#[derive(Clone, Serialize, Deserialize)]
99struct OptimizationState {
100    best_value: I256,
101    best_sequence: Vec<BasicTxDetails>,
102}
103
104/// Holds Corpus information.
105#[derive(Clone, Serialize)]
106struct CorpusEntry {
107    // Unique corpus identifier.
108    uuid: Uuid,
109    // Total mutations of corpus as primary source.
110    total_mutations: usize,
111    // New coverage found as a result of mutating this corpus.
112    new_finds_produced: usize,
113    // Corpus call sequence.
114    #[serde(skip_serializing)]
115    tx_seq: Vec<BasicTxDetails>,
116    // Whether this corpus is favored, i.e. producing new finds more often than
117    // `FAVORABILITY_THRESHOLD`.
118    is_favored: bool,
119    /// Timestamp of when this entry was written to disk in seconds.
120    #[serde(skip_serializing)]
121    timestamp: u64,
122}
123
124impl CorpusEntry {
125    /// Creates a corpus entry with a new UUID.
126    pub fn new(tx_seq: Vec<BasicTxDetails>) -> Self {
127        Self::new_with(tx_seq, Uuid::new_v4())
128    }
129
130    /// Creates a corpus entry with a path.
131    /// The UUID is parsed from the file name, otherwise a new UUID is generated.
132    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    /// Creates a corpus entry with the given UUID.
141    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    // Number of edges seen during the invariant run.
182    cumulative_edges_seen: AtomicUsize,
183    // Number of features (new hitcount bin of previously hit edge) seen during the invariant run.
184    cumulative_features_seen: AtomicUsize,
185    // Number of corpus entries.
186    corpus_count: AtomicUsize,
187    // Number of corpus entries that are favored.
188    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    // Number of edges seen during the invariant run.
211    cumulative_edges_seen: usize,
212    // Number of features (new hitcount bin of previously hit edge) seen during the invariant run.
213    cumulative_features_seen: usize,
214    // Number of corpus entries.
215    corpus_count: usize,
216    // Number of corpus entries that are favored.
217    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    /// Records number of new edges or features explored during the campaign.
233    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    /// Updates campaign favored items.
242    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
251/// Per-worker corpus manager.
252pub struct WorkerCorpus {
253    /// Worker Id
254    id: usize,
255    /// In-memory corpus entries populated from the persisted files and
256    /// runs administered by this worker.
257    in_memory_corpus: Vec<CorpusEntry>,
258    /// History of binned hitcount of edges seen during fuzzing
259    history_map: Vec<u8>,
260    /// History of binned hitcount of sancov (native Rust) edges seen during fuzzing
261    sancov_history_map: Vec<u8>,
262    /// Number of failed replays from initial corpus
263    pub(crate) failed_replays: usize,
264    /// Worker Metrics
265    pub(crate) metrics: CorpusMetrics,
266    /// Fuzzed calls generator.
267    tx_generator: BoxedStrategy<BasicTxDetails>,
268    /// Call sequence mutation strategy type generator used by stateful fuzzing.
269    mutation_generator: BoxedStrategy<MutationType>,
270    /// Identifier of current mutated entry for this worker.
271    current_mutated: Option<Uuid>,
272    /// Config
273    config: Arc<FuzzCorpusConfig>,
274    /// Indices of new entries added to [`WorkerCorpus::in_memory_corpus`] since last sync.
275    new_entry_indices: Vec<usize>,
276    /// Last sync timestamp in seconds.
277    last_sync_timestamp: u64,
278    /// Worker Dir
279    /// corpus_dir/worker1/
280    worker_dir: Option<PathBuf>,
281    /// Metrics at last sync - used to calculate deltas while syncing with global metrics
282    last_sync_metrics: CorpusMetrics,
283    /// Optimization mode: the best value found so far (loaded from disk or discovered in-run).
284    optimization_best_value: Option<I256>,
285    /// Optimization mode: the call sequence that produced the best value.
286    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        // Only required by master worker (id = 0) to replay existing corpus.
295        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            // Create the necessary directories for the worker.
315            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            // Load persisted optimization state if it exists.
333            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            // Seed in-memory corpus with the persisted optimization best sequence
356            // so the mutation engine can build on it in future runs.
357            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            // Master worker loads the initial corpus, if it exists.
363            // Then, [distribute]s it to workers.
364            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                // Warm up history map from loaded sequences.
371                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                        // Commit only when running invariant / stateful tests.
382                        if fuzzed_contracts.is_some() {
383                            executor.commit(&mut call_result);
384                        }
385                    } else {
386                        failed_replays += 1;
387
388                        // If the only input for fuzzed function cannot be replied, then move to
389                        // next one without adding it in memory.
390                        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                // Populate in memory corpus with the sequence from corpus file.
406                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    /// Updates stats for the given call sequence, if new coverage produced.
431    /// Persists the call sequence (if corpus directory is configured and new coverage or
432    /// improved optimization value) and updates in-memory corpus.
433    #[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        // Check if this run improved the optimization value.
446        let improved_optimization = optimization.as_ref().is_some_and(|(value, _)| {
447            self.optimization_best_value.is_none_or(|best| *value > best)
448        });
449
450        // Update stats of current mutated primary corpus.
451        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        // Persist optimization state to disk if improved.
476        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        // Collect inputs if current run produced new coverage or improved optimization.
485        if !new_coverage && !improved_optimization {
486            return;
487        }
488
489        // When the run is interesting only because of optimization (no new coverage),
490        // add the best prefix to the corpus instead of the full run — the prefix is
491        // the sequence that actually achieved the best value.
492        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        // Persist to disk.
501        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        // Track in-memory corpus changes to update MasterWorker on sync.
514        let new_index = self.in_memory_corpus.len();
515        self.new_entry_indices.push(new_index);
516
517        // This includes reverting txs in the corpus and `can_continue` removes
518        // them. We want this as it is new coverage and may help reach the other branch.
519        self.metrics.corpus_count += 1;
520        self.in_memory_corpus.push(corpus);
521    }
522
523    /// Returns the previously persisted optimization best value and sequence (if any).
524    pub fn optimization_initial_state(&self) -> (Option<I256>, Vec<BasicTxDetails>) {
525        (self.optimization_best_value, self.optimization_best_sequence.clone())
526    }
527
528    /// Persists the current optimization best value and sequence to disk.
529    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    /// Collects EVM and sancov coverage from call result and updates metrics.
554    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    /// Generates new call sequence from in memory corpus. Evicts oldest corpus mutated more than
571    /// configured max mutations value. Used by invariant test campaigns.
572    #[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        // Early return with first_input only if corpus dir / coverage guided fuzzing not
582        // configured.
583        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                        // TODO: chunks?
641                        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                        // TODO: add call_value to call details and mutate it as well as sender some
681                        // of the time.
682                        if !function.inputs.is_empty() {
683                            self.abi_mutate(tx, function, test_runner, fuzz_state)?;
684                        }
685                    }
686                }
687            }
688        }
689
690        // Make sure the new sequence contains at least one tx to start fuzzing from.
691        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    /// Generates a new input from the shared in memory corpus.  Evicts oldest corpus mutated more
700    /// than configured max mutations value. Used by fuzz (stateless) test campaigns.
701    #[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        // Early return if not running with coverage guided fuzzing.
709        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    /// Generates single call from corpus strategy.
730    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    /// Returns the next call to be used in call sequence.
739    /// If coverage guided fuzzing is not configured or if previous input was discarded then this is
740    /// a new tx from strategy.
741    /// If running with coverage guided fuzzing it returns a new call only when sequence
742    /// does not have enough entries, or randomly. Otherwise, returns the next call from initial
743    /// sequence.
744    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        // Early return with new input if corpus dir / coverage guided fuzzing not configured or if
752        // call was discarded.
753        if self.config.corpus_dir.is_none() || discarded {
754            return self.new_tx(test_runner);
755        }
756
757        // When running with coverage guided fuzzing enabled then generate new sequence if initial
758        // sequence's length is less than depth or randomly, to occasionally intermix new txs.
759        if depth > sequence.len().saturating_sub(1) || test_runner.rng().random_ratio(1, 10) {
760            return self.new_tx(test_runner);
761        }
762
763        // Continue with the next call initial sequence.
764        Ok(sequence[depth].clone())
765    }
766
767    /// Flush the oldest corpus mutated more than configured max mutations unless they are
768    /// favored.
769    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            // Remove corpus from memory.
780            self.in_memory_corpus.remove(index);
781
782            // Adjust the tracked indices.
783            self.new_entry_indices.retain_mut(|i| {
784                if *i > index {
785                    *i -= 1; // Shift indices down.
786                    true // Keep this index.
787                } else {
788                    *i != index // Remove if it's the deleted index, keep otherwise.
789                }
790            });
791        }
792        Ok(())
793    }
794
795    /// Mutates calldata of provided tx by abi decoding current values and randomly selecting the
796    /// inputs to change.
797    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 rng = test_runner.rng();
805        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    // Sync Methods.
840
841    /// Imports the new corpus entries from the `sync` directory.
842    /// These contain tx sequences which are replayed and used to update the history map.
843    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    /// Syncs and calibrates the in memory corpus and updates the history_map if new coverage is
874    /// found from the corpus findings of other workers.
875    #[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                // Check if this provides new coverage.
898                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                // Commit only for stateful tests.
907                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                // Move file from sync/ to corpus/ directory.
922                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                // Remove the file as it did not generate new coverage.
938                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    /// Exports the new corpus entries to the master worker's sync dir.
950    #[instrument(skip_all)]
951    fn export_to_master(&self) -> Result<()> {
952        // Master doesn't export (it only receives from others).
953        assert_ne!(self.id, 0, "non-master only");
954
955        // Early return if no new entries or corpus dir not configured.
956        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    /// Exports the global corpus to the `sync/` directories of all the non-master workers.
991    #[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    // TODO(dani): currently only master syncs metrics?
1035    /// Syncs local metrics with global corpus metrics by calculating and applying deltas.
1036    pub(crate) fn sync_metrics(&mut self, global_corpus_metrics: &GlobalCorpusMetrics) {
1037        // Calculate delta metrics since last sync.
1038        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        // For corpus count and favored items, calculate deltas.
1047        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        // Add delta values to global metrics.
1053
1054        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        // Store current metrics as last sync metrics for next delta calculation.
1084        self.last_sync_metrics = self.metrics.clone();
1085    }
1086
1087    /// Syncs the workers in_memory_corpus and history_map with the findings from other workers.
1088    #[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    /// Helper to check if a tx can be replayed.
1119    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
1194/// Parses the corpus filename and returns the uuid and timestamp associated with it.
1195fn 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        // Create corpus root dir and worker subdirectory.
1246        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; // ratio currently 0.5 if both increment → 3/5 = 0.6 > 0.3.
1278        corpus.is_favored = false;
1279
1280        // Ensure metrics start at 0.
1281        assert_eq!(manager.metrics.favored_items, 0);
1282
1283        // Mark this as the currently mutated corpus and process a run with new coverage.
1284        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; // 3/9 = 0.333.. > 0.3; after +1: 3/10 = 0.3 => not favored.
1301        corpus.is_favored = true; // Start as favored.
1302
1303        manager.metrics.favored_items = 1;
1304
1305        // Next run does NOT produce coverage → only total_mutations increments, ratio drops.
1306        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        // After this call with new_coverage=true, totals become 10 and 3 → 0.3.
1322        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        // Manager with two corpora.
1339        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        // First eviction should remove the non-favored one.
1380        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        // Attempt eviction again: only favored remains → should not remove.
1385        manager.evict_oldest_corpus().unwrap();
1386        assert_eq!(manager.in_memory_corpus.len(), 1, "favored corpus must not be evicted");
1387
1388        // Ensure the evicted one was the non-favored uuid.
1389        assert!(manager.in_memory_corpus.iter().all(|c| c.uuid != non_favored_uuid));
1390    }
1391}