Skip to main content

foundry_evm/executors/
showmap.rs

1//! AFL-`afl-showmap`-style corpus replay.
2//!
3//! Replays a persisted corpus through a fresh executor and emits one text file
4//! per trial (or per corpus entry). Each line has the form `<id>:<count>`:
5//!
6//! - EVM IDs use the *deterministic* `(bytecode_hash, pc)` derived from the line-coverage `HitMap`
7//!   so that IDs are stable across `forge` invocations and meaningful for cross-approach analysis.
8//!   Format: `evm_<bytecode_hash[:16]>_<pc:04x>`.
9//! - Sancov IDs use the deterministic guard index from the sancov bitmap: `sancov_0x<index:04x>`.
10//!
11//! Counts are raw saturating-summed hitcounts across the replayed corpus.
12//!
13//! Output is consumable by tools like `riesentoaster/differential-coverage`.
14
15use crate::{
16    executors::{
17        Executor,
18        corpus::{
19            DynamicTargetCtx, StatelessReplayTarget, WorkerCorpus, register_replay_created,
20            rollback_replay_created,
21        },
22        corpus_io::read_corpus_tree,
23        invariant::{
24            call_after_invariant_function, call_invariant_function, did_fail_on_assert, execute_tx,
25            snapshot_edge_fingerprint,
26        },
27    },
28    inspectors::EdgeIndexMap,
29};
30use alloy_dyn_abi::JsonAbiExt;
31use alloy_json_abi::Function;
32use alloy_primitives::{Address, B256, Selector, hex, keccak256};
33use eyre::Result;
34use foundry_config::FuzzCorpusConfig;
35use foundry_evm_core::{
36    constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME},
37    decode::SkipReason,
38    evm::FoundryEvmNetwork,
39};
40use foundry_evm_coverage::HitMaps;
41use foundry_evm_fuzz::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts};
42use std::{
43    borrow::Cow,
44    cmp::Ordering,
45    collections::{BTreeSet, HashMap},
46    fmt,
47    fs::File,
48    io::{BufWriter, Write},
49    path::{Path, PathBuf},
50};
51
52type EvmShowmap = HashMap<(B256, u32), u64>;
53const MAX_REPORTED_REPLAY_FAILURES: usize = 20;
54
55/// Which coverage bitmap(s) to dump.
56#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
57pub enum ShowmapDomain {
58    #[default]
59    Evm,
60    Sancov,
61    Both,
62}
63
64impl ShowmapDomain {
65    pub const fn includes_evm(self) -> bool {
66        matches!(self, Self::Evm | Self::Both)
67    }
68    pub const fn includes_sancov(self) -> bool {
69        matches!(self, Self::Sancov | Self::Both)
70    }
71}
72
73impl fmt::Display for ShowmapDomain {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            Self::Evm => f.write_str("evm"),
77            Self::Sancov => f.write_str("sancov"),
78            Self::Both => f.write_str("both"),
79        }
80    }
81}
82
83/// Per-replay options.
84#[derive(Clone, Debug)]
85pub struct ShowmapOpts {
86    /// Output root directory; emitted files live under `<out_dir>/<approach>/`.
87    pub out_dir: PathBuf,
88    /// Approach directory name; test identity is folded in here so each
89    /// `<approach>/` contains trials of one test (matches `differential-coverage`).
90    pub approach: String,
91    /// Rerun identifier used as the filename so multiple trials accumulate side-by-side.
92    pub trial: String,
93    /// Whether to emit one file per corpus entry or one aggregated file.
94    pub per_input: bool,
95    /// Which bitmap(s) to dump.
96    pub domain: ShowmapDomain,
97    /// Whether to write showmap files. Disabled by `forge fuzz replay`.
98    pub emit_files: bool,
99}
100
101/// Stats returned from a single trial replay.
102#[derive(Clone, Debug, Default)]
103pub struct ShowmapStats {
104    /// Number of corpus entries successfully replayed.
105    pub corpus_entries: usize,
106    /// Number of files written to disk.
107    pub showmap_files: usize,
108    /// Number of corpus entries skipped because they couldn't be replayed
109    /// against the current target (e.g. selector mismatch).
110    pub skipped_entries: usize,
111    /// Number of corpus entries skipped because they could not be read.
112    pub unreadable_entries: usize,
113    /// True if sancov coverage was requested. Lets the caller distinguish
114    /// "sancov not asked for" from "sancov asked for but produced nothing".
115    pub sancov_requested: bool,
116    /// True if any non-zero sancov hits were observed across the replay.
117    pub sancov_observed: bool,
118}
119
120/// Test target metadata needed to replay corpus entries.
121pub struct ShowmapReplayTarget<'a> {
122    pub stateless: Option<StatelessReplayTarget<'a>>,
123    pub fuzz_fail_on_revert: bool,
124    pub fuzzed_contracts: Option<&'a FuzzRunIdentifiedContracts>,
125    pub invariant_address: Option<Address>,
126    pub invariant_fns: &'a [(&'a Function, bool)],
127    pub invariant_replay: InvariantReplayOptions,
128    pub dynamic: Option<&'a DynamicTargetCtx<'a>>,
129}
130
131/// Invariant replay settings that affect when terminal checks run.
132#[derive(Clone, Copy, Debug, Default)]
133pub struct InvariantReplayOptions {
134    pub check_interval: u32,
135    pub call_after_invariant: bool,
136    pub is_optimization: bool,
137}
138
139/// A structured identity for a failure observed during corpus replay.
140#[derive(Clone, Debug)]
141pub enum ReplayFailure {
142    /// A stateless fuzz test call failed. Keyed by selector, code-path fingerprint, and output.
143    Fuzz { selector: Selector, fingerprint: Option<B256>, output: B256 },
144    /// An invariant handler call hit an assertion.
145    /// Keyed by `(target, selector)`. The fingerprint is retained as replay metadata but does not
146    /// distinguish failure identities.
147    Handler { target: Address, selector: Selector, fingerprint: Option<B256> },
148    /// A handler reverted for an invariant configured with `fail_on_revert`.
149    /// Keyed by invariant name; the handler site is retained for diagnostics.
150    HandlerRevert { name: String, target: Address, selector: Selector },
151    /// A broken invariant predicate. Keyed by the invariant function name.
152    Invariant { name: String },
153    /// The `afterInvariant` hook reverted.
154    AfterInvariant,
155}
156
157impl PartialEq for ReplayFailure {
158    fn eq(&self, other: &Self) -> bool {
159        self.cmp(other) == Ordering::Equal
160    }
161}
162
163impl Eq for ReplayFailure {}
164
165impl PartialOrd for ReplayFailure {
166    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
167        Some(self.cmp(other))
168    }
169}
170
171impl Ord for ReplayFailure {
172    fn cmp(&self, other: &Self) -> Ordering {
173        match (self, other) {
174            (
175                Self::Fuzz { selector, fingerprint, output },
176                Self::Fuzz {
177                    selector: other_selector,
178                    fingerprint: other_fingerprint,
179                    output: other_output,
180                },
181            ) => (selector, fingerprint, output).cmp(&(
182                other_selector,
183                other_fingerprint,
184                other_output,
185            )),
186            (
187                Self::Handler { target, selector, .. },
188                Self::Handler { target: other_target, selector: other_selector, .. },
189            ) => (target, selector).cmp(&(other_target, other_selector)),
190            (Self::HandlerRevert { name, .. }, Self::HandlerRevert { name: other_name, .. }) => {
191                name.cmp(other_name)
192            }
193            (Self::Invariant { name }, Self::Invariant { name: other_name }) => {
194                name.cmp(other_name)
195            }
196            (Self::AfterInvariant, Self::AfterInvariant) => Ordering::Equal,
197            _ => replay_failure_rank(self).cmp(&replay_failure_rank(other)),
198        }
199    }
200}
201
202const fn replay_failure_rank(failure: &ReplayFailure) -> u8 {
203    match failure {
204        ReplayFailure::Fuzz { .. } => 0,
205        ReplayFailure::Handler { .. } => 1,
206        ReplayFailure::HandlerRevert { .. } => 2,
207        ReplayFailure::Invariant { .. } => 3,
208        ReplayFailure::AfterInvariant => 4,
209    }
210}
211
212impl fmt::Display for ReplayFailure {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        match self {
215            Self::Fuzz { selector, .. } => write!(f, "fuzz call {selector:?} failed"),
216            Self::Handler { target, selector, .. } => {
217                write!(f, "handler {selector:?} on {target:?} failed")
218            }
219            Self::HandlerRevert { name, target, selector } => {
220                write!(f, "invariant `{name}` failed on handler {selector:?} on {target:?}")
221            }
222            Self::Invariant { name } => write!(f, "invariant `{name}` broken"),
223            Self::AfterInvariant => f.write_str("afterInvariant broken"),
224        }
225    }
226}
227
228impl ReplayFailure {
229    /// Whether this failure comes from an invariant predicate or `afterInvariant` hook.
230    const fn is_predicate(&self) -> bool {
231        matches!(self, Self::Invariant { .. } | Self::AfterInvariant)
232    }
233}
234
235/// Facts observed while replaying one candidate for corpus minimization.
236#[derive(Clone, Debug, Default, PartialEq, Eq)]
237pub struct ReplayObservation {
238    /// AFL-bucketed EVM edge coverage for the candidate.
239    pub evm_edges: Vec<u8>,
240    /// AFL-bucketed native sancov edge coverage for the candidate.
241    pub sancov_edges: Vec<u8>,
242    /// All unique failure identities observed while replaying the candidate.
243    pub failures: BTreeSet<ReplayFailure>,
244    /// Number of replayable transactions executed.
245    pub replayed: usize,
246    /// Number of transactions that do not target this fuzz/invariant context.
247    pub unmatched: usize,
248    /// Number of transactions rejected via `vm.assume`/`vm.skip`.
249    pub skipped: usize,
250}
251
252impl ReplayObservation {
253    /// Whether replay observed a stateless fuzz or invariant handler failure.
254    pub fn has_non_predicate_failure(&self) -> bool {
255        self.failures.iter().any(|failure| !failure.is_predicate())
256    }
257
258    fn has_invariant_failure(&self) -> bool {
259        self.failures.iter().any(|failure| {
260            matches!(failure, ReplayFailure::HandlerRevert { .. } | ReplayFailure::Invariant { .. })
261        })
262    }
263}
264
265/// Replay every corpus entry under `corpus_dir` and emit showmap files.
266///
267/// `stateless` is set for stateless fuzz tests; `fuzzed_contracts` is set for
268/// invariant tests (txs are committed between calls in that case).
269/// `dynamic` lets invariant replay register contracts deployed mid-sequence so
270/// follow-up calls into them aren't dropped.
271pub fn replay_corpus_to_showmap<FEN: FoundryEvmNetwork>(
272    executor: &Executor<FEN>,
273    corpus_dir: &Path,
274    target: ShowmapReplayTarget<'_>,
275    opts: &ShowmapOpts,
276) -> Result<ShowmapStats> {
277    let entries = read_corpus_tree(corpus_dir)?;
278    if opts.emit_files && entries.is_empty() {
279        return Err(eyre::eyre!("corpus directory not found: {}", corpus_dir.display()));
280    }
281
282    let approach_dir = opts.out_dir.join(&opts.approach);
283    if opts.emit_files {
284        foundry_common::fs::create_dir_all(&approach_dir)?;
285    }
286
287    let mut stats =
288        ShowmapStats { sancov_requested: opts.domain.includes_sancov(), ..Default::default() };
289    let mut replay_failures = Vec::new();
290    let mut failed_entries = 0usize;
291    // Reused per call. In aggregate mode it accumulates across all entries; in per-input mode it
292    // is cleared after each entry's file is written.
293    let mut evm_buf = EvmShowmap::new();
294    let mut san_buf: Vec<u64> = Vec::new();
295
296    for entry in entries {
297        let tx_seq = match entry.read_tx_seq() {
298            Ok(seq) if !seq.is_empty() => seq,
299            Ok(_) => continue,
300            Err(err) => {
301                debug!(target: "showmap", %err, ?entry.path, "failed to read corpus entry");
302                stats.unreadable_entries += 1;
303                stats.skipped_entries += 1;
304                continue;
305            }
306        };
307
308        let mut had_accepted = false;
309        let mut executor = executor.clone();
310        // Targets deployed during this entry, cleared after the entry.
311        let mut created: Vec<Address> = Vec::new();
312        // Number of committed (non-`vm.assume`) calls, used to gate invariant checks.
313        let mut accepted = 0usize;
314        let mut last_accepted_checked_invariant = false;
315        let mut entry_failure: Option<ReplayFailure> = None;
316        for tx in &tx_seq {
317            if !WorkerCorpus::can_replay_tx(tx, target.stateless, target.fuzzed_contracts) {
318                continue;
319            }
320
321            let mut call_result = execute_tx(&mut executor, tx)?;
322            // Snapshot the edge fingerprint before any coverage merge zeroes the buffer.
323            let fingerprint = snapshot_edge_fingerprint(&call_result);
324            // `vm.assume` rejects and cheatcode `vm.skip` are discarded by the campaign: the call
325            // is not committed, checked, or counted toward coverage.
326            if call_result.result.as_ref() == MAGIC_ASSUME
327                || (call_result.reverter == Some(CHEATCODE_ADDRESS)
328                    && SkipReason::decode(&call_result.result).is_some())
329            {
330                continue;
331            }
332            // Coverage-collection asymmetry across calls within a stateful sequence:
333            // - line_coverage is per-call: `Executor::call_raw` returns a fresh HitMap each time,
334            //   so we can simply accumulate it.
335            // - sancov_coverage is the inspector's shared `Vec<u8>` buffer that keeps growing
336            //   across calls, so after consuming it we zero it out to avoid double-counting on the
337            //   next iteration.
338            if opts.domain.includes_evm() {
339                accumulate_evm(&mut evm_buf, call_result.line_coverage.as_ref());
340            }
341            if opts.domain.includes_sancov() {
342                accumulate_sancov(&mut san_buf, call_result.sancov_coverage.as_deref());
343                if let Some(buf) = call_result.sancov_coverage.as_mut() {
344                    buf.fill(0);
345                }
346            }
347
348            had_accepted = true;
349
350            register_replay_created(
351                &call_result.state_changeset,
352                target.dynamic,
353                target.fuzzed_contracts,
354                &mut created,
355            );
356
357            let target_addr = tx.call_details.target;
358            let selector =
359                tx.call_details.calldata.get(..4).map(Selector::from_slice).unwrap_or_default();
360
361            // Stateful tests need the tx committed so subsequent calls see its effects.
362            if target.fuzzed_contracts.is_some() {
363                accepted += 1;
364                last_accepted_checked_invariant = false;
365                if !opts.emit_files
366                    && let Some(failure) = invariant_replay_failures(
367                        target_addr,
368                        selector,
369                        did_fail_on_assert(&call_result, &call_result.state_changeset),
370                        target.invariant_fns,
371                        &call_result,
372                        fingerprint,
373                    )
374                    .into_iter()
375                    .next()
376                {
377                    entry_failure = Some(failure);
378                    break;
379                }
380                executor.commit(&mut call_result);
381                if !opts.emit_files
382                    && should_check_invariant(
383                        accepted,
384                        target.invariant_replay.check_interval,
385                        target.invariant_replay.is_optimization,
386                    )
387                {
388                    last_accepted_checked_invariant = true;
389                    if !target.invariant_replay.is_optimization
390                        && let Some(address) = target.invariant_address
391                        && let Some(failure) =
392                            first_broken_invariant(&executor, address, target.invariant_fns)?
393                    {
394                        entry_failure = Some(failure);
395                        break;
396                    }
397                }
398            } else if !opts.emit_files
399                && !fuzz_replay_call_succeeded(
400                    &executor,
401                    target_addr,
402                    &mut call_result,
403                    target.fuzz_fail_on_revert,
404                )
405            {
406                entry_failure = Some(ReplayFailure::Fuzz {
407                    selector,
408                    fingerprint,
409                    output: keccak256(call_result.result.as_ref()),
410                });
411                break;
412            }
413        }
414        // Final invariant + afterInvariant checks (replay mode only): mirror the
415        // campaign's "always check on the last call", and run afterInvariant unless a
416        // predicate already broke.
417        if !opts.emit_files
418            && entry_failure.is_none()
419            && accepted > 0
420            && target.fuzzed_contracts.is_some()
421            && let Some(address) = target.invariant_address
422        {
423            if !target.invariant_replay.is_optimization
424                && !last_accepted_checked_invariant
425                && let Some(failure) =
426                    first_broken_invariant(&executor, address, target.invariant_fns)?
427            {
428                entry_failure = Some(failure);
429            } else if target.invariant_replay.call_after_invariant
430                && let Some(failure) = broken_after_invariant(&executor, address)?
431            {
432                entry_failure = Some(failure);
433            }
434        }
435        if let Some(failure) = entry_failure {
436            rollback_replay_created(target.fuzzed_contracts, created);
437            failed_entries += 1;
438            if replay_failures.len() < MAX_REPORTED_REPLAY_FAILURES {
439                replay_failures.push(format!(
440                    "corpus entry {} failed during replay: {failure}",
441                    entry.path.display()
442                ));
443            }
444            continue;
445        }
446        rollback_replay_created(target.fuzzed_contracts, created);
447
448        if !had_accepted {
449            stats.skipped_entries += 1;
450            continue;
451        }
452        stats.corpus_entries += 1;
453        if !stats.sancov_observed && san_buf.iter().any(|&x| x != 0) {
454            stats.sancov_observed = true;
455        }
456
457        if opts.emit_files && opts.per_input {
458            // <trial>__<uuid>-<ts>.txt
459            let stem = format!("{}__{}-{}", opts.trial, entry.uuid, entry.timestamp);
460            stats.showmap_files +=
461                write_showmap_file(&approach_dir.join(format!("{stem}.txt")), &evm_buf, &san_buf)?;
462            // Reset for the next entry; preserves capacity so we don't reallocate.
463            evm_buf.clear();
464            san_buf.fill(0);
465        }
466    }
467
468    if opts.emit_files && !opts.per_input {
469        // <trial>.txt
470        stats.showmap_files += write_showmap_file(
471            &approach_dir.join(format!("{}.txt", opts.trial)),
472            &evm_buf,
473            &san_buf,
474        )?;
475    }
476
477    if failed_entries > 0 {
478        return Err(eyre::eyre!(
479            "corpus replay failed:\n{}",
480            replay_failure_report(&replay_failures, failed_entries)
481        ));
482    }
483
484    Ok(stats)
485}
486
487pub struct MinimizationReplayInput<'a> {
488    pub sequence: &'a [BasicTxDetails],
489    pub evm_edge_indices: &'a mut EdgeIndexMap,
490    pub corpus: &'a FuzzCorpusConfig,
491    /// Whether to stop once every selected invariant has failed, mirroring a tmin campaign.
492    pub stop_at_campaign_end: bool,
493}
494
495/// Replays one candidate input and returns coverage/failure facts for minimizers.
496pub fn replay_sequence_for_minimization<FEN: FoundryEvmNetwork>(
497    executor: &Executor<FEN>,
498    input: MinimizationReplayInput<'_>,
499    target: ShowmapReplayTarget<'_>,
500) -> Result<ReplayObservation> {
501    let mut observation = ReplayObservation::default();
502    let mut executor = executor.clone();
503    executor.inspector_mut().collect_edge_coverage_with_config(input.corpus);
504    executor.inspector_mut().collect_sancov_edges(input.corpus.collect_sancov_edges());
505    executor.inspector_mut().collect_sancov_trace_cmp(input.corpus.collect_sancov_trace_cmp());
506
507    let mut created = Vec::new();
508    let mut accepted = 0usize;
509    let mut last_accepted_checked_invariant = false;
510    let mut last_accepted_handlers_succeeded = false;
511    for tx in input.sequence {
512        if !WorkerCorpus::can_replay_tx(tx, target.stateless, target.fuzzed_contracts) {
513            observation.unmatched += 1;
514            continue;
515        }
516
517        let mut call_result = execute_tx(&mut executor, tx)?;
518        let target_addr = tx.call_details.target;
519        let selector =
520            tx.call_details.calldata.get(..4).map(Selector::from_slice).unwrap_or_default();
521        let fingerprint = snapshot_edge_fingerprint(&call_result);
522
523        if call_result.result.as_ref() == MAGIC_ASSUME
524            || (call_result.reverter == Some(CHEATCODE_ADDRESS)
525                && SkipReason::decode(&call_result.result).is_some())
526        {
527            observation.skipped += 1;
528            continue;
529        }
530
531        call_result.merge_all_coverage(
532            &mut observation.evm_edges,
533            input.evm_edge_indices,
534            &mut observation.sancov_edges,
535        );
536        observation.replayed += 1;
537
538        register_replay_created(
539            &call_result.state_changeset,
540            target.dynamic,
541            target.fuzzed_contracts,
542            &mut created,
543        );
544
545        if target.fuzzed_contracts.is_some() {
546            accepted += 1;
547            last_accepted_checked_invariant = false;
548            last_accepted_handlers_succeeded =
549                invariant_handlers_succeeded(&executor, &target, &call_result);
550            for failure in invariant_replay_failures(
551                target_addr,
552                selector,
553                did_fail_on_assert(&call_result, &call_result.state_changeset),
554                target.invariant_fns,
555                &call_result,
556                fingerprint,
557            ) {
558                observation.failures.insert(failure);
559            }
560            executor.commit(&mut call_result);
561            if input.stop_at_campaign_end
562                && all_invariants_failed(&observation.failures, target.invariant_fns)
563            {
564                break;
565            }
566            if should_check_invariant(
567                accepted,
568                target.invariant_replay.check_interval,
569                target.invariant_replay.is_optimization,
570            ) && last_accepted_handlers_succeeded
571            {
572                last_accepted_checked_invariant = true;
573                if !target.invariant_replay.is_optimization
574                    && let Some(address) = target.invariant_address
575                {
576                    for failure in newly_broken_invariants(
577                        &executor,
578                        address,
579                        target.invariant_fns,
580                        &observation.failures,
581                    )? {
582                        observation.failures.insert(failure);
583                    }
584                    if input.stop_at_campaign_end
585                        && all_invariants_failed(&observation.failures, target.invariant_fns)
586                    {
587                        break;
588                    }
589                }
590            }
591        } else if !fuzz_replay_call_succeeded(
592            &executor,
593            target_addr,
594            &mut call_result,
595            target.fuzz_fail_on_revert,
596        ) {
597            observation.failures.insert(ReplayFailure::Fuzz {
598                selector,
599                fingerprint,
600                output: keccak256(call_result.result.as_ref()),
601            });
602            break;
603        }
604    }
605
606    if !all_invariants_failed(&observation.failures, target.invariant_fns)
607        && accepted > 0
608        && target.fuzzed_contracts.is_some()
609        && let Some(address) = target.invariant_address
610    {
611        if !target.invariant_replay.is_optimization
612            && last_accepted_handlers_succeeded
613            && !last_accepted_checked_invariant
614        {
615            for failure in newly_broken_invariants(
616                &executor,
617                address,
618                target.invariant_fns,
619                &observation.failures,
620            )? {
621                observation.failures.insert(failure);
622            }
623        }
624        if target.invariant_replay.call_after_invariant
625            && !observation.has_invariant_failure()
626            && let Some(failure) = broken_after_invariant(&executor, address)?
627        {
628            observation.failures.insert(failure);
629        }
630    }
631
632    rollback_replay_created(target.fuzzed_contracts, created);
633    Ok(observation)
634}
635
636/// Whether the just-executed handler call passed the campaign's success gate.
637fn invariant_handlers_succeeded<FEN: FoundryEvmNetwork>(
638    executor: &Executor<FEN>,
639    target: &ShowmapReplayTarget<'_>,
640    call_result: &crate::executors::RawCallResult<FEN>,
641) -> bool {
642    if call_result.reverted {
643        return false;
644    }
645
646    if !executor.legacy_assertions() {
647        return target.invariant_address.is_some_and(|address| {
648            executor.is_success_handler_gate(
649                address,
650                false,
651                Cow::Borrowed(&call_result.state_changeset),
652            )
653        });
654    }
655
656    target.fuzzed_contracts.is_some_and(|contracts| {
657        contracts.targets().keys().all(|address| {
658            executor.is_success_handler_gate(
659                *address,
660                false,
661                Cow::Borrowed(&call_result.state_changeset),
662            )
663        })
664    })
665}
666
667fn replay_failure_report(replay_failures: &[String], failed_entries: usize) -> String {
668    let mut report = replay_failures.join("\n");
669    let unreported = failed_entries.saturating_sub(replay_failures.len());
670    if unreported > 0 {
671        if !report.is_empty() {
672            report.push('\n');
673        }
674        report.push_str(&format!("... and {unreported} more failed corpus entries"));
675    }
676    report
677}
678
679/// Returns replay failures produced directly by a handler call, mirroring the campaign:
680/// assertions are keyed by handler site, while a non-assertion revert breaks every invariant
681/// configured with `fail_on_revert`.
682fn invariant_replay_failures<FEN: FoundryEvmNetwork>(
683    target: Address,
684    selector: Selector,
685    assertion_failure: bool,
686    invariant_fns: &[(&Function, bool)],
687    call_result: &crate::executors::RawCallResult<FEN>,
688    fingerprint: Option<B256>,
689) -> Vec<ReplayFailure> {
690    if assertion_failure {
691        return vec![ReplayFailure::Handler { target, selector, fingerprint }];
692    }
693    if !call_result.reverted || call_result.result.as_ref() == MAGIC_ASSUME {
694        return Vec::new();
695    }
696    invariant_fns
697        .iter()
698        .filter(|(_, fail_on_revert)| *fail_on_revert)
699        .map(|(invariant, _)| ReplayFailure::HandlerRevert {
700            name: invariant.name.clone(),
701            target,
702            selector,
703        })
704        .collect()
705}
706
707fn fuzz_replay_call_succeeded<FEN: FoundryEvmNetwork>(
708    executor: &Executor<FEN>,
709    target_addr: Address,
710    call_result: &mut crate::executors::RawCallResult<FEN>,
711    fail_on_revert: bool,
712) -> bool {
713    if !fail_on_revert
714        && call_result
715            .reverter
716            .is_some_and(|reverter| reverter != target_addr && reverter != CHEATCODE_ADDRESS)
717    {
718        true
719    } else {
720        executor.is_raw_call_mut_success(target_addr, call_result, false)
721    }
722}
723
724fn newly_broken_invariants<FEN: FoundryEvmNetwork>(
725    executor: &Executor<FEN>,
726    invariant_address: Address,
727    invariant_fns: &[(&Function, bool)],
728    observed: &BTreeSet<ReplayFailure>,
729) -> Result<Vec<ReplayFailure>> {
730    let mut failures = Vec::new();
731    for (invariant, _) in invariant_fns {
732        if has_replay_invariant_failure(observed, &invariant.name) {
733            continue;
734        }
735        let (_, success) = call_invariant_function(
736            executor,
737            invariant_address,
738            invariant.abi_encode_input(&[])?.into(),
739        )?;
740        if !success {
741            failures.push(ReplayFailure::Invariant { name: invariant.name.clone() });
742        }
743    }
744    Ok(failures)
745}
746
747fn first_broken_invariant<FEN: FoundryEvmNetwork>(
748    executor: &Executor<FEN>,
749    invariant_address: Address,
750    invariant_fns: &[(&Function, bool)],
751) -> Result<Option<ReplayFailure>> {
752    for (invariant, _) in invariant_fns {
753        let (_, success) = call_invariant_function(
754            executor,
755            invariant_address,
756            invariant.abi_encode_input(&[])?.into(),
757        )?;
758        if !success {
759            return Ok(Some(ReplayFailure::Invariant { name: invariant.name.clone() }));
760        }
761    }
762    Ok(None)
763}
764
765fn has_replay_invariant_failure(failures: &BTreeSet<ReplayFailure>, name: &str) -> bool {
766    failures.iter().any(|failure| {
767        matches!(
768            failure,
769            ReplayFailure::HandlerRevert { name: observed, .. }
770                | ReplayFailure::Invariant { name: observed }
771                if observed == name
772        )
773    })
774}
775
776fn all_invariants_failed(
777    failures: &BTreeSet<ReplayFailure>,
778    invariant_fns: &[(&Function, bool)],
779) -> bool {
780    invariant_fns
781        .iter()
782        .all(|(invariant, _)| has_replay_invariant_failure(failures, &invariant.name))
783}
784
785fn broken_after_invariant<FEN: FoundryEvmNetwork>(
786    executor: &Executor<FEN>,
787    invariant_address: Address,
788) -> Result<Option<ReplayFailure>> {
789    let (_, success) = call_after_invariant_function(executor, invariant_address)?;
790    Ok((!success).then_some(ReplayFailure::AfterInvariant))
791}
792
793/// Whether the invariant predicate should be evaluated after the `accepted`-th
794/// committed (non-`vm.assume`) call.
795///
796/// Mirrors the campaign: with `check_interval == 0` only the final call is checked
797/// (callers additionally perform a final check after the sequence ends); with
798/// `check_interval == 1` every call is checked; otherwise every N-th call.
799fn should_check_invariant(accepted: usize, check_interval: u32, is_optimization: bool) -> bool {
800    debug_assert!(accepted > 0);
801    is_optimization
802        || check_interval == 1
803        || (check_interval > 1 && accepted.is_multiple_of(check_interval as usize))
804}
805
806/// Saturating-add per-(bytecode, pc) hits from a `HitMaps` snapshot into `dst`.
807fn accumulate_evm(dst: &mut EvmShowmap, src: Option<&HitMaps>) {
808    let Some(maps) = src else { return };
809    for (hash, hitmap) in maps.iter() {
810        for (pc, hits) in hitmap.iter() {
811            let slot = dst.entry((*hash, pc)).or_default();
812            *slot = slot.saturating_add(hits as u64);
813        }
814    }
815}
816
817/// Saturating-add `src` (u8 raw counts) into `dst` (u64 aggregated counts).
818fn accumulate_sancov(dst: &mut Vec<u64>, src: Option<&[u8]>) {
819    let Some(src) = src else { return };
820    if dst.len() < src.len() {
821        dst.resize(src.len(), 0);
822    }
823    for (d, &s) in dst.iter_mut().zip(src) {
824        if s != 0 {
825            *d = d.saturating_add(s as u64);
826        }
827    }
828}
829
830/// Write a single showmap file. Returns 1 if a file was written, 0 if skipped
831/// (no nonzero entries).
832fn write_showmap_file(path: &Path, evm: &EvmShowmap, san: &[u64]) -> Result<usize> {
833    // Pre-check so we don't create empty files.
834    let has_evm = evm.values().any(|&c| c != 0);
835    let has_san = san.iter().any(|&c| c != 0);
836    if !has_evm && !has_san {
837        return Ok(0);
838    }
839    let mut w = BufWriter::new(File::create_new(path).map_err(|err| {
840        eyre::eyre!(
841            "failed to create showmap file {}: {err}; pick a different --showmap-trial or remove \
842             the existing file",
843            path.display()
844        )
845    })?);
846    write_evm(&mut w, evm)?;
847    write_sancov(&mut w, san)?;
848    w.flush()?;
849    Ok(1)
850}
851
852/// Each EVM ID is `evm_<bytecode_hash[:16hex]>_<pc:04x>`. The 16-hex prefix
853/// (64 bits) of the keccak256 bytecode hash makes IDs deterministic across
854/// processes while keeping line lengths short.
855fn write_evm<W: Write>(out: &mut W, evm: &EvmShowmap) -> std::io::Result<()> {
856    let mut entries = evm.iter().filter(|(_, count)| **count != 0).collect::<Vec<_>>();
857    entries.sort_unstable_by_key(|((hash, pc), _)| (*hash, *pc));
858
859    for ((hash, pc), count) in entries {
860        let h = hex::encode(&hash.as_slice()[..8]);
861        writeln!(out, "evm_{h}_{pc:04x}:{count}")?;
862    }
863    Ok(())
864}
865
866fn write_sancov<W: Write>(out: &mut W, bitmap: &[u64]) -> std::io::Result<()> {
867    for (idx, &count) in bitmap.iter().enumerate() {
868        if count != 0 {
869            // Underscore (not `:`) between prefix and id keeps the showmap
870            // `<id>:<count>` parser unambiguous.
871            writeln!(out, "sancov_0x{idx:04x}:{count}")?;
872        }
873    }
874    Ok(())
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use crate::executors::{RawCallResult, corpus_io::canonical_replay_dirs};
881    use foundry_evm_core::evm::EthEvmNetwork;
882    use revm::interpreter::InstructionResult;
883    use uuid::Uuid;
884
885    fn temp_dir() -> PathBuf {
886        let dir = std::env::temp_dir().join(format!("foundry-showmap-{}", Uuid::new_v4()));
887        std::fs::create_dir_all(&dir).unwrap();
888        dir
889    }
890
891    #[test]
892    fn accumulate_sancov_resizes_and_saturating_adds() {
893        let mut dst: Vec<u64> = vec![10];
894        accumulate_sancov(&mut dst, Some(&[1u8, 2, 3]));
895        assert_eq!(dst, vec![11, 2, 3]);
896    }
897
898    #[test]
899    fn write_evm_emits_only_nonzero_deterministic_ids() {
900        let mut buf: Vec<u8> = Vec::new();
901        let h = B256::with_last_byte(0xab);
902        let mut evm = EvmShowmap::new();
903        evm.insert((h, 1u32), 0u64); // skipped (count=0)
904        evm.insert((h, 0x2au32), 3u64);
905        write_evm(&mut buf, &evm).unwrap();
906        let h_hex = hex::encode(&h.as_slice()[..8]);
907        assert_eq!(String::from_utf8(buf).unwrap(), format!("evm_{h_hex}_002a:3\n"));
908    }
909
910    #[test]
911    fn write_sancov_emits_only_nonzero_hex_ids() {
912        let mut buf: Vec<u8> = Vec::new();
913        write_sancov(&mut buf, &[0, 3, 0, 1]).unwrap();
914        assert_eq!(String::from_utf8(buf).unwrap(), "sancov_0x0001:3\nsancov_0x0003:1\n");
915    }
916
917    #[test]
918    fn write_showmap_file_skips_when_empty() {
919        let dir = temp_dir();
920        let path = dir.join("trial.txt");
921        let written = write_showmap_file(&path, &EvmShowmap::new(), &[]).unwrap();
922        assert_eq!(written, 0);
923        assert!(!path.exists());
924    }
925
926    #[test]
927    fn write_showmap_file_writes_combined_domains() {
928        let dir = temp_dir();
929        let path = dir.join("trial.txt");
930        let h = B256::with_last_byte(0xff);
931        let mut evm = EvmShowmap::new();
932        evm.insert((h, 7u32), 5u64);
933        let written = write_showmap_file(&path, &evm, &[2]).unwrap();
934        assert_eq!(written, 1);
935        let body = std::fs::read_to_string(&path).unwrap();
936        let h_hex = hex::encode(&h.as_slice()[..8]);
937        assert_eq!(body, format!("evm_{h_hex}_0007:5\nsancov_0x0000:2\n"));
938    }
939
940    #[test]
941    fn write_showmap_file_does_not_overwrite_existing_file() {
942        let dir = temp_dir();
943        let path = dir.join("trial.txt");
944        std::fs::write(&path, "keep me").unwrap();
945        let h = B256::with_last_byte(0xff);
946        let mut evm = EvmShowmap::new();
947        evm.insert((h, 7u32), 5u64);
948        let err = write_showmap_file(&path, &evm, &[]).unwrap_err();
949        assert!(err.to_string().contains("pick a different --showmap-trial"), "{err:?}");
950        assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me");
951    }
952
953    #[test]
954    fn replay_failure_report_caps_details() {
955        let failures = (0..MAX_REPORTED_REPLAY_FAILURES)
956            .map(|idx| format!("corpus entry {idx} failed during replay: fuzz call failed"))
957            .collect::<Vec<_>>();
958        let report = replay_failure_report(&failures, MAX_REPORTED_REPLAY_FAILURES + 3);
959
960        assert!(report.contains("corpus entry 0 failed during replay"), "{report}");
961        assert!(report.contains("corpus entry 19 failed during replay"), "{report}");
962        assert!(report.contains("... and 3 more failed corpus entries"), "{report}");
963        assert!(!report.contains("corpus entry 20 failed during replay"), "{report}");
964    }
965
966    #[test]
967    fn replay_observation_derives_failure_classification() {
968        let handler = ReplayFailure::Handler {
969            target: Address::with_last_byte(1),
970            selector: Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
971            fingerprint: None,
972        };
973        let mut observation = ReplayObservation::default();
974
975        assert!(!observation.has_non_predicate_failure());
976        assert!(!observation.has_invariant_failure());
977
978        observation
979            .failures
980            .insert(ReplayFailure::Invariant { name: "invariant_first".to_string() });
981        assert!(!observation.has_non_predicate_failure());
982        assert!(observation.has_invariant_failure());
983
984        observation.failures.insert(handler);
985        assert!(observation.has_non_predicate_failure());
986
987        observation.failures.insert(ReplayFailure::Fuzz {
988            selector: Selector::ZERO,
989            fingerprint: None,
990            output: B256::ZERO,
991        });
992        assert!(observation.has_non_predicate_failure());
993    }
994
995    #[test]
996    fn handler_failure_identity_ignores_path_fingerprint() {
997        let target = Address::with_last_byte(1);
998        let selector = Selector::from([0xaa, 0xbb, 0xcc, 0xdd]);
999        let failures = BTreeSet::from([
1000            ReplayFailure::Handler { target, selector, fingerprint: Some(B256::with_last_byte(1)) },
1001            ReplayFailure::Handler { target, selector, fingerprint: Some(B256::with_last_byte(2)) },
1002        ]);
1003
1004        let observation = ReplayObservation { failures, ..ReplayObservation::default() };
1005        assert_eq!(observation.failures.len(), 1);
1006        assert!(observation.has_non_predicate_failure());
1007    }
1008
1009    #[test]
1010    fn handler_revert_identity_ignores_handler_site() {
1011        let failures = BTreeSet::from([
1012            ReplayFailure::HandlerRevert {
1013                name: "invariant_ok".to_string(),
1014                target: Address::with_last_byte(1),
1015                selector: Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
1016            },
1017            ReplayFailure::HandlerRevert {
1018                name: "invariant_ok".to_string(),
1019                target: Address::with_last_byte(2),
1020                selector: Selector::from([0x11, 0x22, 0x33, 0x44]),
1021            },
1022        ]);
1023
1024        let observation = ReplayObservation { failures, ..ReplayObservation::default() };
1025        assert_eq!(observation.failures.len(), 1);
1026        assert!(observation.has_non_predicate_failure());
1027        assert!(observation.has_invariant_failure());
1028    }
1029
1030    #[test]
1031    fn has_replay_invariant_failure_matches_only_observed_predicate() {
1032        let failures = BTreeSet::from([
1033            ReplayFailure::HandlerRevert {
1034                name: "invariant_revert".to_string(),
1035                target: Address::with_last_byte(1),
1036                selector: Selector::ZERO,
1037            },
1038            ReplayFailure::Invariant { name: "invariant_first".to_string() },
1039            ReplayFailure::AfterInvariant,
1040        ]);
1041
1042        assert!(has_replay_invariant_failure(&failures, "invariant_revert"));
1043        assert!(has_replay_invariant_failure(&failures, "invariant_first"));
1044        assert!(!has_replay_invariant_failure(&failures, "invariant_second"));
1045    }
1046
1047    #[test]
1048    fn should_check_invariant_matches_campaign_intervals() {
1049        // check_interval == 1: every accepted call.
1050        assert!(should_check_invariant(1, 1, false));
1051        assert!(should_check_invariant(2, 1, false));
1052        // check_interval == 0: never inline (callers do a final check instead).
1053        assert!(!should_check_invariant(1, 0, false));
1054        assert!(!should_check_invariant(5, 0, false));
1055        // check_interval == N: every N-th accepted call.
1056        assert!(!should_check_invariant(1, 3, false));
1057        assert!(!should_check_invariant(2, 3, false));
1058        assert!(should_check_invariant(3, 3, false));
1059        assert!(should_check_invariant(6, 3, false));
1060        // Optimization mode evaluates every prefix to track the best value.
1061        assert!(should_check_invariant(1, 0, true));
1062    }
1063
1064    #[test]
1065    fn invariant_replay_failures_ignore_plain_revert() {
1066        let call_result = RawCallResult::<EthEvmNetwork> {
1067            reverted: true,
1068            exit_reason: Some(InstructionResult::Revert),
1069            ..Default::default()
1070        };
1071        let failures = invariant_replay_failures(
1072            Address::with_last_byte(1),
1073            Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
1074            did_fail_on_assert(&call_result, &call_result.state_changeset),
1075            &[],
1076            &call_result,
1077            None,
1078        );
1079        assert!(failures.is_empty());
1080    }
1081
1082    #[test]
1083    fn invariant_replay_failures_report_every_fail_on_revert_invariant() {
1084        let call_result = RawCallResult::<EthEvmNetwork> {
1085            reverted: true,
1086            exit_reason: Some(InstructionResult::Revert),
1087            ..Default::default()
1088        };
1089        let first = serde_json::from_value::<Function>(serde_json::json!({
1090            "type": "function",
1091            "name": "invariant_first",
1092            "inputs": [],
1093            "outputs": [],
1094            "stateMutability": "view"
1095        }))
1096        .unwrap();
1097        let second = serde_json::from_value::<Function>(serde_json::json!({
1098            "type": "function",
1099            "name": "invariant_second",
1100            "inputs": [],
1101            "outputs": [],
1102            "stateMutability": "view"
1103        }))
1104        .unwrap();
1105        let ignored = serde_json::from_value::<Function>(serde_json::json!({
1106            "type": "function",
1107            "name": "invariant_ignored",
1108            "inputs": [],
1109            "outputs": [],
1110            "stateMutability": "view"
1111        }))
1112        .unwrap();
1113        let failures = invariant_replay_failures(
1114            Address::with_last_byte(1),
1115            Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
1116            did_fail_on_assert(&call_result, &call_result.state_changeset),
1117            &[(&first, true), (&second, true), (&ignored, false)],
1118            &call_result,
1119            None,
1120        );
1121        assert_eq!(
1122            failures,
1123            [
1124                ReplayFailure::HandlerRevert {
1125                    name: "invariant_first".to_string(),
1126                    target: Address::with_last_byte(1),
1127                    selector: Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
1128                },
1129                ReplayFailure::HandlerRevert {
1130                    name: "invariant_second".to_string(),
1131                    target: Address::with_last_byte(1),
1132                    selector: Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
1133                },
1134            ]
1135        );
1136    }
1137
1138    #[test]
1139    fn invariant_replay_failures_prefer_assertion_over_fail_on_revert() {
1140        let call_result = RawCallResult::<EthEvmNetwork> {
1141            reverted: true,
1142            exit_reason: Some(InstructionResult::Revert),
1143            ..Default::default()
1144        };
1145        let invariant = serde_json::from_value::<Function>(serde_json::json!({
1146            "type": "function",
1147            "name": "invariant_ok",
1148            "inputs": [],
1149            "outputs": [],
1150            "stateMutability": "view"
1151        }))
1152        .unwrap();
1153        let failures = invariant_replay_failures(
1154            Address::with_last_byte(1),
1155            Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
1156            true,
1157            &[(&invariant, true)],
1158            &call_result,
1159            None,
1160        );
1161        assert!(matches!(failures.as_slice(), [ReplayFailure::Handler { .. }]));
1162    }
1163
1164    #[test]
1165    fn canonical_replay_dirs_collects_all_workers() {
1166        let dir = temp_dir();
1167        let w0 = dir.join("worker0").join("corpus");
1168        let w1 = dir.join("worker1").join("corpus");
1169        std::fs::create_dir_all(&w0).unwrap();
1170        std::fs::create_dir_all(&w1).unwrap();
1171        assert_eq!(canonical_replay_dirs(&dir), vec![w0, w1]);
1172    }
1173
1174    #[test]
1175    fn canonical_replay_dirs_falls_back_when_no_workers() {
1176        let dir = temp_dir();
1177        assert_eq!(canonical_replay_dirs(&dir), vec![dir]);
1178    }
1179}