foundry_evm/executors/
corpus.rs

1use crate::executors::{Executor, RawCallResult, invariant::execute_tx};
2use alloy_dyn_abi::JsonAbiExt;
3use alloy_json_abi::Function;
4use alloy_primitives::Bytes;
5use eyre::eyre;
6use foundry_config::FuzzCorpusConfig;
7use foundry_evm_fuzz::{
8    BasicTxDetails,
9    invariant::FuzzRunIdentifiedContracts,
10    strategies::{EvmFuzzState, mutate_param_value},
11};
12use proptest::{
13    prelude::{Just, Rng, Strategy},
14    prop_oneof,
15    strategy::{BoxedStrategy, ValueTree},
16    test_runner::TestRunner,
17};
18use serde::Serialize;
19use std::{
20    fmt,
21    path::PathBuf,
22    time::{SystemTime, UNIX_EPOCH},
23};
24use uuid::Uuid;
25
26const METADATA_SUFFIX: &str = "metadata.json";
27const JSON_EXTENSION: &str = ".json";
28const FAVORABILITY_THRESHOLD: f64 = 0.3;
29const COVERAGE_MAP_SIZE: usize = 65536;
30
31/// Possible mutation strategies to apply on a call sequence.
32#[derive(Debug, Clone)]
33enum MutationType {
34    /// Splice original call sequence.
35    Splice,
36    /// Repeat selected call several times.
37    Repeat,
38    /// Interleave calls from two random call sequences.
39    Interleave,
40    /// Replace prefix of the original call sequence with new calls.
41    Prefix,
42    /// Replace suffix of the original call sequence with new calls.
43    Suffix,
44    /// ABI mutate random args of selected call in sequence.
45    Abi,
46}
47
48/// Holds Corpus information.
49#[derive(Serialize)]
50struct CorpusEntry {
51    // Unique corpus identifier.
52    uuid: Uuid,
53    // Total mutations of corpus as primary source.
54    total_mutations: usize,
55    // New coverage found as a result of mutating this corpus.
56    new_finds_produced: usize,
57    // Corpus call sequence.
58    #[serde(skip_serializing)]
59    tx_seq: Vec<BasicTxDetails>,
60    // Whether this corpus is favored, i.e. producing new finds more often than
61    // `FAVORABILITY_THRESHOLD`.
62    is_favored: bool,
63}
64
65impl CorpusEntry {
66    /// New corpus from given call sequence and corpus path to read uuid.
67    pub fn new(tx_seq: Vec<BasicTxDetails>, path: PathBuf) -> eyre::Result<Self> {
68        let uuid = if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
69            Uuid::try_from(stem.strip_suffix(JSON_EXTENSION).unwrap_or(stem).to_string())?
70        } else {
71            Uuid::new_v4()
72        };
73        Ok(Self { uuid, total_mutations: 0, new_finds_produced: 0, tx_seq, is_favored: false })
74    }
75
76    /// New corpus with given call sequence and new uuid.
77    pub fn from_tx_seq(tx_seq: &[BasicTxDetails]) -> Self {
78        Self {
79            uuid: Uuid::new_v4(),
80            total_mutations: 0,
81            new_finds_produced: 0,
82            tx_seq: tx_seq.into(),
83            is_favored: false,
84        }
85    }
86}
87
88#[derive(Serialize, Default)]
89pub(crate) struct CorpusMetrics {
90    // Number of edges seen during the invariant run.
91    cumulative_edges_seen: usize,
92    // Number of features (new hitcount bin of previously hit edge) seen during the invariant run.
93    cumulative_features_seen: usize,
94    // Number of corpus entries.
95    corpus_count: usize,
96    // Number of corpus entries that are favored.
97    favored_items: usize,
98}
99
100impl fmt::Display for CorpusMetrics {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        writeln!(f)?;
103        writeln!(f, "        - cumulative edges seen: {}", self.cumulative_edges_seen)?;
104        writeln!(f, "        - cumulative features seen: {}", self.cumulative_features_seen)?;
105        writeln!(f, "        - corpus count: {}", self.corpus_count)?;
106        write!(f, "        - favored items: {}", self.favored_items)?;
107        Ok(())
108    }
109}
110
111impl CorpusMetrics {
112    /// Records number of new edges or features explored during the campaign.
113    pub fn update_seen(&mut self, is_edge: bool) {
114        if is_edge {
115            self.cumulative_edges_seen += 1;
116        } else {
117            self.cumulative_features_seen += 1;
118        }
119    }
120
121    /// Updates campaign favored items.
122    pub fn update_favored(&mut self, is_favored: bool, corpus_favored: bool) {
123        if is_favored && !corpus_favored {
124            self.favored_items += 1;
125        } else if !is_favored && corpus_favored {
126            self.favored_items -= 1;
127        }
128    }
129}
130
131/// Fuzz corpus manager, used in coverage guided fuzzing mode by both stateless and stateful tests.
132pub(crate) struct CorpusManager {
133    // Fuzzed calls generator.
134    tx_generator: BoxedStrategy<BasicTxDetails>,
135    // Call sequence mutation strategy type generator.
136    mutation_generator: BoxedStrategy<MutationType>,
137    // Corpus configuration.
138    config: FuzzCorpusConfig,
139    // In-memory corpus, populated from persisted files and current runs.
140    // Mutation is performed on these.
141    in_memory_corpus: Vec<CorpusEntry>,
142    // Identifier of current mutated entry.
143    current_mutated: Option<Uuid>,
144    // Number of failed replays from persisted corpus.
145    failed_replays: usize,
146    // History of binned hitcount of edges seen during fuzzing.
147    history_map: Vec<u8>,
148    // Corpus metrics.
149    pub(crate) metrics: CorpusMetrics,
150}
151
152impl CorpusManager {
153    pub fn new(
154        config: FuzzCorpusConfig,
155        tx_generator: BoxedStrategy<BasicTxDetails>,
156        executor: &Executor,
157        fuzzed_function: Option<&Function>,
158        fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
159    ) -> eyre::Result<Self> {
160        let mutation_generator = prop_oneof![
161            Just(MutationType::Splice),
162            Just(MutationType::Repeat),
163            Just(MutationType::Interleave),
164            Just(MutationType::Prefix),
165            Just(MutationType::Suffix),
166            Just(MutationType::Abi),
167        ]
168        .boxed();
169        let mut history_map = vec![0u8; COVERAGE_MAP_SIZE];
170        let mut metrics = CorpusMetrics::default();
171        let mut in_memory_corpus = vec![];
172        let mut failed_replays = 0;
173
174        // Early return if corpus dir / coverage guided fuzzing not configured.
175        let Some(corpus_dir) = &config.corpus_dir else {
176            return Ok(Self {
177                tx_generator,
178                mutation_generator,
179                config,
180                in_memory_corpus,
181                current_mutated: None,
182                failed_replays,
183                history_map,
184                metrics,
185            });
186        };
187
188        // Ensure corpus dir for current test is created.
189        if !corpus_dir.is_dir() {
190            foundry_common::fs::create_dir_all(corpus_dir)?;
191        }
192
193        let can_replay_tx = |tx: &BasicTxDetails| -> bool {
194            fuzzed_contracts.is_some_and(|contracts| contracts.targets.lock().can_replay(tx))
195                || fuzzed_function.is_some_and(|function| {
196                    tx.call_details
197                        .calldata
198                        .get(..4)
199                        .is_some_and(|selector| function.selector() == selector)
200                })
201        };
202
203        'corpus_replay: for entry in std::fs::read_dir(corpus_dir)? {
204            let path = entry?.path();
205            if path.is_file()
206                && let Some(name) = path.file_name().and_then(|s| s.to_str())
207                && name.contains(METADATA_SUFFIX)
208            {
209                // Ignore metadata files
210                continue;
211            }
212
213            let read_corpus_result = match path.extension().and_then(|ext| ext.to_str()) {
214                Some("gz") => foundry_common::fs::read_json_gzip_file::<Vec<BasicTxDetails>>(&path),
215                _ => foundry_common::fs::read_json_file::<Vec<BasicTxDetails>>(&path),
216            };
217
218            let Ok(tx_seq) = read_corpus_result else {
219                trace!(target: "corpus", "failed to load corpus from {}", path.display());
220                continue;
221            };
222
223            if !tx_seq.is_empty() {
224                // Warm up history map from loaded sequences.
225                let mut executor = executor.clone();
226                for tx in &tx_seq {
227                    if can_replay_tx(tx) {
228                        let mut call_result = execute_tx(&mut executor, tx)?;
229                        let (new_coverage, is_edge) =
230                            call_result.merge_edge_coverage(&mut history_map);
231                        if new_coverage {
232                            metrics.update_seen(is_edge);
233                        }
234
235                        // Commit only when running invariant / stateful tests.
236                        if fuzzed_contracts.is_some() {
237                            executor.commit(&mut call_result);
238                        }
239                    } else {
240                        failed_replays += 1;
241
242                        // If the only input for fuzzed function cannot be replied, then move to
243                        // next one without adding it in memory.
244                        if fuzzed_function.is_some() {
245                            continue 'corpus_replay;
246                        }
247                    }
248                }
249
250                metrics.corpus_count += 1;
251
252                trace!(
253                    target: "corpus",
254                    "load sequence with len {} from corpus file {}",
255                    tx_seq.len(),
256                    path.display()
257                );
258
259                // Populate in memory corpus with the sequence from corpus file.
260                in_memory_corpus.push(CorpusEntry::new(tx_seq, path)?);
261            }
262        }
263
264        Ok(Self {
265            tx_generator,
266            mutation_generator,
267            config,
268            in_memory_corpus,
269            current_mutated: None,
270            failed_replays,
271            history_map,
272            metrics,
273        })
274    }
275
276    /// Updates stats for the given call sequence, if new coverage produced.
277    /// Persists the call sequence (if corpus directory is configured and new coverage) and updates
278    /// in-memory corpus.
279    pub fn process_inputs(&mut self, inputs: &[BasicTxDetails], new_coverage: bool) {
280        // Early return if corpus dir / coverage guided fuzzing is not configured.
281        let Some(corpus_dir) = &self.config.corpus_dir else {
282            return;
283        };
284
285        // Update stats of current mutated primary corpus.
286        if let Some(uuid) = &self.current_mutated {
287            if let Some(corpus) =
288                self.in_memory_corpus.iter_mut().find(|corpus| corpus.uuid.eq(uuid))
289            {
290                corpus.total_mutations += 1;
291                if new_coverage {
292                    corpus.new_finds_produced += 1
293                }
294                let is_favored = (corpus.new_finds_produced as f64 / corpus.total_mutations as f64)
295                    > FAVORABILITY_THRESHOLD;
296                self.metrics.update_favored(is_favored, corpus.is_favored);
297                corpus.is_favored = is_favored;
298
299                trace!(
300                    target: "corpus",
301                    "updated corpus {}, total mutations: {}, new finds: {}",
302                    corpus.uuid, corpus.total_mutations, corpus.new_finds_produced
303                );
304            }
305
306            self.current_mutated = None;
307        }
308
309        // Collect inputs only if current run produced new coverage.
310        if !new_coverage {
311            return;
312        }
313
314        let corpus = CorpusEntry::from_tx_seq(inputs);
315        let corpus_uuid = corpus.uuid;
316
317        // Persist to disk if corpus dir is configured.
318        let write_result = if self.config.corpus_gzip {
319            foundry_common::fs::write_json_gzip_file(
320                corpus_dir.join(format!("{corpus_uuid}{JSON_EXTENSION}.gz")).as_path(),
321                &corpus.tx_seq,
322            )
323        } else {
324            foundry_common::fs::write_json_file(
325                corpus_dir.join(format!("{corpus_uuid}{JSON_EXTENSION}")).as_path(),
326                &corpus.tx_seq,
327            )
328        };
329
330        if let Err(err) = write_result {
331            debug!(target: "corpus", %err, "Failed to record call sequence {:?}", &corpus.tx_seq);
332        } else {
333            trace!(
334                target: "corpus",
335                "persisted {} inputs for new coverage in {corpus_uuid} corpus",
336                &corpus.tx_seq.len()
337            );
338        }
339
340        // This includes reverting txs in the corpus and `can_continue` removes
341        // them. We want this as it is new coverage and may help reach the other branch.
342        self.metrics.corpus_count += 1;
343        self.in_memory_corpus.push(corpus);
344    }
345
346    /// Generates new call sequence from in memory corpus. Evicts oldest corpus mutated more than
347    /// configured max mutations value. Used by invariant test campaigns.
348    pub fn new_inputs(
349        &mut self,
350        test_runner: &mut TestRunner,
351        fuzz_state: &EvmFuzzState,
352        targeted_contracts: &FuzzRunIdentifiedContracts,
353    ) -> eyre::Result<Vec<BasicTxDetails>> {
354        let mut new_seq = vec![];
355
356        // Early return with first_input only if corpus dir / coverage guided fuzzing not
357        // configured.
358        if !self.config.is_coverage_guided() {
359            new_seq.push(self.new_tx(test_runner)?);
360            return Ok(new_seq);
361        };
362
363        if !self.in_memory_corpus.is_empty() {
364            self.evict_oldest_corpus()?;
365
366            let mutation_type = self
367                .mutation_generator
368                .new_tree(test_runner)
369                .map_err(|err| eyre!("Could not generate mutation type {err}"))?
370                .current();
371            let rng = test_runner.rng();
372            let corpus_len = self.in_memory_corpus.len();
373            let primary = &self.in_memory_corpus[rng.random_range(0..corpus_len)];
374            let secondary = &self.in_memory_corpus[rng.random_range(0..corpus_len)];
375
376            match mutation_type {
377                MutationType::Splice => {
378                    trace!(target: "corpus", "splice {} and {}", primary.uuid, secondary.uuid);
379
380                    self.current_mutated = Some(primary.uuid);
381
382                    let start1 = rng.random_range(0..primary.tx_seq.len());
383                    let end1 = rng.random_range(start1..primary.tx_seq.len());
384
385                    let start2 = rng.random_range(0..secondary.tx_seq.len());
386                    let end2 = rng.random_range(start2..secondary.tx_seq.len());
387
388                    for tx in primary.tx_seq.iter().take(end1).skip(start1) {
389                        new_seq.push(tx.clone());
390                    }
391                    for tx in secondary.tx_seq.iter().take(end2).skip(start2) {
392                        new_seq.push(tx.clone());
393                    }
394                }
395                MutationType::Repeat => {
396                    let corpus = if rng.random::<bool>() { primary } else { secondary };
397                    trace!(target: "corpus", "repeat {}", corpus.uuid);
398
399                    self.current_mutated = Some(corpus.uuid);
400
401                    new_seq = corpus.tx_seq.clone();
402                    let start = rng.random_range(0..corpus.tx_seq.len());
403                    let end = rng.random_range(start..corpus.tx_seq.len());
404                    let item_idx = rng.random_range(0..corpus.tx_seq.len());
405                    let repeated = vec![new_seq[item_idx].clone(); end - start];
406                    new_seq.splice(start..end, repeated);
407                }
408                MutationType::Interleave => {
409                    trace!(target: "corpus", "interleave {} with {}", primary.uuid, secondary.uuid);
410
411                    self.current_mutated = Some(primary.uuid);
412
413                    for (tx1, tx2) in primary.tx_seq.iter().zip(secondary.tx_seq.iter()) {
414                        // chunks?
415                        let tx = if rng.random::<bool>() { tx1.clone() } else { tx2.clone() };
416                        new_seq.push(tx);
417                    }
418                }
419                MutationType::Prefix => {
420                    let corpus = if rng.random::<bool>() { primary } else { secondary };
421                    trace!(target: "corpus", "overwrite prefix of {}", corpus.uuid);
422
423                    self.current_mutated = Some(corpus.uuid);
424
425                    new_seq = corpus.tx_seq.clone();
426                    for i in 0..rng.random_range(0..=new_seq.len()) {
427                        new_seq[i] = self.new_tx(test_runner)?;
428                    }
429                }
430                MutationType::Suffix => {
431                    let corpus = if rng.random::<bool>() { primary } else { secondary };
432                    trace!(target: "corpus", "overwrite suffix of {}", corpus.uuid);
433
434                    self.current_mutated = Some(corpus.uuid);
435
436                    new_seq = corpus.tx_seq.clone();
437                    for i in new_seq.len() - rng.random_range(0..new_seq.len())..corpus.tx_seq.len()
438                    {
439                        new_seq[i] = self.new_tx(test_runner)?;
440                    }
441                }
442                MutationType::Abi => {
443                    let targets = targeted_contracts.targets.lock();
444                    let corpus = if rng.random::<bool>() { primary } else { secondary };
445                    trace!(target: "corpus", "ABI mutate args of {}", corpus.uuid);
446
447                    self.current_mutated = Some(corpus.uuid);
448
449                    new_seq = corpus.tx_seq.clone();
450
451                    let idx = rng.random_range(0..new_seq.len());
452                    let tx = new_seq.get_mut(idx).unwrap();
453                    if let (_, Some(function)) = targets.fuzzed_artifacts(tx) {
454                        // TODO add call_value to call details and mutate it as well as sender some
455                        // of the time
456                        if !function.inputs.is_empty() {
457                            self.abi_mutate(tx, function, test_runner, fuzz_state)?;
458                        }
459                    }
460                }
461            }
462        }
463
464        // Make sure the new sequence contains at least one tx to start fuzzing from.
465        if new_seq.is_empty() {
466            new_seq.push(self.new_tx(test_runner)?);
467        }
468        trace!(target: "corpus", "new sequence of {} calls generated", new_seq.len());
469
470        Ok(new_seq)
471    }
472
473    /// Generates new input from in memory corpus. Evicts oldest corpus mutated more than
474    /// configured max mutations value. Used by fuzz test campaigns.
475    pub fn new_input(
476        &mut self,
477        test_runner: &mut TestRunner,
478        fuzz_state: &EvmFuzzState,
479        function: &Function,
480    ) -> eyre::Result<Bytes> {
481        // Early return if not running with coverage guided fuzzing.
482        if !self.config.is_coverage_guided() {
483            return Ok(self.new_tx(test_runner)?.call_details.calldata);
484        }
485
486        let tx = if !self.in_memory_corpus.is_empty() {
487            self.evict_oldest_corpus()?;
488
489            let corpus = &self.in_memory_corpus
490                [test_runner.rng().random_range(0..self.in_memory_corpus.len())];
491            self.current_mutated = Some(corpus.uuid);
492            let new_seq = corpus.tx_seq.clone();
493            let mut tx = new_seq.first().unwrap().clone();
494            self.abi_mutate(&mut tx, function, test_runner, fuzz_state)?;
495            tx
496        } else {
497            self.new_tx(test_runner)?
498        };
499
500        Ok(tx.call_details.calldata)
501    }
502
503    /// Returns the next call to be used in call sequence.
504    /// If coverage guided fuzzing is not configured or if previous input was discarded then this is
505    /// a new tx from strategy.
506    /// If running with coverage guided fuzzing it returns a new call only when sequence
507    /// does not have enough entries, or randomly. Otherwise, returns the next call from initial
508    /// sequence.
509    pub fn generate_next_input(
510        &mut self,
511        test_runner: &mut TestRunner,
512        sequence: &[BasicTxDetails],
513        discarded: bool,
514        depth: usize,
515    ) -> eyre::Result<BasicTxDetails> {
516        // Early return with new input if corpus dir / coverage guided fuzzing not configured or if
517        // call was discarded.
518        if self.config.corpus_dir.is_none() || discarded {
519            return self.new_tx(test_runner);
520        }
521
522        // When running with coverage guided fuzzing enabled then generate new sequence if initial
523        // sequence's length is less than depth or randomly, to occasionally intermix new txs.
524        if depth > sequence.len().saturating_sub(1) || test_runner.rng().random_ratio(1, 10) {
525            return self.new_tx(test_runner);
526        }
527
528        // Continue with the next call initial sequence
529        Ok(sequence[depth].clone())
530    }
531
532    /// Generates single call from corpus strategy.
533    pub fn new_tx(&mut self, test_runner: &mut TestRunner) -> eyre::Result<BasicTxDetails> {
534        Ok(self
535            .tx_generator
536            .new_tree(test_runner)
537            .map_err(|_| eyre!("Could not generate case"))?
538            .current())
539    }
540
541    /// Returns campaign failed replays.
542    pub fn failed_replays(self) -> usize {
543        self.failed_replays
544    }
545
546    /// Collects coverage from call result and updates metrics.
547    pub fn merge_edge_coverage(&mut self, call_result: &mut RawCallResult) -> bool {
548        if !self.config.collect_edge_coverage() {
549            return false;
550        }
551
552        let (new_coverage, is_edge) = call_result.merge_edge_coverage(&mut self.history_map);
553        if new_coverage {
554            self.metrics.update_seen(is_edge);
555        }
556        new_coverage
557    }
558
559    /// Flush the oldest corpus mutated more than configured max mutations unless they are
560    /// favored.
561    fn evict_oldest_corpus(&mut self) -> eyre::Result<()> {
562        if self.in_memory_corpus.len() > self.config.corpus_min_size.max(1)
563            && let Some(index) = self.in_memory_corpus.iter().position(|corpus| {
564                corpus.total_mutations > self.config.corpus_min_mutations && !corpus.is_favored
565            })
566        {
567            let corpus = self.in_memory_corpus.get(index).unwrap();
568
569            let uuid = corpus.uuid;
570            debug!(target: "corpus", "evict corpus {uuid}");
571
572            // Flush to disk the seed metadata at the time of eviction.
573            let eviction_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
574            foundry_common::fs::write_json_file(
575                self.config
576                    .corpus_dir
577                    .clone()
578                    .unwrap()
579                    .join(format!("{uuid}-{eviction_time}-{METADATA_SUFFIX}"))
580                    .as_path(),
581                &corpus,
582            )?;
583
584            // Remove corpus from memory.
585            self.in_memory_corpus.remove(index);
586        }
587        Ok(())
588    }
589
590    /// Mutates calldata of provided tx by abi decoding current values and randomly selecting the
591    /// inputs to change.
592    fn abi_mutate(
593        &self,
594        tx: &mut BasicTxDetails,
595        function: &Function,
596        test_runner: &mut TestRunner,
597        fuzz_state: &EvmFuzzState,
598    ) -> eyre::Result<()> {
599        // let rng = test_runner.rng();
600        let mut arg_mutation_rounds =
601            test_runner.rng().random_range(0..=function.inputs.len()).max(1);
602        let round_arg_idx: Vec<usize> = if function.inputs.len() <= 1 {
603            vec![0]
604        } else {
605            (0..arg_mutation_rounds)
606                .map(|_| test_runner.rng().random_range(0..function.inputs.len()))
607                .collect()
608        };
609        let mut prev_inputs = function
610            .abi_decode_input(&tx.call_details.calldata[4..])
611            .map_err(|err| eyre!("failed to load previous inputs: {err}"))?;
612
613        while arg_mutation_rounds > 0 {
614            let idx = round_arg_idx[arg_mutation_rounds - 1];
615            prev_inputs[idx] = mutate_param_value(
616                &function
617                    .inputs
618                    .get(idx)
619                    .expect("Could not get input to mutate")
620                    .selector_type()
621                    .parse()?,
622                prev_inputs[idx].clone(),
623                test_runner,
624                fuzz_state,
625            );
626            arg_mutation_rounds -= 1;
627        }
628
629        tx.call_details.calldata =
630            function.abi_encode_input(&prev_inputs).map_err(|e| eyre!(e.to_string()))?.into();
631        Ok(())
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use alloy_primitives::Address;
639    use std::fs;
640
641    fn basic_tx() -> BasicTxDetails {
642        BasicTxDetails {
643            warp: None,
644            roll: None,
645            sender: Address::ZERO,
646            call_details: foundry_evm_fuzz::CallDetails {
647                target: Address::ZERO,
648                calldata: Bytes::new(),
649            },
650        }
651    }
652
653    fn temp_corpus_dir() -> PathBuf {
654        let dir = std::env::temp_dir().join(format!("foundry-corpus-tests-{}", Uuid::new_v4()));
655        let _ = fs::create_dir_all(&dir);
656        dir
657    }
658
659    fn new_manager_with_single_corpus() -> (CorpusManager, Uuid) {
660        let tx_gen = Just(basic_tx()).boxed();
661        let config = FuzzCorpusConfig {
662            corpus_dir: Some(temp_corpus_dir()),
663            corpus_gzip: false,
664            corpus_min_mutations: 0,
665            corpus_min_size: 0,
666            ..Default::default()
667        };
668
669        let tx_seq = vec![basic_tx()];
670        let corpus = CorpusEntry::from_tx_seq(&tx_seq);
671        let seed_uuid = corpus.uuid;
672
673        let manager = CorpusManager {
674            tx_generator: tx_gen,
675            mutation_generator: Just(MutationType::Repeat).boxed(),
676            config,
677            in_memory_corpus: vec![corpus],
678            current_mutated: Some(seed_uuid),
679            failed_replays: 0,
680            history_map: vec![0u8; COVERAGE_MAP_SIZE],
681            metrics: CorpusMetrics::default(),
682        };
683
684        (manager, seed_uuid)
685    }
686
687    #[test]
688    fn favored_sets_true_and_metrics_increment_when_ratio_gt_threshold() {
689        let (mut manager, uuid) = new_manager_with_single_corpus();
690        let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
691        corpus.total_mutations = 4;
692        corpus.new_finds_produced = 2; // ratio currently 0.5 if both increment → 3/5 = 0.6 > 0.3
693        corpus.is_favored = false;
694
695        // ensure metrics start at 0
696        assert_eq!(manager.metrics.favored_items, 0);
697
698        // mark this as the currently mutated corpus and process a run with new coverage
699        manager.current_mutated = Some(uuid);
700        manager.process_inputs(&[basic_tx()], true);
701
702        let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
703        assert!(corpus.is_favored, "expected favored to be true when ratio > threshold");
704        assert_eq!(
705            manager.metrics.favored_items, 1,
706            "favored_items should increment on false→true"
707        );
708    }
709
710    #[test]
711    fn favored_sets_false_and_metrics_decrement_when_ratio_lt_threshold() {
712        let (mut manager, uuid) = new_manager_with_single_corpus();
713        let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
714        corpus.total_mutations = 9;
715        corpus.new_finds_produced = 3; // 3/9 = 0.333.. > 0.3; after +1: 3/10 = 0.3 => not favored
716        corpus.is_favored = true; // start as favored
717
718        manager.metrics.favored_items = 1;
719
720        // Next run does NOT produce coverage → only total_mutations increments, ratio drops
721        manager.current_mutated = Some(uuid);
722        manager.process_inputs(&[basic_tx()], false);
723
724        let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
725        assert!(!corpus.is_favored, "expected favored to be false when ratio < threshold");
726        assert_eq!(
727            manager.metrics.favored_items, 0,
728            "favored_items should decrement on true→false"
729        );
730    }
731
732    #[test]
733    fn favored_is_false_on_ratio_equal_threshold() {
734        let (mut manager, uuid) = new_manager_with_single_corpus();
735        let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap();
736        // After this call with new_coverage=true, totals become 10 and 3 → 0.3
737        corpus.total_mutations = 9;
738        corpus.new_finds_produced = 2;
739        corpus.is_favored = false;
740
741        manager.current_mutated = Some(uuid);
742        manager.process_inputs(&[basic_tx()], true);
743
744        let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap();
745        assert!(
746            !(corpus.is_favored),
747            "with strict '>' comparison, favored must be false when ratio == threshold"
748        );
749    }
750
751    #[test]
752    fn eviction_skips_favored_and_evicts_non_favored() {
753        // manager with two corpora
754        let tx_gen = Just(basic_tx()).boxed();
755        let config = FuzzCorpusConfig {
756            corpus_dir: Some(temp_corpus_dir()),
757            corpus_min_mutations: 0,
758            corpus_min_size: 0,
759            ..Default::default()
760        };
761
762        let mut favored = CorpusEntry::from_tx_seq(&[basic_tx()]);
763        favored.total_mutations = 2;
764        favored.is_favored = true;
765
766        let mut non_favored = CorpusEntry::from_tx_seq(&[basic_tx()]);
767        non_favored.total_mutations = 2;
768        non_favored.is_favored = false;
769        let non_favored_uuid = non_favored.uuid;
770
771        let mut manager = CorpusManager {
772            tx_generator: tx_gen,
773            mutation_generator: Just(MutationType::Repeat).boxed(),
774            config,
775            in_memory_corpus: vec![favored, non_favored],
776            current_mutated: None,
777            failed_replays: 0,
778            history_map: vec![0u8; COVERAGE_MAP_SIZE],
779            metrics: CorpusMetrics::default(),
780        };
781
782        // First eviction should remove the non-favored one
783        manager.evict_oldest_corpus().unwrap();
784        assert_eq!(manager.in_memory_corpus.len(), 1);
785        assert!(manager.in_memory_corpus.iter().all(|c| c.is_favored));
786
787        // Attempt eviction again: only favored remains → should not remove
788        manager.evict_oldest_corpus().unwrap();
789        assert_eq!(manager.in_memory_corpus.len(), 1, "favored corpus must not be evicted");
790
791        // ensure the evicted one was the non-favored uuid
792        assert!(manager.in_memory_corpus.iter().all(|c| c.uuid != non_favored_uuid));
793    }
794}