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