Skip to main content

forge/cmd/
fuzz.rs

1use crate::{
2    cmd::test::{CampaignArgs, FilterArgs, FuzzMinimizeReplaySession, ShowmapDomainArg, TestArgs},
3    multi_runner::{
4        FuzzMinimizeEdgeIndices, FuzzMinimizeMode, FuzzMinimizeObservation, ShowmapConfig,
5    },
6    result::TestOutcome,
7};
8use alloy_dyn_abi::{DynSolValue, JsonAbiExt};
9use alloy_json_abi::{Function, JsonAbi};
10use alloy_primitives::{Address, B256, Function as SolFunction, I256, Selector, U256};
11use clap::{Parser, Subcommand, ValueEnum, ValueHint};
12use eyre::{Context, Result, bail};
13use flate2::{Compression, write::GzEncoder};
14use foundry_cli::{
15    opts::{BuildOpts, EvmArgs, GlobalArgs},
16    utils::LoadConfig,
17};
18use foundry_common::{
19    fmt::format_tokens_raw,
20    fs, sh_println, sh_status,
21    shell::{OutputMode, Shell},
22};
23use foundry_config::{Config, filter::GlobMatcher};
24use foundry_evm::{
25    executors::{CorpusDirEntry, ReplayObservation, ShowmapDomain, read_corpus_tree},
26    fuzz::BasicTxDetails,
27};
28use serde::Serialize;
29use std::{
30    collections::BTreeMap,
31    fs::OpenOptions,
32    io::{BufWriter, Write},
33    path::{Path, PathBuf},
34    pin::Pin,
35    time::{SystemTime, UNIX_EPOCH},
36};
37use tempfile::{Builder as TempDirBuilder, TempDir};
38
39type FuzzOutcomeFuture = Pin<Box<dyn Future<Output = Result<TestOutcome>>>>;
40
41/// Run and manage Forge fuzzing corpora.
42#[derive(Clone, Debug, Parser)]
43pub struct FuzzArgs {
44    #[command(subcommand)]
45    pub command: FuzzSubcommands,
46}
47
48impl FuzzArgs {
49    pub fn run(self) -> FuzzOutcomeFuture {
50        match self.command {
51            FuzzSubcommands::Run(args) => {
52                let mut test = TestArgs::from_fuzz_run(args);
53                test.enable_fuzz_only_with_auto_fuzz_corpus();
54                Box::pin(test.run())
55            }
56            FuzzSubcommands::Replay(args) => Box::pin(args.run()),
57            FuzzSubcommands::Show(args) => Box::pin(async move {
58                args.run()?;
59                Ok(TestOutcome::empty(None, true))
60            }),
61            FuzzSubcommands::Cmin(args) => Box::pin(async move {
62                args.run().await?;
63                Ok(TestOutcome::empty(None, true))
64            }),
65            FuzzSubcommands::Tmin(args) => Box::pin(async move {
66                args.run().await?;
67                Ok(TestOutcome::empty(None, true))
68            }),
69        }
70    }
71
72    pub const fn is_junit(&self) -> bool {
73        match &self.command {
74            FuzzSubcommands::Run(args) => args.junit,
75            FuzzSubcommands::Replay(args) => args.is_junit(),
76            FuzzSubcommands::Show(_) | FuzzSubcommands::Cmin(_) | FuzzSubcommands::Tmin(_) => false,
77        }
78    }
79}
80
81#[derive(Clone, Debug, Subcommand)]
82#[allow(clippy::large_enum_variant)]
83pub enum FuzzSubcommands {
84    /// Run only fuzz and invariant tests.
85    Run(FuzzRunArgs),
86    /// Replay persisted fuzz failures, or corpus entries with `--corpus-dir`.
87    Replay(FuzzReplayArgs),
88    /// Print persisted corpus entries.
89    Show(FuzzShowArgs),
90    /// Minimize a corpus by keeping entries that contribute new coverage.
91    Cmin(FuzzCminArgs),
92    /// Minimize one corpus entry while preserving its failure or coverage.
93    Tmin(FuzzTminArgs),
94}
95
96/// Run only fuzz and invariant tests.
97#[derive(Clone, Debug, Parser)]
98pub struct FuzzRunArgs {
99    #[command(flatten)]
100    pub(crate) global: GlobalArgs,
101
102    /// The contract file you want to test, it's a shortcut for --match-path.
103    #[arg(value_hint = ValueHint::FilePath)]
104    pub(crate) path: Option<GlobMatcher>,
105
106    #[command(flatten)]
107    pub(crate) filter: FilterArgs,
108
109    #[command(flatten)]
110    pub(crate) campaign: CampaignArgs,
111
112    #[command(flatten)]
113    pub(crate) evm: EvmArgs,
114
115    #[command(flatten)]
116    pub(crate) build: BuildOpts,
117
118    /// Output test results as JUnit XML report.
119    #[arg(long, conflicts_with_all = ["quiet", "json", "gas_report", "list", "show_progress"], help_heading = "Display options")]
120    pub(crate) junit: bool,
121
122    /// Exit with code 0 even if a test fails.
123    #[arg(long, env = "FORGE_ALLOW_FAILURE")]
124    pub(crate) allow_failure: bool,
125
126    /// Stop running tests after the first failure.
127    #[arg(long)]
128    pub(crate) fail_fast: bool,
129
130    /// Re-run recorded test failures from last run.
131    /// If no failure recorded then regular test run is performed.
132    #[arg(long)]
133    pub(crate) rerun: bool,
134
135    /// Show test execution progress.
136    #[arg(long, conflicts_with_all = ["quiet", "json"], help_heading = "Display options")]
137    pub(crate) show_progress: bool,
138
139    /// The Etherscan (or equivalent) API key.
140    #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")]
141    pub(crate) etherscan_api_key: Option<String>,
142
143    /// List fuzz and invariant tests instead of running them.
144    #[arg(long, short, conflicts_with_all = ["show_progress"], help_heading = "Display options")]
145    pub(crate) list: bool,
146
147    /// Print a gas report.
148    #[arg(long, env = "FORGE_GAS_REPORT")]
149    pub(crate) gas_report: bool,
150
151    /// Replay the persisted corpus and emit AFL-`afl-showmap`-style coverage
152    /// files at the given output directory.
153    #[arg(
154        long,
155        value_name = "DIR",
156        value_hint = ValueHint::DirPath,
157        help_heading = "Showmap replay",
158        conflicts_with_all = ["rerun", "fuzz_input_file", "gas_report"],
159    )]
160    pub(crate) showmap_out: Option<PathBuf>,
161
162    /// Emit one showmap file per corpus entry (default: one aggregated file per test).
163    #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")]
164    pub(crate) showmap_per_input: bool,
165
166    /// Coverage domain(s) to dump.
167    #[arg(
168        long,
169        value_enum,
170        default_value_t = ShowmapDomainArg::Evm,
171        help_heading = "Showmap replay",
172        requires = "showmap_out",
173    )]
174    pub(crate) showmap_domain: ShowmapDomainArg,
175
176    /// Approach name (used as a subdirectory of `--showmap-out`).
177    #[arg(
178        long,
179        default_value = "replay",
180        help_heading = "Showmap replay",
181        requires = "showmap_out"
182    )]
183    pub(crate) showmap_approach: String,
184
185    /// Trial identifier embedded in each showmap filename.
186    #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")]
187    pub(crate) showmap_trial: Option<String>,
188
189    /// Override the corpus directory to replay.
190    #[arg(
191        long,
192        value_name = "PATH",
193        value_hint = ValueHint::DirPath,
194        help_heading = "Showmap replay",
195        requires = "showmap_out",
196    )]
197    pub(crate) showmap_corpus_dir: Option<PathBuf>,
198
199    /// File to rerun fuzz failures from.
200    #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath, conflicts_with = "list")]
201    pub(crate) fuzz_input_file: Option<PathBuf>,
202}
203
204/// Replay persisted fuzz failures, or corpus entries with `--corpus-dir`.
205#[derive(Clone, Debug, Parser)]
206pub struct FuzzReplayArgs {
207    #[command(flatten)]
208    run: FuzzRunArgs,
209}
210
211impl FuzzReplayArgs {
212    async fn run(self) -> Result<TestOutcome> {
213        let corpus_dir = self.run.campaign.corpus_dir.clone();
214        if corpus_dir.is_some() && self.run.fuzz_input_file.is_some() {
215            bail!("`--fuzz-input-file` cannot be combined with `--corpus-dir`");
216        }
217        let mut test = TestArgs::from_fuzz_run(self.run);
218        if corpus_dir.is_none() {
219            test.enable_fuzz_failure_replay();
220            return test.run().await;
221        }
222
223        let replay_id =
224            SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
225        test.set_showmap_override(ShowmapConfig {
226            out_dir: std::env::temp_dir().join(format!("forge-fuzz-replay-{replay_id}")),
227            approach: "replay".to_string(),
228            trial: "replay".to_string(),
229            per_input: false,
230            domain: ShowmapDomain::Evm,
231            corpus_dir,
232            emit_files: false,
233        });
234        test.run().await
235    }
236
237    const fn is_junit(&self) -> bool {
238        self.run.junit
239    }
240}
241
242#[derive(Clone, Copy, Debug, Default, ValueEnum)]
243#[clap(rename_all = "lowercase")]
244pub enum CorpusShowFormat {
245    #[default]
246    Human,
247    Json,
248}
249
250/// Print persisted corpus entries.
251#[derive(Clone, Debug, Parser)]
252pub struct FuzzShowArgs {
253    /// Corpus directory or a single corpus file.
254    #[arg(value_name = "PATH", value_hint = ValueHint::AnyPath)]
255    corpus: PathBuf,
256    /// Output format.
257    #[arg(long, value_enum, default_value_t)]
258    format: CorpusShowFormat,
259    /// Maximum number of entries to print.
260    #[arg(long, value_name = "N")]
261    limit: Option<usize>,
262}
263
264impl FuzzShowArgs {
265    fn run(&self) -> Result<()> {
266        let decoder = CorpusDecoder::load();
267        let entries = read_entries(&self.corpus, self.limit, &decoder)?;
268        match self.format {
269            CorpusShowFormat::Human => {
270                for entry in entries {
271                    sh_println!("{} ({} txs)", entry.path.display(), entry.sequence.len())?;
272                    for (idx, tx) in entry.sequence.iter().enumerate() {
273                        if let Some(decoded) = &tx.decoded {
274                            let ambiguity = if decoded.ambiguous_contracts.is_empty() {
275                                String::new()
276                            } else {
277                                format!(" ambiguous=[{}]", decoded.ambiguous_contracts.join(","))
278                            };
279                            sh_println!(
280                                "  {idx}: {} sender={} target={} value={}{}",
281                                decoded.call,
282                                tx.raw.sender,
283                                tx.raw.call_details.target,
284                                tx.raw
285                                    .call_details
286                                    .value
287                                    .map(|v| v.to_string())
288                                    .unwrap_or_else(|| "0".to_string()),
289                                ambiguity
290                            )?;
291                        } else {
292                            sh_println!(
293                                "  {idx}: target={} sender={} calldata={} value={}",
294                                tx.raw.call_details.target,
295                                tx.raw.sender,
296                                tx.raw.call_details.calldata,
297                                tx.raw
298                                    .call_details
299                                    .value
300                                    .map(|v| v.to_string())
301                                    .unwrap_or_else(|| "0".to_string())
302                            )?;
303                        }
304                    }
305                }
306            }
307            CorpusShowFormat::Json => sh_println!("{}", serde_json::to_string_pretty(&entries)?)?,
308        }
309        Ok(())
310    }
311}
312
313/// Minimize a corpus by keeping entries that contribute new coverage.
314#[derive(Clone, Debug, Parser)]
315pub struct FuzzCminArgs {
316    #[command(flatten)]
317    test: FuzzMinimizeTestArgs,
318    /// Input corpus directory.
319    #[arg(value_name = "CORPUS_DIR", value_hint = ValueHint::DirPath)]
320    corpus_dir: PathBuf,
321    /// Output corpus directory.
322    #[arg(long = "corpus-out", value_name = "DIR", value_hint = ValueHint::DirPath)]
323    out: PathBuf,
324}
325
326impl FuzzCminArgs {
327    async fn run(self) -> Result<()> {
328        if cmin_out_exists(&self.out) {
329            bail!("output corpus directory already exists: {}", self.out.display());
330        }
331
332        let staging_out = temporary_cmin_out(&self.out)?;
333        let summary = self.run_to(staging_out.path()).await?;
334        let staging_path = staging_out.keep();
335
336        if cmin_out_exists(&self.out) {
337            bail!(
338                "output corpus directory already exists: {}; minimized corpus remains staged at {}",
339                self.out.display(),
340                staging_path.display()
341            );
342        }
343
344        std::fs::rename(&staging_path, &self.out).with_context(|| {
345            format!(
346                "failed to rename minimized corpus {} to {}",
347                staging_path.display(),
348                self.out.display()
349            )
350        })?;
351
352        sh_println!(
353            "minimized corpus: kept {}/{} entries in {}",
354            summary.kept,
355            summary.total,
356            self.out.display()
357        )?;
358        if summary.skipped > 0 {
359            sh_status!(
360                "skipped {} entries or txs that could not be read or replayed",
361                summary.skipped
362            )?;
363        }
364        Ok(())
365    }
366
367    async fn run_to(&self, out_dir: &Path) -> Result<CminSummary> {
368        let session = self.test.clone().prepare_session(&self.corpus_dir).await?;
369        let mut kept = 0usize;
370        let mut total = 0usize;
371        let mut skipped_entries = 0usize;
372        let mut unreadable = 0usize;
373        let mut empty = 0usize;
374        let mut unmatched_txs = 0usize;
375        let mut rejected_txs = 0usize;
376        let mut failed_entries = 0usize;
377        let mut failed_replays = 0usize;
378        let mut replayed = 0usize;
379        let mut cumulative = BTreeMap::<String, ReplayObservation>::new();
380        let evm_edge_indices = FuzzMinimizeEdgeIndices::default();
381
382        for entry in read_corpus_entries(&self.corpus_dir)? {
383            total += 1;
384            let sequence = entry
385                .read_tx_seq()
386                .with_context(|| format!("failed to read corpus entry {}", entry.path.display()));
387            let Ok(sequence) = sequence else {
388                skipped_entries += 1;
389                unreadable += 1;
390                continue;
391            };
392            if sequence.is_empty() {
393                skipped_entries += 1;
394                empty += 1;
395                continue;
396            }
397            let observations = replay_candidate(
398                &session,
399                evm_edge_indices.clone(),
400                sequence,
401                FuzzMinimizeMode::Cmin,
402            )?;
403            let mut entry_improved = false;
404            let mut entry_failed = false;
405            let mut entry_failed_replays = 0usize;
406            let mut entry_replayed = 0usize;
407            let mut entry_unmatched_txs = 0usize;
408            let mut entry_rejected_txs = 0usize;
409            for FuzzMinimizeObservation { target, observation } in observations {
410                if observation.has_non_predicate_failure() {
411                    entry_failed = true;
412                    entry_failed_replays += observation.replayed;
413                    continue;
414                }
415                entry_replayed += observation.replayed;
416                entry_unmatched_txs = entry_unmatched_txs.max(observation.unmatched);
417                entry_rejected_txs = entry_rejected_txs.max(observation.skipped);
418                let cumulative = cumulative.entry(target).or_default();
419                entry_improved |= merge_new_edges(cumulative, &observation);
420            }
421            if entry_replayed > 0 {
422                replayed += entry_replayed;
423            } else if entry_failed {
424                skipped_entries += 1;
425                failed_entries += 1;
426                failed_replays += entry_failed_replays;
427            } else {
428                unmatched_txs += entry_unmatched_txs;
429                rejected_txs += entry_rejected_txs;
430            }
431            if !entry_improved {
432                continue;
433            }
434
435            let out = if self.corpus_dir.is_file() {
436                out_dir.join(entry.path.file_name().unwrap_or_default())
437            } else {
438                let relative = entry.path.strip_prefix(&self.corpus_dir).with_context(|| {
439                    format!(
440                        "corpus entry {} is not under {}",
441                        entry.path.display(),
442                        self.corpus_dir.display()
443                    )
444                })?;
445                out_dir.join(relative)
446            };
447            if let Some(parent) = out.parent() {
448                fs::create_dir_all(parent)?;
449            }
450            std::fs::copy(&entry.path, &out).with_context(|| {
451                format!("failed to copy {} to {}", entry.path.display(), out.display())
452            })?;
453            kept += 1;
454        }
455
456        if total > 0 && replayed == 0 {
457            let corpus = self.corpus_dir.display();
458            if unreadable == total {
459                bail!(
460                    "replayed 0 transactions from {corpus}; all {unreadable} corpus entries could not be read"
461                );
462            }
463            if failed_entries > 0 {
464                bail!(
465                    "replayed 0 successful transactions from {corpus}; {failed_entries} corpus \
466                     entries failed during replay after {failed_replays} replayed transactions"
467                );
468            }
469            if unmatched_txs > 0 {
470                bail!(
471                    "replayed 0 transactions from {corpus}; {unmatched_txs} transactions did not match \
472                     the test; check that --mc/--mt and replay-critical options match the corpus \
473                     entries"
474                );
475            }
476            if rejected_txs > 0 {
477                bail!(
478                    "replayed 0 transactions from {corpus}; {rejected_txs} transactions were rejected \
479                     by vm.assume or vm.skip"
480                );
481            }
482            if empty == total.saturating_sub(unreadable) {
483                bail!(
484                    "replayed 0 transactions from {corpus}; corpus entries were empty{}",
485                    if unreadable > 0 {
486                        format!(" or unreadable ({unreadable} unreadable)")
487                    } else {
488                        String::new()
489                    }
490                );
491            }
492            bail!(
493                "replayed 0 transactions from {corpus}; {unreadable} unreadable entries, {empty} \
494                 empty entries"
495            );
496        }
497
498        Ok(CminSummary { kept, total, skipped: skipped_entries + unmatched_txs + rejected_txs })
499    }
500}
501
502fn cmin_out_exists(out: &Path) -> bool {
503    std::fs::symlink_metadata(out).is_ok()
504}
505
506struct CminSummary {
507    kept: usize,
508    total: usize,
509    skipped: usize,
510}
511
512/// Minimize one corpus entry while preserving its failure or coverage.
513#[derive(Clone, Debug, Parser)]
514pub struct FuzzTminArgs {
515    #[command(flatten)]
516    test: FuzzMinimizeTestArgs,
517    /// Input corpus file or directory.
518    #[arg(value_name = "INPUT", value_hint = ValueHint::AnyPath)]
519    input: PathBuf,
520    /// Output corpus file or directory.
521    #[arg(long = "corpus-out", value_name = "PATH", value_hint = ValueHint::AnyPath)]
522    out: PathBuf,
523    /// Maximum candidate replays to attempt per corpus entry.
524    #[arg(long, default_value_t = 5000, value_name = "N")]
525    max_attempts: usize,
526}
527
528impl FuzzTminArgs {
529    async fn run(self) -> Result<()> {
530        if self.max_attempts == 0 {
531            bail!("--max-attempts must be greater than 0");
532        }
533        if self.input.is_dir() { self.run_dir().await } else { self.run_file().await }
534    }
535
536    async fn run_file(self) -> Result<()> {
537        validate_tmin_output_path(&self.out)?;
538
539        let mut sequence = read_single_sequence(&self.input)?;
540        if sequence.is_empty() {
541            bail!("corpus entry {} is empty", self.input.display());
542        }
543
544        let before_txs = sequence.len();
545        let decoder_args = self.test.clone();
546        let corpus_root = self
547            .input
548            .parent()
549            .filter(|parent| !parent.as_os_str().is_empty())
550            .unwrap_or(Path::new("."));
551        let session = self.test.prepare_session(corpus_root).await?;
552        let decoder = decoder_args.decoder();
553        let attempts =
554            minimize_entry(&session, &decoder, &self.input, &mut sequence, self.max_attempts)?;
555        write_sequence_create_new(&self.out, &sequence)?;
556
557        sh_println!(
558            "minimized entry: {before_txs} txs -> {} txs in {}",
559            sequence.len(),
560            self.out.display()
561        )?;
562        sh_status!("attempted {attempts} candidate replays")?;
563        Ok(())
564    }
565
566    async fn run_dir(self) -> Result<()> {
567        if cmin_out_exists(&self.out) {
568            bail!("output corpus directory already exists: {}", self.out.display());
569        }
570
571        let entries = read_corpus_entries(&self.input)?;
572        let staging_out = temporary_cmin_out(&self.out)?;
573        let decoder_args = self.test.clone();
574        let session = self.test.prepare_session(&self.input).await?;
575        let decoder = decoder_args.decoder();
576        let mut total_entries = 0usize;
577        let mut before_txs = 0usize;
578        let mut after_txs = 0usize;
579        let mut attempts = 0usize;
580        let mut skipped_entries = 0usize;
581
582        for entry in entries {
583            let sequence = entry
584                .read_tx_seq()
585                .with_context(|| format!("failed to read corpus entry {}", entry.path.display()));
586            let Ok(mut sequence) = sequence else {
587                skipped_entries += 1;
588                continue;
589            };
590            if sequence.is_empty() {
591                skipped_entries += 1;
592                continue;
593            }
594            before_txs += sequence.len();
595            attempts +=
596                minimize_entry(&session, &decoder, &entry.path, &mut sequence, self.max_attempts)?;
597            after_txs += sequence.len();
598
599            let relative = entry.path.strip_prefix(&self.input).with_context(|| {
600                format!(
601                    "corpus entry {} is not under {}",
602                    entry.path.display(),
603                    self.input.display()
604                )
605            })?;
606            write_sequence_create_new(&staging_out.path().join(relative), &sequence)?;
607            total_entries += 1;
608        }
609        if total_entries == 0 {
610            bail!("no readable non-empty corpus entries found under {}", self.input.display());
611        }
612
613        let staging_path = staging_out.keep();
614        if cmin_out_exists(&self.out) {
615            bail!(
616                "output corpus directory already exists: {}; minimized corpus remains staged at {}",
617                self.out.display(),
618                staging_path.display()
619            );
620        }
621        std::fs::rename(&staging_path, &self.out).with_context(|| {
622            format!(
623                "failed to rename minimized corpus {} to {}",
624                staging_path.display(),
625                self.out.display()
626            )
627        })?;
628
629        sh_println!(
630            "minimized corpus: {total_entries} entries, {before_txs} txs -> {after_txs} txs in {}",
631            self.out.display()
632        )?;
633        sh_status!("attempted {attempts} candidate replays")?;
634        if skipped_entries > 0 {
635            sh_status!("skipped {skipped_entries} entries that could not be read or were empty")?;
636        }
637        Ok(())
638    }
639}
640
641fn minimize_entry(
642    session: &FuzzMinimizeReplaySession,
643    decoder: &CorpusDecoder,
644    input: &Path,
645    sequence: &mut Vec<BasicTxDetails>,
646    max_attempts: usize,
647) -> Result<usize> {
648    let evm_edge_indices = FuzzMinimizeEdgeIndices::default();
649    let baseline = replay_baseline(session, evm_edge_indices.clone(), sequence.clone())
650        .with_context(|| format!("failed to replay baseline corpus entry {}", input.display()))?;
651    if baseline.requirements.is_empty() {
652        bail!(
653            "replayed 0 transactions from {}; check that --mc/--mt and replay-critical options match the corpus entry",
654            input.display()
655        );
656    }
657    if !baseline.has_failure && !baseline.has_coverage {
658        bail!("baseline replay for {} produced no failure or edge coverage", input.display());
659    }
660
661    let mut ctx = MinimizeContext::new(session, evm_edge_indices, baseline, max_attempts);
662    minimize_sequence(&mut ctx, sequence, decoder)?;
663    Ok(ctx.attempts)
664}
665
666struct ReplayBaseline {
667    requirements: BTreeMap<String, ReplayObservation>,
668    has_failure: bool,
669    has_coverage: bool,
670}
671
672fn replay_baseline(
673    session: &FuzzMinimizeReplaySession,
674    evm_edge_indices: FuzzMinimizeEdgeIndices,
675    sequence: Vec<BasicTxDetails>,
676) -> Result<ReplayBaseline> {
677    let observations =
678        replay_candidate(session, evm_edge_indices, sequence, FuzzMinimizeMode::Tmin)?;
679    let mut requirements = BTreeMap::new();
680    let mut has_failure = false;
681    let mut has_coverage = false;
682    for FuzzMinimizeObservation { target, observation } in observations {
683        let observation_has_failure = !observation.failures.is_empty();
684        let observation_has_coverage = has_edges(&observation);
685        if observation.replayed == 0 && !observation_has_failure && !observation_has_coverage {
686            continue;
687        }
688        has_failure |= observation_has_failure;
689        has_coverage |= observation_has_coverage;
690        requirements.insert(target, observation);
691    }
692    Ok(ReplayBaseline { requirements, has_failure, has_coverage })
693}
694
695struct MinimizeContext<'a> {
696    session: &'a FuzzMinimizeReplaySession,
697    evm_edge_indices: FuzzMinimizeEdgeIndices,
698    baseline: ReplayBaseline,
699    max_attempts: usize,
700    attempts: usize,
701}
702
703impl<'a> MinimizeContext<'a> {
704    const fn new(
705        session: &'a FuzzMinimizeReplaySession,
706        evm_edge_indices: FuzzMinimizeEdgeIndices,
707        baseline: ReplayBaseline,
708        max_attempts: usize,
709    ) -> Self {
710        Self { session, evm_edge_indices, baseline, max_attempts, attempts: 0 }
711    }
712
713    const fn at_budget(&self) -> bool {
714        self.attempts >= self.max_attempts
715    }
716
717    const fn remaining_attempts(&self) -> usize {
718        self.max_attempts.saturating_sub(self.attempts)
719    }
720
721    fn accepts(&mut self, candidate: &[BasicTxDetails]) -> Result<bool> {
722        if self.at_budget() {
723            return Ok(false);
724        }
725        self.attempts += 1;
726        let observations = replay_candidate(
727            self.session,
728            self.evm_edge_indices.clone(),
729            candidate.to_vec(),
730            FuzzMinimizeMode::Tmin,
731        )?;
732        let observations = observations
733            .into_iter()
734            .map(|obs| (obs.target, obs.observation))
735            .collect::<BTreeMap<_, _>>();
736
737        if has_new_active_targets(&observations, &self.baseline.requirements) {
738            return Ok(false);
739        }
740
741        for (target, baseline) in &self.baseline.requirements {
742            let Some(candidate) = observations.get(target) else {
743                return Ok(false);
744            };
745            if !baseline.failures.is_empty() {
746                if candidate.failures != baseline.failures {
747                    return Ok(false);
748                }
749            } else if !candidate.failures.is_empty() || !same_edge_hit_sets(candidate, baseline) {
750                return Ok(false);
751            }
752        }
753
754        Ok(true)
755    }
756}
757
758fn has_new_active_targets(
759    observations: &BTreeMap<String, ReplayObservation>,
760    baseline: &BTreeMap<String, ReplayObservation>,
761) -> bool {
762    observations.iter().any(|(target, observation)| {
763        !baseline.contains_key(target)
764            && (!observation.failures.is_empty()
765                || observation.replayed > 0
766                || has_edges(observation))
767    })
768}
769
770fn minimize_sequence(
771    ctx: &mut MinimizeContext<'_>,
772    sequence: &mut Vec<BasicTxDetails>,
773    decoder: &CorpusDecoder,
774) -> Result<()> {
775    let mut idx = 0;
776    while idx < sequence.len() && !ctx.at_budget() {
777        let removed = sequence.remove(idx);
778        if ctx.accepts(sequence)? {
779            continue;
780        }
781        sequence.insert(idx, removed);
782        idx += 1;
783    }
784
785    let mut idx = 0;
786    while idx < sequence.len() && !ctx.at_budget() {
787        let restore = sequence[idx].clone();
788        cleanup_metadata(&mut sequence[idx]);
789        if !ctx.accepts(sequence)? {
790            sequence[idx] = restore;
791        }
792        idx += 1;
793    }
794
795    let mut tx_idx = 0;
796    while tx_idx < sequence.len() && !ctx.at_budget() {
797        loop {
798            let candidates = abi_calldata_candidates(
799                sequence[tx_idx].call_details.calldata.as_ref(),
800                decoder,
801                ctx.remaining_attempts(),
802            );
803            if candidates.is_empty() {
804                break;
805            };
806
807            let mut accepted = false;
808            for calldata in candidates {
809                if calldata.len() > sequence[tx_idx].call_details.calldata.len() {
810                    continue;
811                }
812                let restore =
813                    std::mem::replace(&mut sequence[tx_idx].call_details.calldata, calldata.into());
814                if ctx.accepts(sequence)? {
815                    accepted = true;
816                    break;
817                }
818                sequence[tx_idx].call_details.calldata = restore;
819                if ctx.at_budget() {
820                    break;
821                }
822            }
823            if !accepted || ctx.at_budget() {
824                break;
825            }
826        }
827        tx_idx += 1;
828    }
829
830    Ok(())
831}
832
833fn temporary_cmin_out(out: &Path) -> Result<TempDir> {
834    let parent =
835        out.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new("."));
836    let filename =
837        out.file_name().ok_or_else(|| eyre::eyre!("missing output corpus directory name"))?;
838    let prefix = format!(".{}.tmp-", filename.to_string_lossy());
839    TempDirBuilder::new().prefix(&prefix).tempdir_in(parent).with_context(|| {
840        format!("failed to create temporary output directory for {}", out.display())
841    })
842}
843
844fn read_single_sequence(path: &Path) -> Result<Vec<BasicTxDetails>> {
845    let entries = read_corpus_tree(path)?;
846    let [entry] = entries.as_slice() else {
847        bail!("expected one corpus entry at {}, found {}", path.display(), entries.len());
848    };
849    entry
850        .read_tx_seq()
851        .with_context(|| format!("failed to read corpus entry {}", entry.path.display()))
852}
853
854fn validate_tmin_output_path(path: &Path) -> Result<()> {
855    if std::fs::symlink_metadata(path).is_ok() {
856        bail!("output corpus file already exists: {}", path.display());
857    }
858    Ok(())
859}
860
861fn write_sequence_create_new(path: &Path, sequence: &[BasicTxDetails]) -> Result<()> {
862    if let Some(parent) = path.parent()
863        && !parent.as_os_str().is_empty()
864    {
865        fs::create_dir_all(parent)?;
866    }
867
868    let file = OpenOptions::new()
869        .write(true)
870        .create_new(true)
871        .open(path)
872        .with_context(|| format!("failed to create output corpus file {}", path.display()))?;
873    if is_gzip_path(path) {
874        let writer = BufWriter::new(file);
875        let mut encoder = GzEncoder::new(writer, Compression::default());
876        serde_json::to_writer(&mut encoder, &sequence)
877            .with_context(|| format!("failed to write output corpus file {}", path.display()))?;
878        let mut writer = encoder
879            .finish()
880            .with_context(|| format!("failed to finish output corpus file {}", path.display()))?;
881        writer
882            .flush()
883            .with_context(|| format!("failed to flush output corpus file {}", path.display()))?;
884    } else {
885        let mut writer = BufWriter::new(file);
886        serde_json::to_writer(&mut writer, &sequence)
887            .with_context(|| format!("failed to write output corpus file {}", path.display()))?;
888        writer
889            .flush()
890            .with_context(|| format!("failed to flush output corpus file {}", path.display()))?;
891    }
892    Ok(())
893}
894
895fn is_gzip_path(path: &Path) -> bool {
896    path.extension()
897        .and_then(|extension| extension.to_str())
898        .is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
899}
900
901fn has_edges(observation: &ReplayObservation) -> bool {
902    observation.evm_edges.iter().any(|&edge| edge != 0)
903        || observation.sancov_edges.iter().any(|&edge| edge != 0)
904}
905
906fn same_edge_hit_sets(candidate: &ReplayObservation, baseline: &ReplayObservation) -> bool {
907    same_edge_hit_set(&candidate.evm_edges, &baseline.evm_edges)
908        && same_edge_hit_set(&candidate.sancov_edges, &baseline.sancov_edges)
909}
910
911fn same_edge_hit_set(candidate: &[u8], baseline: &[u8]) -> bool {
912    let len = candidate.len().max(baseline.len());
913    (0..len).all(|idx| {
914        let candidate_hit = candidate.get(idx).copied().unwrap_or_default() != 0;
915        let baseline_hit = baseline.get(idx).copied().unwrap_or_default() != 0;
916        candidate_hit == baseline_hit
917    })
918}
919
920fn cleanup_metadata(tx: &mut BasicTxDetails) {
921    if tx.warp == Some(U256::ZERO) {
922        tx.warp = None;
923    }
924    if tx.roll == Some(U256::ZERO) {
925        tx.roll = None;
926    }
927    if tx.call_details.value == Some(U256::ZERO) {
928        tx.call_details.value = None;
929    }
930}
931
932fn abi_calldata_candidates(calldata: &[u8], decoder: &CorpusDecoder, limit: usize) -> Vec<Vec<u8>> {
933    if limit == 0 {
934        return Vec::new();
935    }
936    let Some((function, args)) = decoder.unique_decodable_function(calldata) else {
937        return Vec::new();
938    };
939
940    let mut candidates = Vec::new();
941    for arg_idx in 0..args.len() {
942        for value in value_candidates(&args[arg_idx], limit.saturating_sub(candidates.len())) {
943            let mut candidate_args = args.clone();
944            candidate_args[arg_idx] = value;
945            let Ok(encoded) = function.abi_encode_input(&candidate_args) else {
946                continue;
947            };
948            if encoded.as_slice() != calldata && !candidates.contains(&encoded) {
949                candidates.push(encoded);
950                if candidates.len() >= limit {
951                    return candidates;
952                }
953            }
954        }
955    }
956    candidates
957}
958
959fn value_candidates(value: &DynSolValue, limit: usize) -> Vec<DynSolValue> {
960    let mut candidates = Vec::new();
961    push_scalar_value_candidates(value, &mut candidates, limit);
962    push_compound_value_candidates(value, &mut candidates, limit);
963    candidates.into_iter().filter(|candidate| candidate != value).collect()
964}
965
966fn push_candidate(candidates: &mut Vec<DynSolValue>, limit: usize, candidate: DynSolValue) -> bool {
967    if candidates.len() >= limit {
968        return false;
969    }
970    candidates.push(candidate);
971    true
972}
973
974fn push_scalar_value_candidates(
975    value: &DynSolValue,
976    candidates: &mut Vec<DynSolValue>,
977    limit: usize,
978) {
979    match value {
980        DynSolValue::Bool(_) => {
981            push_candidate(candidates, limit, DynSolValue::Bool(false));
982        }
983        DynSolValue::Uint(value, bits) => {
984            if *value != U256::ZERO {
985                push_candidate(candidates, limit, DynSolValue::Uint(U256::ZERO, *bits));
986            }
987            if *value > U256::from(1) {
988                push_candidate(candidates, limit, DynSolValue::Uint(U256::from(1), *bits));
989            }
990        }
991        DynSolValue::Int(value, bits) => {
992            if *value != I256::ZERO {
993                push_candidate(candidates, limit, DynSolValue::Int(I256::ZERO, *bits));
994            }
995            if *value != I256::ZERO
996                && *value != I256::from_raw(U256::from(1))
997                && *value != I256::MINUS_ONE
998            {
999                push_candidate(
1000                    candidates,
1001                    limit,
1002                    DynSolValue::Int(I256::from_raw(U256::from(1)), *bits),
1003                );
1004            }
1005            if *value != I256::ZERO
1006                && *value != I256::from_raw(U256::from(1))
1007                && *value != I256::MINUS_ONE
1008            {
1009                push_candidate(candidates, limit, DynSolValue::Int(I256::MINUS_ONE, *bits));
1010            }
1011        }
1012        DynSolValue::Address(_) => {
1013            push_candidate(candidates, limit, DynSolValue::Address(Address::ZERO));
1014        }
1015        DynSolValue::FixedBytes(_, size) => {
1016            push_candidate(candidates, limit, DynSolValue::FixedBytes(B256::ZERO, *size));
1017        }
1018        DynSolValue::Function(_) => {
1019            push_candidate(candidates, limit, DynSolValue::Function(SolFunction::ZERO));
1020        }
1021        DynSolValue::Bytes(bytes) => {
1022            push_candidate(candidates, limit, DynSolValue::Bytes(Vec::new()));
1023            if bytes.len() > 1 {
1024                push_candidate(
1025                    candidates,
1026                    limit,
1027                    DynSolValue::Bytes(bytes[..bytes.len() / 2].to_vec()),
1028                );
1029            }
1030        }
1031        DynSolValue::String(string) => {
1032            push_candidate(candidates, limit, DynSolValue::String(String::new()));
1033            if string.len() > 1 {
1034                let mut half = string.len() / 2;
1035                while half > 0 && !string.is_char_boundary(half) {
1036                    half -= 1;
1037                }
1038                push_candidate(candidates, limit, DynSolValue::String(string[..half].to_string()));
1039            }
1040        }
1041        DynSolValue::Array(_)
1042        | DynSolValue::FixedArray(_)
1043        | DynSolValue::Tuple(_)
1044        | DynSolValue::CustomStruct { .. } => {}
1045    }
1046}
1047
1048fn push_compound_value_candidates(
1049    value: &DynSolValue,
1050    candidates: &mut Vec<DynSolValue>,
1051    limit: usize,
1052) {
1053    match value {
1054        DynSolValue::Array(values) => {
1055            push_candidate(candidates, limit, DynSolValue::Array(Vec::new()));
1056            if values.len() > 1 {
1057                push_candidate(
1058                    candidates,
1059                    limit,
1060                    DynSolValue::Array(values[..values.len() / 2].to_vec()),
1061                );
1062            }
1063            push_child_value_candidates(values, candidates, limit, |values| {
1064                DynSolValue::Array(values.to_vec())
1065            });
1066        }
1067        DynSolValue::FixedArray(values) => {
1068            push_child_value_candidates(values, candidates, limit, |values| {
1069                DynSolValue::FixedArray(values.to_vec())
1070            });
1071        }
1072        DynSolValue::Tuple(values) => {
1073            push_child_value_candidates(values, candidates, limit, |values| {
1074                DynSolValue::Tuple(values.to_vec())
1075            });
1076        }
1077        DynSolValue::CustomStruct { name, prop_names, tuple } => {
1078            push_child_value_candidates(tuple, candidates, limit, |values| {
1079                DynSolValue::CustomStruct {
1080                    name: name.clone(),
1081                    prop_names: prop_names.clone(),
1082                    tuple: values.to_vec(),
1083                }
1084            });
1085        }
1086        DynSolValue::Bool(_)
1087        | DynSolValue::Uint(_, _)
1088        | DynSolValue::Int(_, _)
1089        | DynSolValue::Address(_)
1090        | DynSolValue::FixedBytes(_, _)
1091        | DynSolValue::Function(_)
1092        | DynSolValue::Bytes(_)
1093        | DynSolValue::String(_) => {}
1094    }
1095}
1096
1097fn push_child_value_candidates(
1098    values: &[DynSolValue],
1099    candidates: &mut Vec<DynSolValue>,
1100    limit: usize,
1101    rebuild: impl Fn(&[DynSolValue]) -> DynSolValue,
1102) {
1103    for idx in 0..values.len() {
1104        if candidates.len() >= limit {
1105            return;
1106        }
1107        for child in value_candidates(&values[idx], limit.saturating_sub(candidates.len())) {
1108            let mut values = values.to_vec();
1109            values[idx] = child;
1110            candidates.push(rebuild(&values));
1111            if candidates.len() >= limit {
1112                return;
1113            }
1114        }
1115    }
1116}
1117
1118#[derive(Serialize)]
1119pub struct DisplayCorpusEntry {
1120    path: PathBuf,
1121    sequence: Vec<DisplayTxDetails>,
1122}
1123
1124#[derive(Serialize)]
1125struct DisplayTxDetails {
1126    #[serde(flatten)]
1127    raw: BasicTxDetails,
1128    #[serde(skip_serializing_if = "Option::is_none")]
1129    decoded: Option<DecodedCall>,
1130}
1131
1132#[derive(Serialize)]
1133struct DecodedCall {
1134    #[serde(skip_serializing_if = "Option::is_none")]
1135    contract: Option<String>,
1136    #[serde(skip_serializing_if = "Vec::is_empty")]
1137    ambiguous_contracts: Vec<String>,
1138    signature: String,
1139    args: Vec<String>,
1140    call: String,
1141}
1142
1143struct IndexedFunction {
1144    contract: String,
1145    function: Function,
1146}
1147
1148#[derive(Default)]
1149struct CorpusDecoder {
1150    functions: BTreeMap<Selector, Vec<IndexedFunction>>,
1151}
1152
1153impl CorpusDecoder {
1154    fn load() -> Self {
1155        Config::load().ok().map(|config| Self::from_artifacts(&config.out)).unwrap_or_default()
1156    }
1157
1158    fn from_artifacts(out: &Path) -> Self {
1159        let mut this = Self::default();
1160        if !out.is_dir() {
1161            return this;
1162        }
1163
1164        for path in fs::json_files(out) {
1165            let Ok(artifact) = fs::read_json_file::<serde_json::Value>(&path) else {
1166                continue;
1167            };
1168            let Some(abi_value) = artifact.get("abi").cloned() else {
1169                continue;
1170            };
1171            let Ok(abi) = serde_json::from_value::<JsonAbi>(abi_value) else {
1172                continue;
1173            };
1174            let contract =
1175                path.file_stem().and_then(|name| name.to_str()).unwrap_or("<unknown>").to_string();
1176
1177            for function in abi.functions().cloned() {
1178                this.functions
1179                    .entry(function.selector())
1180                    .or_default()
1181                    .push(IndexedFunction { contract: contract.clone(), function });
1182            }
1183        }
1184
1185        this
1186    }
1187
1188    fn decode(&self, tx: &BasicTxDetails) -> Option<DecodedCall> {
1189        let calldata = tx.call_details.calldata.as_ref();
1190        if calldata.len() < 4 {
1191            return None;
1192        }
1193
1194        let selector = Selector::from_slice(&calldata[..4]);
1195        let functions = self.functions.get(&selector)?;
1196        let (function, decoded_args) = self.unique_decodable_function(calldata)?;
1197        let args = format_tokens_raw(&decoded_args).collect::<Vec<_>>();
1198        let signature = function.signature();
1199
1200        let mut contracts = functions
1201            .iter()
1202            .filter(|indexed| indexed.function.signature() == signature.as_str())
1203            .map(|indexed| indexed.contract.clone())
1204            .collect::<Vec<_>>();
1205        contracts.sort();
1206        contracts.dedup();
1207
1208        let function_call = format!("{}({})", function.name, args.join(", "));
1209        if contracts.len() == 1 {
1210            let contract = contracts.pop()?;
1211            Some(DecodedCall {
1212                call: format!("{contract}.{function_call}"),
1213                contract: Some(contract),
1214                ambiguous_contracts: Vec::new(),
1215                signature,
1216                args,
1217            })
1218        } else {
1219            Some(DecodedCall {
1220                call: function_call,
1221                contract: None,
1222                ambiguous_contracts: contracts,
1223                signature,
1224                args,
1225            })
1226        }
1227    }
1228
1229    fn unique_decodable_function(&self, calldata: &[u8]) -> Option<(&Function, Vec<DynSolValue>)> {
1230        if calldata.len() < 4 {
1231            return None;
1232        }
1233
1234        let selector = Selector::from_slice(&calldata[..4]);
1235        let mut unique = None;
1236        for (function, decoded_args) in
1237            self.functions.get(&selector)?.iter().filter_map(|indexed| {
1238                let decoded_args = indexed.function.abi_decode_input(&calldata[4..]).ok()?;
1239                Some((&indexed.function, decoded_args))
1240            })
1241        {
1242            let signature = function.signature();
1243            match &unique {
1244                Some((existing, _, _)) if existing == &signature => {}
1245                Some(_) => return None,
1246                None => unique = Some((signature, function, decoded_args)),
1247            }
1248        }
1249        unique.map(|(_, function, decoded_args)| (function, decoded_args))
1250    }
1251}
1252
1253fn read_entries(
1254    path: &Path,
1255    limit: Option<usize>,
1256    decoder: &CorpusDecoder,
1257) -> Result<Vec<DisplayCorpusEntry>> {
1258    let iter = read_corpus_entries(path)?.into_iter().take(limit.unwrap_or(usize::MAX));
1259    iter.map(|entry| {
1260        let sequence = entry
1261            .read_tx_seq()
1262            .with_context(|| format!("failed to read corpus entry {}", entry.path.display()))?;
1263        let sequence = sequence
1264            .into_iter()
1265            .map(|raw| {
1266                let decoded = decoder.decode(&raw);
1267                DisplayTxDetails { raw, decoded }
1268            })
1269            .collect();
1270        Ok(DisplayCorpusEntry { path: entry.path, sequence })
1271    })
1272    .collect()
1273}
1274
1275fn read_corpus_entries(path: &Path) -> Result<Vec<CorpusDirEntry>> {
1276    let entries = read_corpus_tree(path)?;
1277    if entries.is_empty() {
1278        bail!("no corpus entries found under {}", path.display());
1279    }
1280    Ok(entries)
1281}
1282
1283#[derive(Clone, Debug, Parser)]
1284struct FuzzMinimizeTestArgs {
1285    #[command(flatten)]
1286    global: GlobalArgs,
1287    #[command(flatten)]
1288    filter: FilterArgs,
1289    #[command(flatten)]
1290    evm: EvmArgs,
1291    #[command(flatten)]
1292    build: BuildOpts,
1293}
1294
1295impl FuzzMinimizeTestArgs {
1296    async fn prepare_session(self, corpus_dir: &Path) -> Result<FuzzMinimizeReplaySession> {
1297        let mut test = TestArgs::parse_from(["test", "-q"]);
1298        test.set_fuzz_minimize_replay_options(self.global, self.evm, self.build, self.filter);
1299        test.enable_fuzz_only();
1300        prepare_minimize_session(&mut test, corpus_dir).await
1301    }
1302
1303    fn decoder(&self) -> CorpusDecoder {
1304        self.build
1305            .load_config_no_warnings()
1306            .ok()
1307            .map(|config| CorpusDecoder::from_artifacts(&config.out))
1308            .unwrap_or_default()
1309    }
1310}
1311
1312struct QuietShellGuard {
1313    previous: OutputMode,
1314}
1315
1316impl QuietShellGuard {
1317    fn new() -> Self {
1318        let mut shell = Shell::get();
1319        let previous = shell.output_mode();
1320        shell.set_output_mode(OutputMode::Quiet);
1321        Self { previous }
1322    }
1323}
1324
1325impl Drop for QuietShellGuard {
1326    fn drop(&mut self) {
1327        Shell::get().set_output_mode(self.previous);
1328    }
1329}
1330
1331async fn prepare_minimize_session(
1332    test: &mut TestArgs,
1333    corpus_dir: &Path,
1334) -> Result<FuzzMinimizeReplaySession> {
1335    let _quiet = QuietShellGuard::new();
1336    test.prepare_fuzz_minimize_replay(corpus_dir).await
1337}
1338
1339fn replay_candidate(
1340    session: &FuzzMinimizeReplaySession,
1341    evm_edge_indices: FuzzMinimizeEdgeIndices,
1342    sequence: Vec<BasicTxDetails>,
1343    mode: FuzzMinimizeMode,
1344) -> Result<Vec<FuzzMinimizeObservation>> {
1345    let _quiet = QuietShellGuard::new();
1346    session.replay(sequence, evm_edge_indices, mode)
1347}
1348
1349fn merge_new_edges(cumulative: &mut ReplayObservation, observation: &ReplayObservation) -> bool {
1350    merge_new_edge_vec(&mut cumulative.evm_edges, &observation.evm_edges)
1351        | merge_new_edge_vec(&mut cumulative.sancov_edges, &observation.sancov_edges)
1352}
1353
1354fn merge_new_edge_vec(cumulative: &mut Vec<u8>, candidate: &[u8]) -> bool {
1355    if cumulative.len() < candidate.len() {
1356        cumulative.resize(candidate.len(), 0);
1357    }
1358    let mut improved = false;
1359    for (cumulative, &candidate) in cumulative.iter_mut().zip(candidate) {
1360        if *cumulative < candidate {
1361            *cumulative = candidate;
1362            improved = true;
1363        }
1364    }
1365    improved
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371    use clap::CommandFactory;
1372    use foundry_evm::executors::ReplayFailure;
1373
1374    #[test]
1375    fn fuzz_args_clap_shape_is_valid() {
1376        FuzzArgs::command().debug_assert();
1377    }
1378
1379    #[test]
1380    fn fuzz_run_rejects_fuzz_worker() {
1381        assert!(FuzzArgs::try_parse_from(["foundry-cli", "run", "--fuzz-worker", "1"]).is_err());
1382    }
1383
1384    #[test]
1385    fn fuzz_run_rejects_fuzz_run() {
1386        assert!(FuzzArgs::try_parse_from(["foundry-cli", "run", "--fuzz-run", "1"]).is_err());
1387    }
1388
1389    fn decoder_with_functions(functions: Vec<Function>) -> CorpusDecoder {
1390        let mut decoder = CorpusDecoder::default();
1391        for function in functions {
1392            decoder
1393                .functions
1394                .entry(function.selector())
1395                .or_default()
1396                .push(IndexedFunction { contract: "Target".to_string(), function });
1397        }
1398        decoder
1399    }
1400
1401    fn candidate_args(function: &Function, candidates: Vec<Vec<u8>>) -> Vec<Vec<DynSolValue>> {
1402        candidates
1403            .into_iter()
1404            .map(|calldata| function.abi_decode_input(&calldata[4..]).unwrap())
1405            .collect()
1406    }
1407
1408    #[test]
1409    fn merge_new_edges_keeps_sancov_hit_count_bucket_increases() {
1410        let mut cumulative = ReplayObservation { sancov_edges: vec![0, 1], ..Default::default() };
1411        let candidate = ReplayObservation { sancov_edges: vec![0, 8], ..Default::default() };
1412
1413        assert!(merge_new_edges(&mut cumulative, &candidate));
1414        assert_eq!(cumulative.sancov_edges, vec![0, 8]);
1415    }
1416
1417    #[test]
1418    fn same_edge_hit_sets_allow_hit_count_bucket_changes() {
1419        let baseline = ReplayObservation { evm_edges: vec![0, 8], ..Default::default() };
1420        let candidate = ReplayObservation { evm_edges: vec![0, 1], ..Default::default() };
1421
1422        assert!(same_edge_hit_sets(&candidate, &baseline));
1423    }
1424
1425    #[test]
1426    fn same_edge_hit_sets_treat_missing_trailing_buckets_as_zero() {
1427        let baseline = ReplayObservation { sancov_edges: vec![0, 0], ..Default::default() };
1428        let candidate = ReplayObservation { sancov_edges: vec![0], ..Default::default() };
1429
1430        assert!(same_edge_hit_sets(&candidate, &baseline));
1431    }
1432
1433    #[test]
1434    fn has_new_active_targets_rejects_candidate_only_activity() {
1435        let baseline = BTreeMap::from([(
1436            "A".to_string(),
1437            ReplayObservation { evm_edges: vec![1], ..Default::default() },
1438        )]);
1439        let observations = BTreeMap::from([
1440            ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1441            ("B".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1442        ]);
1443
1444        assert!(has_new_active_targets(&observations, &baseline));
1445    }
1446
1447    #[test]
1448    fn has_new_active_targets_rejects_candidate_only_failures() {
1449        let baseline = BTreeMap::from([(
1450            "A".to_string(),
1451            ReplayObservation { evm_edges: vec![1], ..Default::default() },
1452        )]);
1453        let observations = BTreeMap::from([
1454            ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1455            (
1456                "B".to_string(),
1457                ReplayObservation {
1458                    failures: std::collections::BTreeSet::from([ReplayFailure::AfterInvariant]),
1459                    ..Default::default()
1460                },
1461            ),
1462        ]);
1463
1464        assert!(has_new_active_targets(&observations, &baseline));
1465    }
1466
1467    #[test]
1468    fn has_new_active_targets_rejects_candidate_only_replayed_transactions() {
1469        let baseline = BTreeMap::from([(
1470            "A".to_string(),
1471            ReplayObservation { evm_edges: vec![1], ..Default::default() },
1472        )]);
1473        let observations = BTreeMap::from([
1474            ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1475            ("B".to_string(), ReplayObservation { replayed: 1, ..Default::default() }),
1476        ]);
1477
1478        assert!(has_new_active_targets(&observations, &baseline));
1479    }
1480
1481    #[test]
1482    fn has_new_active_targets_allows_inactive_candidate_only_targets() {
1483        let baseline = BTreeMap::from([(
1484            "A".to_string(),
1485            ReplayObservation { evm_edges: vec![1], ..Default::default() },
1486        )]);
1487        let observations = BTreeMap::from([
1488            ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }),
1489            ("B".to_string(), ReplayObservation::default()),
1490        ]);
1491
1492        assert!(!has_new_active_targets(&observations, &baseline));
1493    }
1494
1495    #[test]
1496    fn abi_calldata_candidates_simplify_scalar_values() {
1497        let function = Function::parse("target(uint256,int256,bool,address)").unwrap();
1498        let decoder = decoder_with_functions(vec![function.clone()]);
1499        let calldata = function
1500            .abi_encode_input(&[
1501                DynSolValue::Uint(U256::from(42), 256),
1502                DynSolValue::Int(I256::from_raw(U256::from(42)), 256),
1503                DynSolValue::Bool(true),
1504                DynSolValue::Address(Address::from([0x11; 20])),
1505            ])
1506            .unwrap();
1507
1508        let candidates =
1509            candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX));
1510
1511        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::ZERO, 256)));
1512        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::from(1), 256)));
1513        assert!(candidates.iter().any(|args| args[1] == DynSolValue::Int(I256::ZERO, 256)));
1514        assert!(
1515            candidates
1516                .iter()
1517                .any(|args| { args[1] == DynSolValue::Int(I256::from_raw(U256::from(1)), 256) })
1518        );
1519        assert!(candidates.iter().any(|args| args[1] == DynSolValue::Int(I256::MINUS_ONE, 256)));
1520        assert!(candidates.iter().any(|args| args[2] == DynSolValue::Bool(false)));
1521        assert!(candidates.iter().any(|args| args[3] == DynSolValue::Address(Address::ZERO)));
1522    }
1523
1524    #[test]
1525    fn abi_calldata_candidates_do_not_oscillate_signed_one_values() {
1526        let function = Function::parse("target(int256)").unwrap();
1527        let decoder = decoder_with_functions(vec![function.clone()]);
1528        let one = DynSolValue::Int(I256::from_raw(U256::from(1)), 256);
1529        let minus_one = DynSolValue::Int(I256::MINUS_ONE, 256);
1530
1531        let one_calldata = function.abi_encode_input(&[one]).unwrap();
1532        let one_candidates =
1533            candidate_args(&function, abi_calldata_candidates(&one_calldata, &decoder, usize::MAX));
1534        assert_eq!(one_candidates, vec![vec![DynSolValue::Int(I256::ZERO, 256)]]);
1535
1536        let minus_one_calldata = function.abi_encode_input(&[minus_one]).unwrap();
1537        let minus_one_candidates = candidate_args(
1538            &function,
1539            abi_calldata_candidates(&minus_one_calldata, &decoder, usize::MAX),
1540        );
1541        assert_eq!(minus_one_candidates, vec![vec![DynSolValue::Int(I256::ZERO, 256)]]);
1542    }
1543
1544    #[test]
1545    fn abi_calldata_candidates_shrink_dynamic_values() {
1546        let function = Function::parse("target(bytes,string,uint256[])").unwrap();
1547        let decoder = decoder_with_functions(vec![function.clone()]);
1548        let calldata = function
1549            .abi_encode_input(&[
1550                DynSolValue::Bytes(vec![1, 2, 3, 4]),
1551                DynSolValue::String("abcdef".to_string()),
1552                DynSolValue::Array(vec![
1553                    DynSolValue::Uint(U256::from(10), 256),
1554                    DynSolValue::Uint(U256::from(11), 256),
1555                    DynSolValue::Uint(U256::from(12), 256),
1556                    DynSolValue::Uint(U256::from(13), 256),
1557                ]),
1558            ])
1559            .unwrap();
1560
1561        let candidates =
1562            candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX));
1563
1564        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Bytes(Vec::new())));
1565        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Bytes(vec![1, 2])));
1566        assert!(candidates.iter().any(|args| args[1] == DynSolValue::String(String::new())));
1567        assert!(candidates.iter().any(|args| args[1] == DynSolValue::String("abc".to_string())));
1568        assert!(candidates.iter().any(|args| args[2] == DynSolValue::Array(Vec::new())));
1569        assert!(candidates.iter().any(|args| {
1570            args[2]
1571                == DynSolValue::Array(vec![
1572                    DynSolValue::Uint(U256::from(10), 256),
1573                    DynSolValue::Uint(U256::from(11), 256),
1574                ])
1575        }));
1576    }
1577
1578    #[test]
1579    fn abi_calldata_candidates_simplify_tuple_children() {
1580        let function = Function::parse("target((uint256,bool,address))").unwrap();
1581        let decoder = decoder_with_functions(vec![function.clone()]);
1582        let calldata = function
1583            .abi_encode_input(&[DynSolValue::Tuple(vec![
1584                DynSolValue::Uint(U256::from(42), 256),
1585                DynSolValue::Bool(true),
1586                DynSolValue::Address(Address::from([0x11; 20])),
1587            ])])
1588            .unwrap();
1589
1590        let candidates =
1591            candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX));
1592
1593        assert!(candidates.iter().any(|args| {
1594            args[0]
1595                == DynSolValue::Tuple(vec![
1596                    DynSolValue::Uint(U256::ZERO, 256),
1597                    DynSolValue::Bool(true),
1598                    DynSolValue::Address(Address::from([0x11; 20])),
1599                ])
1600        }));
1601        assert!(candidates.iter().any(|args| {
1602            args[0]
1603                == DynSolValue::Tuple(vec![
1604                    DynSolValue::Uint(U256::from(42), 256),
1605                    DynSolValue::Bool(false),
1606                    DynSolValue::Address(Address::from([0x11; 20])),
1607                ])
1608        }));
1609        assert!(candidates.iter().any(|args| {
1610            args[0]
1611                == DynSolValue::Tuple(vec![
1612                    DynSolValue::Uint(U256::from(42), 256),
1613                    DynSolValue::Bool(true),
1614                    DynSolValue::Address(Address::ZERO),
1615                ])
1616        }));
1617    }
1618
1619    #[test]
1620    fn abi_calldata_candidates_skip_ambiguous_or_undecodable_calldata() {
1621        let function = Function::parse("target(uint256)").unwrap();
1622        let other = Function::parse("other(uint256)").unwrap();
1623        let calldata =
1624            function.abi_encode_input(&[DynSolValue::Uint(U256::from(42), 256)]).unwrap();
1625
1626        let mut ambiguous = CorpusDecoder::default();
1627        ambiguous.functions.entry(function.selector()).or_default().extend([
1628            IndexedFunction { contract: "Target".to_string(), function: function.clone() },
1629            IndexedFunction { contract: "Other".to_string(), function: other },
1630        ]);
1631        assert!(abi_calldata_candidates(&calldata, &ambiguous, usize::MAX).is_empty());
1632
1633        let decoder = decoder_with_functions(vec![function]);
1634        assert!(
1635            abi_calldata_candidates(&calldata[..calldata.len() - 1], &decoder, usize::MAX)
1636                .is_empty()
1637        );
1638    }
1639
1640    #[test]
1641    fn abi_calldata_candidates_accept_same_signature_with_metadata_differences() {
1642        let function =
1643            Function::parse("function target(uint256 value) external returns (uint256)").unwrap();
1644        let same_signature = Function::parse("function target(uint256 value) view").unwrap();
1645        let calldata =
1646            function.abi_encode_input(&[DynSolValue::Uint(U256::from(42), 256)]).unwrap();
1647
1648        let mut decoder = CorpusDecoder::default();
1649        decoder.functions.entry(function.selector()).or_default().extend([
1650            IndexedFunction { contract: "WithReturn".to_string(), function: function.clone() },
1651            IndexedFunction { contract: "NoReturn".to_string(), function: same_signature },
1652        ]);
1653
1654        let candidates =
1655            candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX));
1656
1657        assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::ZERO, 256)));
1658    }
1659}