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::executors::{
16    Executor,
17    corpus::{DynamicTargetCtx, WorkerCorpus, register_replay_created, rollback_replay_created},
18    corpus_io::{canonical_replay_dirs, read_corpus_dir},
19    invariant::execute_tx,
20};
21use alloy_json_abi::Function;
22use alloy_primitives::{Address, B256, hex};
23use eyre::{Result, eyre};
24use foundry_evm_core::evm::FoundryEvmNetwork;
25use foundry_evm_coverage::HitMaps;
26use foundry_evm_fuzz::invariant::FuzzRunIdentifiedContracts;
27use std::{
28    collections::{BTreeMap, HashSet},
29    fmt,
30    fs::File,
31    io::{BufWriter, Write},
32    path::{Path, PathBuf},
33};
34use uuid::Uuid;
35
36/// Which coverage bitmap(s) to dump.
37#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
38pub enum ShowmapDomain {
39    #[default]
40    Evm,
41    Sancov,
42    Both,
43}
44
45impl ShowmapDomain {
46    pub const fn includes_evm(self) -> bool {
47        matches!(self, Self::Evm | Self::Both)
48    }
49    pub const fn includes_sancov(self) -> bool {
50        matches!(self, Self::Sancov | Self::Both)
51    }
52}
53
54impl fmt::Display for ShowmapDomain {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::Evm => f.write_str("evm"),
58            Self::Sancov => f.write_str("sancov"),
59            Self::Both => f.write_str("both"),
60        }
61    }
62}
63
64/// Per-replay options.
65#[derive(Clone, Debug)]
66pub struct ShowmapOpts {
67    /// Output root directory; emitted files live under `<out_dir>/<approach>/`.
68    pub out_dir: PathBuf,
69    /// Approach directory name; test identity is folded in here so each
70    /// `<approach>/` contains trials of one test (matches `differential-coverage`).
71    pub approach: String,
72    /// Rerun identifier used as the filename so multiple trials accumulate side-by-side.
73    pub trial: String,
74    /// Whether to emit one file per corpus entry or one aggregated file.
75    pub per_input: bool,
76    /// Which bitmap(s) to dump.
77    pub domain: ShowmapDomain,
78}
79
80/// Stats returned from a single trial replay.
81#[derive(Clone, Debug, Default)]
82pub struct ShowmapStats {
83    /// Number of corpus entries successfully replayed.
84    pub corpus_entries: usize,
85    /// Number of files written to disk.
86    pub showmap_files: usize,
87    /// Number of corpus entries skipped because they couldn't be replayed
88    /// against the current target (e.g. selector mismatch).
89    pub skipped_entries: usize,
90    /// True if sancov coverage was requested. Lets the caller distinguish
91    /// "sancov not asked for" from "sancov asked for but produced nothing".
92    pub sancov_requested: bool,
93    /// True if any non-zero sancov hits were observed across the replay.
94    pub sancov_observed: bool,
95}
96
97/// Replay every corpus entry under `corpus_dir` and emit showmap files.
98///
99/// `fuzzed_function` is set for stateless fuzz tests; `fuzzed_contracts` is set
100/// for invariant tests (txs are committed between calls in that case).
101/// `dynamic` lets invariant replay register contracts deployed mid-sequence so
102/// follow-up calls into them aren't dropped.
103pub fn replay_corpus_to_showmap<FEN: FoundryEvmNetwork>(
104    executor: &Executor<FEN>,
105    corpus_dir: &Path,
106    fuzzed_function: Option<&Function>,
107    fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
108    dynamic: Option<&DynamicTargetCtx<'_>>,
109    opts: &ShowmapOpts,
110) -> Result<ShowmapStats> {
111    let replay_dirs = canonical_replay_dirs(corpus_dir);
112    if !replay_dirs.iter().any(|d| d.is_dir()) {
113        return Err(eyre!("corpus directory not found: {}", corpus_dir.display()));
114    }
115
116    let approach_dir = opts.out_dir.join(&opts.approach);
117    foundry_common::fs::create_dir_all(&approach_dir)?;
118
119    let mut stats =
120        ShowmapStats { sancov_requested: opts.domain.includes_sancov(), ..Default::default() };
121    // Reused per call. In aggregate mode it accumulates across all entries; in per-input mode it
122    // is cleared after each entry's file is written.
123    let mut evm_buf: BTreeMap<(B256, u32), u64> = BTreeMap::new();
124    let mut san_buf: Vec<u64> = Vec::new();
125
126    // Dedup hard-linked entries shared across workers.
127    let mut seen_uuids: HashSet<Uuid> = HashSet::new();
128    let entries =
129        replay_dirs.iter().flat_map(|d| read_corpus_dir(d)).filter(|e| seen_uuids.insert(e.uuid));
130
131    for entry in entries {
132        let tx_seq = match entry.read_tx_seq() {
133            Ok(seq) if !seq.is_empty() => seq,
134            Ok(_) => continue,
135            Err(err) => {
136                debug!(target: "showmap", %err, ?entry.path, "failed to read corpus entry");
137                continue;
138            }
139        };
140
141        let mut had_replayable = false;
142        let mut executor = executor.clone();
143        // Targets deployed during this entry, cleared after the entry.
144        let mut created: Vec<Address> = Vec::new();
145        for tx in &tx_seq {
146            if !WorkerCorpus::can_replay_tx(tx, fuzzed_function, fuzzed_contracts) {
147                continue;
148            }
149            had_replayable = true;
150
151            let mut call_result = execute_tx(&mut executor, tx)?;
152            // Coverage-collection asymmetry across calls within a stateful sequence:
153            // - line_coverage is per-call: `Executor::call_raw` returns a fresh HitMap each time,
154            //   so we can simply accumulate it.
155            // - sancov_coverage is the inspector's shared `Vec<u8>` buffer that keeps growing
156            //   across calls, so after consuming it we zero it out to avoid double-counting on the
157            //   next iteration.
158            if opts.domain.includes_evm() {
159                accumulate_evm(&mut evm_buf, call_result.line_coverage.as_ref());
160            }
161            if opts.domain.includes_sancov() {
162                accumulate_sancov(&mut san_buf, call_result.sancov_coverage.as_deref());
163                if let Some(buf) = call_result.sancov_coverage.as_mut() {
164                    buf.fill(0);
165                }
166            }
167
168            register_replay_created(
169                &call_result.state_changeset,
170                dynamic,
171                fuzzed_contracts,
172                &mut created,
173            );
174
175            // Stateful tests need the tx committed so subsequent calls see its effects.
176            if fuzzed_contracts.is_some() {
177                executor.commit(&mut call_result);
178            }
179        }
180        rollback_replay_created(fuzzed_contracts, created);
181
182        if !had_replayable {
183            stats.skipped_entries += 1;
184            continue;
185        }
186        stats.corpus_entries += 1;
187        if !stats.sancov_observed && san_buf.iter().any(|&x| x != 0) {
188            stats.sancov_observed = true;
189        }
190
191        if opts.per_input {
192            // <trial>__<uuid>-<ts>.txt
193            let stem = format!("{}__{}-{}", opts.trial, entry.uuid, entry.timestamp);
194            stats.showmap_files +=
195                write_showmap_file(&approach_dir.join(format!("{stem}.txt")), &evm_buf, &san_buf)?;
196            // Reset for the next entry; preserves capacity so we don't reallocate.
197            evm_buf.clear();
198            san_buf.fill(0);
199        }
200    }
201
202    if !opts.per_input {
203        // <trial>.txt
204        stats.showmap_files += write_showmap_file(
205            &approach_dir.join(format!("{}.txt", opts.trial)),
206            &evm_buf,
207            &san_buf,
208        )?;
209    }
210
211    Ok(stats)
212}
213
214/// Saturating-add per-(bytecode, pc) hits from a `HitMaps` snapshot into `dst`.
215fn accumulate_evm(dst: &mut BTreeMap<(B256, u32), u64>, src: Option<&HitMaps>) {
216    let Some(maps) = src else { return };
217    for (hash, hitmap) in maps.iter() {
218        for (pc, hits) in hitmap.iter() {
219            let slot = dst.entry((*hash, pc)).or_default();
220            *slot = slot.saturating_add(hits as u64);
221        }
222    }
223}
224
225/// Saturating-add `src` (u8 raw counts) into `dst` (u64 aggregated counts).
226fn accumulate_sancov(dst: &mut Vec<u64>, src: Option<&[u8]>) {
227    let Some(src) = src else { return };
228    if dst.len() < src.len() {
229        dst.resize(src.len(), 0);
230    }
231    for (d, &s) in dst.iter_mut().zip(src) {
232        if s != 0 {
233            *d = d.saturating_add(s as u64);
234        }
235    }
236}
237
238/// Write a single showmap file. Returns 1 if a file was written, 0 if skipped
239/// (no nonzero entries).
240fn write_showmap_file(path: &Path, evm: &BTreeMap<(B256, u32), u64>, san: &[u64]) -> Result<usize> {
241    // Pre-check so we don't create empty files.
242    let has_evm = evm.values().any(|&c| c != 0);
243    let has_san = san.iter().any(|&c| c != 0);
244    if !has_evm && !has_san {
245        return Ok(0);
246    }
247    let mut w = BufWriter::new(File::create(path)?);
248    write_evm(&mut w, evm)?;
249    write_sancov(&mut w, san)?;
250    w.flush()?;
251    Ok(1)
252}
253
254/// Each EVM ID is `evm_<bytecode_hash[:16hex]>_<pc:04x>`. The 16-hex prefix
255/// (64 bits) of the keccak256 bytecode hash makes IDs deterministic across
256/// processes while keeping line lengths short.
257fn write_evm<W: Write>(out: &mut W, evm: &BTreeMap<(B256, u32), u64>) -> std::io::Result<()> {
258    for ((hash, pc), count) in evm {
259        if *count == 0 {
260            continue;
261        }
262        let h = hex::encode(&hash.as_slice()[..8]);
263        writeln!(out, "evm_{h}_{pc:04x}:{count}")?;
264    }
265    Ok(())
266}
267
268fn write_sancov<W: Write>(out: &mut W, bitmap: &[u64]) -> std::io::Result<()> {
269    for (idx, &count) in bitmap.iter().enumerate() {
270        if count != 0 {
271            // Underscore (not `:`) between prefix and id keeps the showmap
272            // `<id>:<count>` parser unambiguous.
273            writeln!(out, "sancov_0x{idx:04x}:{count}")?;
274        }
275    }
276    Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn temp_dir() -> PathBuf {
284        let dir = std::env::temp_dir().join(format!("foundry-showmap-{}", Uuid::new_v4()));
285        std::fs::create_dir_all(&dir).unwrap();
286        dir
287    }
288
289    #[test]
290    fn accumulate_sancov_resizes_and_saturating_adds() {
291        let mut dst: Vec<u64> = vec![10];
292        accumulate_sancov(&mut dst, Some(&[1u8, 2, 3]));
293        assert_eq!(dst, vec![11, 2, 3]);
294    }
295
296    #[test]
297    fn write_evm_emits_only_nonzero_deterministic_ids() {
298        let mut buf: Vec<u8> = Vec::new();
299        let h = B256::with_last_byte(0xab);
300        let mut evm = BTreeMap::new();
301        evm.insert((h, 1u32), 0u64); // skipped (count=0)
302        evm.insert((h, 0x2au32), 3u64);
303        write_evm(&mut buf, &evm).unwrap();
304        let h_hex = hex::encode(&h.as_slice()[..8]);
305        assert_eq!(String::from_utf8(buf).unwrap(), format!("evm_{h_hex}_002a:3\n"));
306    }
307
308    #[test]
309    fn write_sancov_emits_only_nonzero_hex_ids() {
310        let mut buf: Vec<u8> = Vec::new();
311        write_sancov(&mut buf, &[0, 3, 0, 1]).unwrap();
312        assert_eq!(String::from_utf8(buf).unwrap(), "sancov_0x0001:3\nsancov_0x0003:1\n");
313    }
314
315    #[test]
316    fn write_showmap_file_skips_when_empty() {
317        let dir = temp_dir();
318        let path = dir.join("trial.txt");
319        let written = write_showmap_file(&path, &BTreeMap::new(), &[]).unwrap();
320        assert_eq!(written, 0);
321        assert!(!path.exists());
322    }
323
324    #[test]
325    fn write_showmap_file_writes_combined_domains() {
326        let dir = temp_dir();
327        let path = dir.join("trial.txt");
328        let h = B256::with_last_byte(0xff);
329        let mut evm = BTreeMap::new();
330        evm.insert((h, 7u32), 5u64);
331        let written = write_showmap_file(&path, &evm, &[2]).unwrap();
332        assert_eq!(written, 1);
333        let body = std::fs::read_to_string(&path).unwrap();
334        let h_hex = hex::encode(&h.as_slice()[..8]);
335        assert_eq!(body, format!("evm_{h_hex}_0007:5\nsancov_0x0000:2\n"));
336    }
337
338    #[test]
339    fn canonical_replay_dirs_collects_all_workers() {
340        let dir = temp_dir();
341        let w0 = dir.join("worker0").join("corpus");
342        let w1 = dir.join("worker1").join("corpus");
343        std::fs::create_dir_all(&w0).unwrap();
344        std::fs::create_dir_all(&w1).unwrap();
345        assert_eq!(canonical_replay_dirs(&dir), vec![w0, w1]);
346    }
347
348    #[test]
349    fn canonical_replay_dirs_falls_back_when_no_workers() {
350        let dir = temp_dir();
351        assert_eq!(canonical_replay_dirs(&dir), vec![dir]);
352    }
353}