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    collections::HashMap,
44    fmt,
45    fs::File,
46    io::{BufWriter, Write},
47    path::{Path, PathBuf},
48};
49
50type EvmShowmap = HashMap<(B256, u32), u64>;
51const MAX_REPORTED_REPLAY_FAILURES: usize = 20;
52
53/// Which coverage bitmap(s) to dump.
54#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
55pub enum ShowmapDomain {
56    #[default]
57    Evm,
58    Sancov,
59    Both,
60}
61
62impl ShowmapDomain {
63    pub const fn includes_evm(self) -> bool {
64        matches!(self, Self::Evm | Self::Both)
65    }
66    pub const fn includes_sancov(self) -> bool {
67        matches!(self, Self::Sancov | Self::Both)
68    }
69}
70
71impl fmt::Display for ShowmapDomain {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::Evm => f.write_str("evm"),
75            Self::Sancov => f.write_str("sancov"),
76            Self::Both => f.write_str("both"),
77        }
78    }
79}
80
81/// Per-replay options.
82#[derive(Clone, Debug)]
83pub struct ShowmapOpts {
84    /// Output root directory; emitted files live under `<out_dir>/<approach>/`.
85    pub out_dir: PathBuf,
86    /// Approach directory name; test identity is folded in here so each
87    /// `<approach>/` contains trials of one test (matches `differential-coverage`).
88    pub approach: String,
89    /// Rerun identifier used as the filename so multiple trials accumulate side-by-side.
90    pub trial: String,
91    /// Whether to emit one file per corpus entry or one aggregated file.
92    pub per_input: bool,
93    /// Which bitmap(s) to dump.
94    pub domain: ShowmapDomain,
95    /// Whether to write showmap files. Disabled by `forge fuzz replay`.
96    pub emit_files: bool,
97}
98
99/// Stats returned from a single trial replay.
100#[derive(Clone, Debug, Default)]
101pub struct ShowmapStats {
102    /// Number of corpus entries successfully replayed.
103    pub corpus_entries: usize,
104    /// Number of files written to disk.
105    pub showmap_files: usize,
106    /// Number of corpus entries skipped because they couldn't be replayed
107    /// against the current target (e.g. selector mismatch).
108    pub skipped_entries: usize,
109    /// Number of corpus entries skipped because they could not be read.
110    pub unreadable_entries: usize,
111    /// True if sancov coverage was requested. Lets the caller distinguish
112    /// "sancov not asked for" from "sancov asked for but produced nothing".
113    pub sancov_requested: bool,
114    /// True if any non-zero sancov hits were observed across the replay.
115    pub sancov_observed: bool,
116}
117
118/// Test target metadata needed to replay corpus entries.
119pub struct ShowmapReplayTarget<'a> {
120    pub stateless: Option<StatelessReplayTarget<'a>>,
121    pub fuzz_fail_on_revert: bool,
122    pub fuzzed_contracts: Option<&'a FuzzRunIdentifiedContracts>,
123    pub invariant_address: Option<Address>,
124    pub invariant_fns: &'a [(&'a Function, bool)],
125    pub invariant_replay: InvariantReplayOptions,
126    pub dynamic: Option<&'a DynamicTargetCtx<'a>>,
127}
128
129/// Invariant replay settings that affect when terminal checks run.
130#[derive(Clone, Copy, Debug, Default)]
131pub struct InvariantReplayOptions {
132    pub check_interval: u32,
133    pub call_after_invariant: bool,
134    pub is_optimization: bool,
135}
136
137/// A structured identity for a failure observed during corpus replay.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub enum ReplayFailure {
140    /// A stateless fuzz test call failed. Keyed by selector, code-path fingerprint, and output.
141    Fuzz { selector: Selector, fingerprint: Option<B256>, output: B256 },
142    /// An invariant handler call hit an assertion or a `fail_on_revert` revert.
143    /// Keyed by `(target, selector)` site and code-path fingerprint, mirroring the
144    /// campaign's handler-bug deduplication.
145    Handler {
146        target: Address,
147        selector: Selector,
148        fingerprint: Option<B256>,
149        terminal: bool,
150        invariant: Option<String>,
151    },
152    /// A broken invariant predicate. Keyed by the invariant function name.
153    Invariant { name: String },
154    /// The `afterInvariant` hook reverted.
155    AfterInvariant,
156}
157
158impl fmt::Display for ReplayFailure {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        match self {
161            Self::Fuzz { selector, .. } => write!(f, "fuzz call {selector:?} failed"),
162            Self::Handler { target, selector, invariant, .. } => {
163                if let Some(invariant) = invariant {
164                    write!(
165                        f,
166                        "invariant `{invariant}` failed on handler {selector:?} on {target:?}"
167                    )
168                } else {
169                    write!(f, "handler {selector:?} on {target:?} failed")
170                }
171            }
172            Self::Invariant { name } => write!(f, "invariant `{name}` broken"),
173            Self::AfterInvariant => f.write_str("afterInvariant broken"),
174        }
175    }
176}
177
178impl ReplayFailure {
179    /// Whether this failure terminates the run, mirroring the campaign.
180    const fn is_terminal(&self) -> bool {
181        !matches!(self, Self::Handler { terminal: false, .. })
182    }
183
184    /// Whether this failure is a broken invariant predicate.
185    const fn is_predicate(&self) -> bool {
186        matches!(self, Self::Invariant { .. } | Self::AfterInvariant)
187    }
188}
189
190/// Records `failure` as the representative failure for an observation, preferring
191/// terminal failures over non-terminal handler bugs and keeping the first of each class.
192fn record_replay_failure(slot: &mut Option<ReplayFailure>, failure: ReplayFailure) {
193    match slot {
194        None => *slot = Some(failure),
195        Some(existing) if !existing.is_terminal() && failure.is_terminal() => *slot = Some(failure),
196        Some(_) => {}
197    }
198}
199
200/// Facts observed while replaying one candidate for corpus minimization.
201#[derive(Clone, Debug, Default, PartialEq, Eq)]
202pub struct ReplayObservation {
203    /// AFL-bucketed EVM edge coverage for the candidate.
204    pub evm_edges: Vec<u8>,
205    /// AFL-bucketed native sancov edge coverage for the candidate.
206    pub sancov_edges: Vec<u8>,
207    /// Comparable failure identity, if replaying this candidate fails.
208    pub failure: Option<ReplayFailure>,
209    /// Number of replayable transactions executed.
210    pub replayed: usize,
211    /// Number of transactions that do not target this fuzz/invariant context.
212    pub unmatched: usize,
213    /// Number of transactions rejected via `vm.assume`/`vm.skip`.
214    pub skipped: usize,
215}
216
217/// Replay every corpus entry under `corpus_dir` and emit showmap files.
218///
219/// `stateless` is set for stateless fuzz tests; `fuzzed_contracts` is set for
220/// invariant tests (txs are committed between calls in that case).
221/// `dynamic` lets invariant replay register contracts deployed mid-sequence so
222/// follow-up calls into them aren't dropped.
223pub fn replay_corpus_to_showmap<FEN: FoundryEvmNetwork>(
224    executor: &Executor<FEN>,
225    corpus_dir: &Path,
226    target: ShowmapReplayTarget<'_>,
227    opts: &ShowmapOpts,
228) -> Result<ShowmapStats> {
229    let entries = read_corpus_tree(corpus_dir)?;
230    if opts.emit_files && entries.is_empty() {
231        return Err(eyre::eyre!("corpus directory not found: {}", corpus_dir.display()));
232    }
233
234    let approach_dir = opts.out_dir.join(&opts.approach);
235    if opts.emit_files {
236        foundry_common::fs::create_dir_all(&approach_dir)?;
237    }
238
239    let mut stats =
240        ShowmapStats { sancov_requested: opts.domain.includes_sancov(), ..Default::default() };
241    let mut replay_failures = Vec::new();
242    let mut failed_entries = 0usize;
243    // Reused per call. In aggregate mode it accumulates across all entries; in per-input mode it
244    // is cleared after each entry's file is written.
245    let mut evm_buf = EvmShowmap::new();
246    let mut san_buf: Vec<u64> = Vec::new();
247
248    for entry in entries {
249        let tx_seq = match entry.read_tx_seq() {
250            Ok(seq) if !seq.is_empty() => seq,
251            Ok(_) => continue,
252            Err(err) => {
253                debug!(target: "showmap", %err, ?entry.path, "failed to read corpus entry");
254                stats.unreadable_entries += 1;
255                stats.skipped_entries += 1;
256                continue;
257            }
258        };
259
260        let mut had_accepted = false;
261        let mut executor = executor.clone();
262        // Targets deployed during this entry, cleared after the entry.
263        let mut created: Vec<Address> = Vec::new();
264        // Number of committed (non-`vm.assume`) calls, used to gate invariant checks.
265        let mut accepted = 0usize;
266        let mut last_accepted_checked_invariant = false;
267        let mut entry_failure: Option<ReplayFailure> = None;
268        for tx in &tx_seq {
269            if !WorkerCorpus::can_replay_tx(tx, target.stateless, target.fuzzed_contracts) {
270                continue;
271            }
272
273            let mut call_result = execute_tx(&mut executor, tx)?;
274            // Snapshot the edge fingerprint before any coverage merge zeroes the buffer.
275            let fingerprint = snapshot_edge_fingerprint(&call_result);
276            // `vm.assume` rejects and cheatcode `vm.skip` are discarded by the campaign: the call
277            // is not committed, checked, or counted toward coverage.
278            if call_result.result.as_ref() == MAGIC_ASSUME
279                || (call_result.reverter == Some(CHEATCODE_ADDRESS)
280                    && SkipReason::decode(&call_result.result).is_some())
281            {
282                continue;
283            }
284            // Coverage-collection asymmetry across calls within a stateful sequence:
285            // - line_coverage is per-call: `Executor::call_raw` returns a fresh HitMap each time,
286            //   so we can simply accumulate it.
287            // - sancov_coverage is the inspector's shared `Vec<u8>` buffer that keeps growing
288            //   across calls, so after consuming it we zero it out to avoid double-counting on the
289            //   next iteration.
290            if opts.domain.includes_evm() {
291                accumulate_evm(&mut evm_buf, call_result.line_coverage.as_ref());
292            }
293            if opts.domain.includes_sancov() {
294                accumulate_sancov(&mut san_buf, call_result.sancov_coverage.as_deref());
295                if let Some(buf) = call_result.sancov_coverage.as_mut() {
296                    buf.fill(0);
297                }
298            }
299
300            had_accepted = true;
301
302            register_replay_created(
303                &call_result.state_changeset,
304                target.dynamic,
305                target.fuzzed_contracts,
306                &mut created,
307            );
308
309            let target_addr = tx.call_details.target;
310            let selector =
311                tx.call_details.calldata.get(..4).map(Selector::from_slice).unwrap_or_default();
312
313            // Stateful tests need the tx committed so subsequent calls see its effects.
314            if target.fuzzed_contracts.is_some() {
315                accepted += 1;
316                last_accepted_checked_invariant = false;
317                if !opts.emit_files
318                    && let Some(failure) = invariant_handler_failure(
319                        target_addr,
320                        selector,
321                        did_fail_on_assert(&call_result, &call_result.state_changeset),
322                        target.invariant_fns,
323                        &call_result,
324                        fingerprint,
325                    )
326                {
327                    entry_failure = Some(failure);
328                    break;
329                }
330                executor.commit(&mut call_result);
331                if !opts.emit_files
332                    && should_check_invariant(
333                        accepted,
334                        target.invariant_replay.check_interval,
335                        target.invariant_replay.is_optimization,
336                    )
337                {
338                    last_accepted_checked_invariant = true;
339                    if !target.invariant_replay.is_optimization
340                        && let Some(address) = target.invariant_address
341                        && let Some(failure) =
342                            broken_invariant(&executor, address, target.invariant_fns)?
343                    {
344                        entry_failure = Some(failure);
345                        break;
346                    }
347                }
348            } else if !opts.emit_files
349                && !fuzz_replay_call_succeeded(
350                    &executor,
351                    target_addr,
352                    &mut call_result,
353                    target.fuzz_fail_on_revert,
354                )
355            {
356                entry_failure = Some(ReplayFailure::Fuzz {
357                    selector,
358                    fingerprint,
359                    output: keccak256(call_result.result.as_ref()),
360                });
361                break;
362            }
363        }
364        // Final invariant + afterInvariant checks (replay mode only): mirror the
365        // campaign's "always check on the last call", and run afterInvariant unless a
366        // predicate already broke.
367        if !opts.emit_files
368            && entry_failure.is_none()
369            && accepted > 0
370            && target.fuzzed_contracts.is_some()
371            && let Some(address) = target.invariant_address
372        {
373            if !target.invariant_replay.is_optimization
374                && !last_accepted_checked_invariant
375                && let Some(failure) = broken_invariant(&executor, address, target.invariant_fns)?
376            {
377                entry_failure = Some(failure);
378            } else if target.invariant_replay.call_after_invariant
379                && let Some(failure) = broken_after_invariant(&executor, address)?
380            {
381                entry_failure = Some(failure);
382            }
383        }
384        if let Some(failure) = entry_failure {
385            rollback_replay_created(target.fuzzed_contracts, created);
386            failed_entries += 1;
387            if replay_failures.len() < MAX_REPORTED_REPLAY_FAILURES {
388                replay_failures.push(format!(
389                    "corpus entry {} failed during replay: {failure}",
390                    entry.path.display()
391                ));
392            }
393            continue;
394        }
395        rollback_replay_created(target.fuzzed_contracts, created);
396
397        if !had_accepted {
398            stats.skipped_entries += 1;
399            continue;
400        }
401        stats.corpus_entries += 1;
402        if !stats.sancov_observed && san_buf.iter().any(|&x| x != 0) {
403            stats.sancov_observed = true;
404        }
405
406        if opts.emit_files && opts.per_input {
407            // <trial>__<uuid>-<ts>.txt
408            let stem = format!("{}__{}-{}", opts.trial, entry.uuid, entry.timestamp);
409            stats.showmap_files +=
410                write_showmap_file(&approach_dir.join(format!("{stem}.txt")), &evm_buf, &san_buf)?;
411            // Reset for the next entry; preserves capacity so we don't reallocate.
412            evm_buf.clear();
413            san_buf.fill(0);
414        }
415    }
416
417    if opts.emit_files && !opts.per_input {
418        // <trial>.txt
419        stats.showmap_files += write_showmap_file(
420            &approach_dir.join(format!("{}.txt", opts.trial)),
421            &evm_buf,
422            &san_buf,
423        )?;
424    }
425
426    if failed_entries > 0 {
427        return Err(eyre::eyre!(
428            "corpus replay failed:\n{}",
429            replay_failure_report(&replay_failures, failed_entries)
430        ));
431    }
432
433    Ok(stats)
434}
435
436pub struct MinimizationReplayInput<'a> {
437    pub sequence: &'a [BasicTxDetails],
438    pub evm_edge_indices: &'a mut EdgeIndexMap,
439    pub corpus: &'a FuzzCorpusConfig,
440}
441
442/// Replays one candidate input and returns coverage/failure facts for minimizers.
443pub fn replay_sequence_for_minimization<FEN: FoundryEvmNetwork>(
444    executor: &Executor<FEN>,
445    input: MinimizationReplayInput<'_>,
446    target: ShowmapReplayTarget<'_>,
447) -> Result<ReplayObservation> {
448    let mut observation = ReplayObservation::default();
449    let mut executor = executor.clone();
450    executor.inspector_mut().collect_edge_coverage_with_config(input.corpus);
451    executor.inspector_mut().collect_sancov_edges(input.corpus.collect_sancov_edges());
452    executor.inspector_mut().collect_sancov_trace_cmp(input.corpus.collect_sancov_trace_cmp());
453
454    let mut created = Vec::new();
455    let mut accepted = 0usize;
456    let mut last_accepted_checked_invariant = false;
457    for tx in input.sequence {
458        if !WorkerCorpus::can_replay_tx(tx, target.stateless, target.fuzzed_contracts) {
459            observation.unmatched += 1;
460            continue;
461        }
462
463        let mut call_result = execute_tx(&mut executor, tx)?;
464        let target_addr = tx.call_details.target;
465        let selector =
466            tx.call_details.calldata.get(..4).map(Selector::from_slice).unwrap_or_default();
467        let fingerprint = snapshot_edge_fingerprint(&call_result);
468
469        if call_result.result.as_ref() == MAGIC_ASSUME
470            || (call_result.reverter == Some(CHEATCODE_ADDRESS)
471                && SkipReason::decode(&call_result.result).is_some())
472        {
473            observation.skipped += 1;
474            continue;
475        }
476
477        call_result.merge_all_coverage(
478            &mut observation.evm_edges,
479            input.evm_edge_indices,
480            &mut observation.sancov_edges,
481        );
482        observation.replayed += 1;
483
484        register_replay_created(
485            &call_result.state_changeset,
486            target.dynamic,
487            target.fuzzed_contracts,
488            &mut created,
489        );
490
491        if target.fuzzed_contracts.is_some() {
492            accepted += 1;
493            last_accepted_checked_invariant = false;
494            if let Some(failure) = invariant_handler_failure(
495                target_addr,
496                selector,
497                did_fail_on_assert(&call_result, &call_result.state_changeset),
498                target.invariant_fns,
499                &call_result,
500                fingerprint,
501            ) {
502                let terminal = failure.is_terminal();
503                record_replay_failure(&mut observation.failure, failure);
504                if terminal {
505                    break;
506                }
507            }
508            executor.commit(&mut call_result);
509            if should_check_invariant(
510                accepted,
511                target.invariant_replay.check_interval,
512                target.invariant_replay.is_optimization,
513            ) {
514                last_accepted_checked_invariant = true;
515                if !target.invariant_replay.is_optimization
516                    && !observation.failure.as_ref().is_some_and(ReplayFailure::is_predicate)
517                    && let Some(address) = target.invariant_address
518                    && let Some(failure) =
519                        broken_invariant(&executor, address, target.invariant_fns)?
520                {
521                    record_replay_failure(&mut observation.failure, failure);
522                }
523            }
524        } else if !fuzz_replay_call_succeeded(
525            &executor,
526            target_addr,
527            &mut call_result,
528            target.fuzz_fail_on_revert,
529        ) {
530            record_replay_failure(
531                &mut observation.failure,
532                ReplayFailure::Fuzz {
533                    selector,
534                    fingerprint,
535                    output: keccak256(call_result.result.as_ref()),
536                },
537            );
538            break;
539        }
540
541        if observation.failure.as_ref().is_some_and(ReplayFailure::is_terminal) {
542            break;
543        }
544    }
545
546    if !observation.failure.as_ref().is_some_and(ReplayFailure::is_terminal)
547        && accepted > 0
548        && target.fuzzed_contracts.is_some()
549        && let Some(address) = target.invariant_address
550    {
551        if !target.invariant_replay.is_optimization
552            && !last_accepted_checked_invariant
553            && let Some(failure) = broken_invariant(&executor, address, target.invariant_fns)?
554        {
555            record_replay_failure(&mut observation.failure, failure);
556        } else if target.invariant_replay.call_after_invariant
557            && let Some(failure) = broken_after_invariant(&executor, address)?
558        {
559            record_replay_failure(&mut observation.failure, failure);
560        }
561    }
562
563    rollback_replay_created(target.fuzzed_contracts, created);
564    Ok(observation)
565}
566
567fn replay_failure_report(replay_failures: &[String], failed_entries: usize) -> String {
568    let mut report = replay_failures.join("\n");
569    let unreported = failed_entries.saturating_sub(replay_failures.len());
570    if unreported > 0 {
571        if !report.is_empty() {
572            report.push('\n');
573        }
574        report.push_str(&format!("... and {unreported} more failed corpus entries"));
575    }
576    report
577}
578
579/// Returns a [`ReplayFailure::Handler`] if a handler call should be treated as a
580/// bug, mirroring the campaign: assertion failures always count, plain reverts only
581/// count under `fail_on_revert` and are never counted for `vm.assume` rejects.
582fn invariant_handler_failure<FEN: FoundryEvmNetwork>(
583    target: Address,
584    selector: Selector,
585    assertion_failure: bool,
586    invariant_fns: &[(&Function, bool)],
587    call_result: &crate::executors::RawCallResult<FEN>,
588    fingerprint: Option<B256>,
589) -> Option<ReplayFailure> {
590    let fail_on_revert_invariant = invariant_fns
591        .iter()
592        .find_map(|(invariant, fail_on_revert)| (*fail_on_revert).then_some(invariant));
593    let fail_on_revert_failure = fail_on_revert_invariant.is_some()
594        && call_result.reverted
595        && call_result.result.as_ref() != MAGIC_ASSUME;
596    let failed = assertion_failure || fail_on_revert_failure;
597    failed.then_some(ReplayFailure::Handler {
598        target,
599        selector,
600        fingerprint,
601        terminal: fail_on_revert_failure && !assertion_failure,
602        invariant: fail_on_revert_failure.then(|| fail_on_revert_invariant.unwrap().name.clone()),
603    })
604}
605
606fn fuzz_replay_call_succeeded<FEN: FoundryEvmNetwork>(
607    executor: &Executor<FEN>,
608    target_addr: Address,
609    call_result: &mut crate::executors::RawCallResult<FEN>,
610    fail_on_revert: bool,
611) -> bool {
612    if !fail_on_revert
613        && call_result
614            .reverter
615            .is_some_and(|reverter| reverter != target_addr && reverter != CHEATCODE_ADDRESS)
616    {
617        true
618    } else {
619        executor.is_raw_call_mut_success(target_addr, call_result, false)
620    }
621}
622
623fn broken_invariant<FEN: FoundryEvmNetwork>(
624    executor: &Executor<FEN>,
625    invariant_address: Address,
626    invariant_fns: &[(&Function, bool)],
627) -> Result<Option<ReplayFailure>> {
628    for (invariant, _) in invariant_fns {
629        let (_, success) = call_invariant_function(
630            executor,
631            invariant_address,
632            invariant.abi_encode_input(&[])?.into(),
633        )?;
634        if !success {
635            return Ok(Some(ReplayFailure::Invariant { name: invariant.name.clone() }));
636        }
637    }
638    Ok(None)
639}
640
641fn broken_after_invariant<FEN: FoundryEvmNetwork>(
642    executor: &Executor<FEN>,
643    invariant_address: Address,
644) -> Result<Option<ReplayFailure>> {
645    let (_, success) = call_after_invariant_function(executor, invariant_address)?;
646    Ok((!success).then_some(ReplayFailure::AfterInvariant))
647}
648
649/// Whether the invariant predicate should be evaluated after the `accepted`-th
650/// committed (non-`vm.assume`) call.
651///
652/// Mirrors the campaign: with `check_interval == 0` only the final call is checked
653/// (callers additionally perform a final check after the sequence ends); with
654/// `check_interval == 1` every call is checked; otherwise every N-th call.
655fn should_check_invariant(accepted: usize, check_interval: u32, is_optimization: bool) -> bool {
656    debug_assert!(accepted > 0);
657    is_optimization
658        || check_interval == 1
659        || (check_interval > 1 && accepted.is_multiple_of(check_interval as usize))
660}
661
662/// Saturating-add per-(bytecode, pc) hits from a `HitMaps` snapshot into `dst`.
663fn accumulate_evm(dst: &mut EvmShowmap, src: Option<&HitMaps>) {
664    let Some(maps) = src else { return };
665    for (hash, hitmap) in maps.iter() {
666        for (pc, hits) in hitmap.iter() {
667            let slot = dst.entry((*hash, pc)).or_default();
668            *slot = slot.saturating_add(hits as u64);
669        }
670    }
671}
672
673/// Saturating-add `src` (u8 raw counts) into `dst` (u64 aggregated counts).
674fn accumulate_sancov(dst: &mut Vec<u64>, src: Option<&[u8]>) {
675    let Some(src) = src else { return };
676    if dst.len() < src.len() {
677        dst.resize(src.len(), 0);
678    }
679    for (d, &s) in dst.iter_mut().zip(src) {
680        if s != 0 {
681            *d = d.saturating_add(s as u64);
682        }
683    }
684}
685
686/// Write a single showmap file. Returns 1 if a file was written, 0 if skipped
687/// (no nonzero entries).
688fn write_showmap_file(path: &Path, evm: &EvmShowmap, san: &[u64]) -> Result<usize> {
689    // Pre-check so we don't create empty files.
690    let has_evm = evm.values().any(|&c| c != 0);
691    let has_san = san.iter().any(|&c| c != 0);
692    if !has_evm && !has_san {
693        return Ok(0);
694    }
695    let mut w = BufWriter::new(File::create_new(path).map_err(|err| {
696        eyre::eyre!(
697            "failed to create showmap file {}: {err}; pick a different --showmap-trial or remove \
698             the existing file",
699            path.display()
700        )
701    })?);
702    write_evm(&mut w, evm)?;
703    write_sancov(&mut w, san)?;
704    w.flush()?;
705    Ok(1)
706}
707
708/// Each EVM ID is `evm_<bytecode_hash[:16hex]>_<pc:04x>`. The 16-hex prefix
709/// (64 bits) of the keccak256 bytecode hash makes IDs deterministic across
710/// processes while keeping line lengths short.
711fn write_evm<W: Write>(out: &mut W, evm: &EvmShowmap) -> std::io::Result<()> {
712    let mut entries = evm.iter().filter(|(_, count)| **count != 0).collect::<Vec<_>>();
713    entries.sort_unstable_by_key(|((hash, pc), _)| (*hash, *pc));
714
715    for ((hash, pc), count) in entries {
716        let h = hex::encode(&hash.as_slice()[..8]);
717        writeln!(out, "evm_{h}_{pc:04x}:{count}")?;
718    }
719    Ok(())
720}
721
722fn write_sancov<W: Write>(out: &mut W, bitmap: &[u64]) -> std::io::Result<()> {
723    for (idx, &count) in bitmap.iter().enumerate() {
724        if count != 0 {
725            // Underscore (not `:`) between prefix and id keeps the showmap
726            // `<id>:<count>` parser unambiguous.
727            writeln!(out, "sancov_0x{idx:04x}:{count}")?;
728        }
729    }
730    Ok(())
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use crate::executors::{RawCallResult, corpus_io::canonical_replay_dirs};
737    use foundry_evm_core::evm::EthEvmNetwork;
738    use revm::interpreter::InstructionResult;
739    use uuid::Uuid;
740
741    fn temp_dir() -> PathBuf {
742        let dir = std::env::temp_dir().join(format!("foundry-showmap-{}", Uuid::new_v4()));
743        std::fs::create_dir_all(&dir).unwrap();
744        dir
745    }
746
747    #[test]
748    fn accumulate_sancov_resizes_and_saturating_adds() {
749        let mut dst: Vec<u64> = vec![10];
750        accumulate_sancov(&mut dst, Some(&[1u8, 2, 3]));
751        assert_eq!(dst, vec![11, 2, 3]);
752    }
753
754    #[test]
755    fn write_evm_emits_only_nonzero_deterministic_ids() {
756        let mut buf: Vec<u8> = Vec::new();
757        let h = B256::with_last_byte(0xab);
758        let mut evm = EvmShowmap::new();
759        evm.insert((h, 1u32), 0u64); // skipped (count=0)
760        evm.insert((h, 0x2au32), 3u64);
761        write_evm(&mut buf, &evm).unwrap();
762        let h_hex = hex::encode(&h.as_slice()[..8]);
763        assert_eq!(String::from_utf8(buf).unwrap(), format!("evm_{h_hex}_002a:3\n"));
764    }
765
766    #[test]
767    fn write_sancov_emits_only_nonzero_hex_ids() {
768        let mut buf: Vec<u8> = Vec::new();
769        write_sancov(&mut buf, &[0, 3, 0, 1]).unwrap();
770        assert_eq!(String::from_utf8(buf).unwrap(), "sancov_0x0001:3\nsancov_0x0003:1\n");
771    }
772
773    #[test]
774    fn write_showmap_file_skips_when_empty() {
775        let dir = temp_dir();
776        let path = dir.join("trial.txt");
777        let written = write_showmap_file(&path, &EvmShowmap::new(), &[]).unwrap();
778        assert_eq!(written, 0);
779        assert!(!path.exists());
780    }
781
782    #[test]
783    fn write_showmap_file_writes_combined_domains() {
784        let dir = temp_dir();
785        let path = dir.join("trial.txt");
786        let h = B256::with_last_byte(0xff);
787        let mut evm = EvmShowmap::new();
788        evm.insert((h, 7u32), 5u64);
789        let written = write_showmap_file(&path, &evm, &[2]).unwrap();
790        assert_eq!(written, 1);
791        let body = std::fs::read_to_string(&path).unwrap();
792        let h_hex = hex::encode(&h.as_slice()[..8]);
793        assert_eq!(body, format!("evm_{h_hex}_0007:5\nsancov_0x0000:2\n"));
794    }
795
796    #[test]
797    fn write_showmap_file_does_not_overwrite_existing_file() {
798        let dir = temp_dir();
799        let path = dir.join("trial.txt");
800        std::fs::write(&path, "keep me").unwrap();
801        let h = B256::with_last_byte(0xff);
802        let mut evm = EvmShowmap::new();
803        evm.insert((h, 7u32), 5u64);
804        let err = write_showmap_file(&path, &evm, &[]).unwrap_err();
805        assert!(err.to_string().contains("pick a different --showmap-trial"), "{err:?}");
806        assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me");
807    }
808
809    #[test]
810    fn replay_failure_report_caps_details() {
811        let failures = (0..MAX_REPORTED_REPLAY_FAILURES)
812            .map(|idx| format!("corpus entry {idx} failed during replay: fuzz call failed"))
813            .collect::<Vec<_>>();
814        let report = replay_failure_report(&failures, MAX_REPORTED_REPLAY_FAILURES + 3);
815
816        assert!(report.contains("corpus entry 0 failed during replay"), "{report}");
817        assert!(report.contains("corpus entry 19 failed during replay"), "{report}");
818        assert!(report.contains("... and 3 more failed corpus entries"), "{report}");
819        assert!(!report.contains("corpus entry 20 failed during replay"), "{report}");
820    }
821
822    #[test]
823    fn should_check_invariant_matches_campaign_intervals() {
824        // check_interval == 1: every accepted call.
825        assert!(should_check_invariant(1, 1, false));
826        assert!(should_check_invariant(2, 1, false));
827        // check_interval == 0: never inline (callers do a final check instead).
828        assert!(!should_check_invariant(1, 0, false));
829        assert!(!should_check_invariant(5, 0, false));
830        // check_interval == N: every N-th accepted call.
831        assert!(!should_check_invariant(1, 3, false));
832        assert!(!should_check_invariant(2, 3, false));
833        assert!(should_check_invariant(3, 3, false));
834        assert!(should_check_invariant(6, 3, false));
835        // Optimization mode evaluates every prefix to track the best value.
836        assert!(should_check_invariant(1, 0, true));
837    }
838
839    #[test]
840    fn invariant_handler_failure_ignores_plain_revert() {
841        let call_result = RawCallResult::<EthEvmNetwork> {
842            reverted: true,
843            exit_reason: Some(InstructionResult::Revert),
844            ..Default::default()
845        };
846        let failure = invariant_handler_failure(
847            Address::with_last_byte(1),
848            Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
849            did_fail_on_assert(&call_result, &call_result.state_changeset),
850            &[],
851            &call_result,
852            None,
853        );
854        assert_eq!(failure, None);
855    }
856
857    #[test]
858    fn invariant_handler_failure_reports_fail_on_revert() {
859        let call_result = RawCallResult::<EthEvmNetwork> {
860            reverted: true,
861            exit_reason: Some(InstructionResult::Revert),
862            ..Default::default()
863        };
864        let invariant = serde_json::from_value::<Function>(serde_json::json!({
865            "type": "function",
866            "name": "invariant_ok",
867            "inputs": [],
868            "outputs": [],
869            "stateMutability": "view"
870        }))
871        .unwrap();
872        let failure = invariant_handler_failure(
873            Address::with_last_byte(1),
874            Selector::from([0xaa, 0xbb, 0xcc, 0xdd]),
875            did_fail_on_assert(&call_result, &call_result.state_changeset),
876            &[(&invariant, true)],
877            &call_result,
878            None,
879        );
880        assert!(matches!(
881            failure,
882            Some(ReplayFailure::Handler {
883                terminal: true,
884                invariant: Some(name),
885                ..
886            }) if name == "invariant_ok"
887        ));
888    }
889
890    #[test]
891    fn canonical_replay_dirs_collects_all_workers() {
892        let dir = temp_dir();
893        let w0 = dir.join("worker0").join("corpus");
894        let w1 = dir.join("worker1").join("corpus");
895        std::fs::create_dir_all(&w0).unwrap();
896        std::fs::create_dir_all(&w1).unwrap();
897        assert_eq!(canonical_replay_dirs(&dir), vec![w0, w1]);
898    }
899
900    #[test]
901    fn canonical_replay_dirs_falls_back_when_no_workers() {
902        let dir = temp_dir();
903        assert_eq!(canonical_replay_dirs(&dir), vec![dir]);
904    }
905}