Skip to content
5 changes: 5 additions & 0 deletions .changelog/overloaded-fuzz-test-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
forge: patch
---

Persist and replay failures for overloaded fuzz tests independently.
8 changes: 6 additions & 2 deletions crates/evm/evm/src/executors/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,14 +434,15 @@ impl WorkerCorpusSeed {

pub(crate) fn load_from_disk<FEN: FoundryEvmNetwork>(
config: &FuzzCorpusConfig,
replay_root: Option<&Path>,
executor: Option<&Executor<FEN>>,
target: ReplayTarget<'_>,
) -> Result<Self> {
let mut seed = Self::empty(config).with_optimization_state(config);
let Some(corpus_dir) = &config.corpus_dir else {
return Ok(seed);
};
let replay_dirs = canonical_replay_dirs(corpus_dir);
let replay_dirs = canonical_replay_dirs(replay_root.unwrap_or(corpus_dir));
seed.replay_dirs = Some(replay_dirs.clone());

// Seed in-memory corpus with the persisted optimization best sequence so the mutation
Expand Down Expand Up @@ -864,12 +865,13 @@ impl WorkerCorpus {
id: usize,
config: FuzzCorpusConfig,
sequence_generator: SequenceGenerator,
replay_root: Option<&Path>,
// Only required by master worker (id = 0) to replay existing corpus.
executor: Option<&Executor<FEN>>,
target: ReplayTarget<'_>,
) -> Result<Self> {
let seed = if id == 0 {
WorkerCorpusSeed::load_from_disk(&config, executor, target)?
WorkerCorpusSeed::load_from_disk(&config, replay_root, executor, target)?
} else {
WorkerCorpusSeed::empty(&config).with_optimization_state(&config)
};
Expand Down Expand Up @@ -2321,6 +2323,7 @@ mod tests {
let seed = WorkerCorpusSeed::load_from_disk::<foundry_evm_core::evm::EthEvmNetwork>(
&config,
None,
None,
ReplayTarget { stateless: None, fuzzed_contracts: None, dynamic: None },
)
.unwrap();
Expand Down Expand Up @@ -2553,6 +2556,7 @@ mod tests {
config,
generator,
None,
None,
ReplayTarget { stateless: None, fuzzed_contracts: None, dynamic: None },
)
.unwrap();
Expand Down
15 changes: 14 additions & 1 deletion crates/evm/evm/src/executors/fuzz/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ use proptest::test_runner::{RngAlgorithm, TestCaseError, TestRng, TestRunner};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde_json::json;
use std::{
path::PathBuf,
sync::{
Arc, OnceLock,
atomic::{AtomicU32, Ordering},
Expand Down Expand Up @@ -199,6 +200,8 @@ pub struct FuzzedExecutor<FEN: FoundryEvmNetwork> {
config: FuzzConfig,
/// The persisted counterexample to be replayed, if any.
persisted_failure: Option<BaseCounterExample>,
/// An existing corpus to replay before persisting into the configured corpus directory.
corpus_replay_dir: Option<PathBuf>,
/// The number of parallel workers.
num_workers: usize,
}
Expand All @@ -211,6 +214,7 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
sender: Address,
config: FuzzConfig,
persisted_failure: Option<BaseCounterExample>,
corpus_replay_dir: Option<PathBuf>,
) -> Self {
let run_limit = if config.run.is_some() { 1 } else { config.runs };
let max_workers = if run_limit == 0 {
Expand All @@ -221,7 +225,15 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
Ord::max(1, run_limit / MIN_RUNS_PER_WORKER)
};
let num_workers = Ord::min(rayon::current_num_threads(), max_workers as usize);
Self { executor_f: executor, runner, sender, config, persisted_failure, num_workers }
Self {
executor_f: executor,
runner,
sender,
config,
persisted_failure,
corpus_replay_dir,
num_workers,
}
}

/// Fuzzes the provided function, assuming it is available at the contract at `address`
Expand Down Expand Up @@ -680,6 +692,7 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
worker_id,
self.config.corpus.clone(),
generator,
self.corpus_replay_dir.as_deref(),
// Master worker replays the persisted corpus using the executor
(worker_id == 0).then_some(&self.executor_f),
replay_target,
Expand Down
1 change: 1 addition & 0 deletions crates/evm/evm/src/executors/invariant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
let dynamic = self.dynamic_target_ctx();
let corpus_seed = WorkerCorpusSeed::load_from_disk(
&self.config.corpus,
None,
Some(&corpus_replay_executor),
ReplayTarget {
stateless: None,
Expand Down
116 changes: 107 additions & 9 deletions crates/forge/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2185,21 +2185,30 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {
}

let mut fuzz_config = self.config.fuzz.clone();
let test_name =
fuzz_test_path_name(&self.cr.contract.abi, func, &fuzz_config, self.cr.name);
let legacy_corpus_dir = legacy_fuzz_corpus_dir(
fuzz_config.corpus.corpus_dir.as_deref(),
self.cr.name,
func,
&test_name,
);
let _ = test_paths(
&mut fuzz_config.corpus,
fuzz_config.failure_persist_dir.clone().unwrap(),
self.cr.name,
&func.name,
&test_name,
);
let corpus_dir = legacy_corpus_dir.or_else(|| fuzz_config.corpus.corpus_dir.clone());
let limit = self.config.symbolic.corpus_seed_limit;
let mut metadata = SymbolicCorpusSeedMetadata {
corpus_dir: fuzz_config.corpus.corpus_dir.clone(),
corpus_dir: corpus_dir.clone(),
limit,
loaded: 0,
skipped: 0,
used: Vec::new(),
};
let Some(corpus_dir) = fuzz_config.corpus.corpus_dir.clone() else {
let Some(corpus_dir) = corpus_dir else {
let _ = sh_warn!(
"`--symbolic-use-fuzz-corpus` requires `--fuzz-corpus-dir` or `fuzz.corpus_dir`; \
running without imported corpus seeds"
Expand Down Expand Up @@ -3593,6 +3602,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {
let invariant_address = self.address;
return self.run_showmap(
func,
&func.name,
corpus_dir,
&showmap,
ShowmapReplayTarget {
Expand Down Expand Up @@ -4890,11 +4900,19 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {

let runner = self.fuzz_runner();
let mut fuzz_config = self.config.fuzz.clone();
let test_name =
fuzz_test_path_name(&self.cr.contract.abi, func, &fuzz_config, self.cr.name);
let legacy_corpus_dir = legacy_fuzz_corpus_dir(
fuzz_config.corpus.corpus_dir.as_deref(),
self.cr.name,
func,
&test_name,
);
let (failure_dir, failure_file) = test_paths(
&mut fuzz_config.corpus,
fuzz_config.failure_persist_dir.clone().unwrap(),
self.cr.name,
&func.name,
&test_name,
);
let fuzz_input = self.cr.mcr.tcfg.fuzz_input.as_ref();
let is_explicit_target = fuzz_input
Expand All @@ -4913,12 +4931,14 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {
.corpus_dir
.clone()
.map(|corpus_dir| {
narrow_generated_fuzz_corpus_root(corpus_dir, self.cr.name, &func.name)
narrow_generated_fuzz_corpus_root(corpus_dir, self.cr.name, &test_name)
})
.or(legacy_corpus_dir)
.or_else(|| fuzz_config.corpus.corpus_dir.clone());
let fuzzed_address = self.address;
return self.run_showmap(
func,
&test_name,
corpus_dir,
&showmap,
ShowmapReplayTarget {
Expand Down Expand Up @@ -4993,7 +5013,23 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {
let persisted_failure = if is_explicit_target {
fuzz_input.map(|input| input.failure.as_ref().clone())
} else {
foundry_common::fs::read_json_file::<BaseCounterExample>(failure_file.as_path()).ok()
foundry_common::fs::read_json_file::<BaseCounterExample>(failure_file.as_path())
.ok()
.or_else(|| {
if test_name.as_ref() == func.name {
return None;
}
let legacy_file = canonicalized(failure_dir.join(&func.name));
let failure = foundry_common::fs::read_json_file::<BaseCounterExample>(
legacy_file.as_path(),
)
.ok()?;
failure
.calldata
.get(..4)
.is_some_and(|selector| func.selector() == selector)
.then_some(failure)
})
};
if self.cr.mcr.tcfg.fuzz_failure_replay {
let Some(failure) = persisted_failure.as_ref() else {
Expand Down Expand Up @@ -5046,8 +5082,14 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {
.inspector_mut()
.collect_sancov_trace_cmp(fuzz_config.corpus.collect_sancov_trace_cmp());
// Run fuzz test.
let mut fuzzed_executor =
FuzzedExecutor::new(executor, runner, self.tcfg.sender, fuzz_config, persisted_failure);
let mut fuzzed_executor = FuzzedExecutor::new(
executor,
runner,
self.tcfg.sender,
fuzz_config,
persisted_failure,
legacy_corpus_dir,
);
if self.cr.mcr.tcfg.fuzz_failure_replay {
let result = match fuzzed_executor.replay_persisted_failure(
func,
Expand Down Expand Up @@ -5153,6 +5195,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {
fn run_showmap(
mut self,
func: &Function,
test_name: &str,
corpus_dir: Option<PathBuf>,
showmap: &crate::multi_runner::ShowmapConfig,
target: ShowmapReplayTarget<'_>,
Expand Down Expand Up @@ -5180,7 +5223,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> {
// (which `File::create_new` would reject). Distinct anchors sharing one
// corpus simply produce equivalent, separately-named approach dirs.
let safe_id = self.cr.name.replace(['/', '\\', ':'], "_");
let safe_fn = func.name.replace(['/', '\\', ':', '(', ')', ',', ' '], "_");
let safe_fn = test_name.replace(['/', '\\', ':', '(', ')', ',', ' '], "_");
let approach = format!("{}__{safe_id}__{safe_fn}", showmap.approach);
let opts = ShowmapOpts {
out_dir: showmap.out_dir.clone(),
Expand Down Expand Up @@ -5562,6 +5605,61 @@ fn frontier_filter_display<T: std::fmt::Display>(values: &[T]) -> String {
if values.is_empty() { "any".to_string() } else { values.iter().format(", ").to_string() }
}

/// Returns a stable path component that distinguishes overloaded fuzz tests.
fn fuzz_test_path_name<'a>(
abi: &JsonAbi,
func: &'a Function,
config: &FuzzConfig,
contract_name: &str,
) -> Cow<'a, str> {
let test_name = format!("{}-{}", func.name, hex::encode(func.selector()));
let overloaded = abi.functions.get(&func.name).is_some_and(|functions| functions.len() > 1);
let contract = contract_name.split(':').next_back().unwrap();
let has_qualified_artifact = config
.failure_persist_dir
.as_ref()
.is_some_and(|dir| dir.join("failures").join(contract).join(&test_name).exists())
|| config
.corpus
.corpus_dir
.as_ref()
.is_some_and(|dir| dir.join(contract).join(&test_name).exists())
|| config
.corpus
.frontier_dir
.as_ref()
.is_some_and(|dir| dir.join(contract).join(&test_name).exists());

if overloaded || has_qualified_artifact {
Comment thread
stevencartavia marked this conversation as resolved.
Cow::Owned(test_name)
} else {
Cow::Borrowed(&func.name)
}
}

/// Returns the legacy unqualified corpus when the qualified corpus has no entries.
fn legacy_fuzz_corpus_dir(
root: Option<&Path>,
contract_name: &str,
func: &Function,
test_name: &str,
) -> Option<PathBuf> {
if test_name == func.name {
return None;
}
let contract = contract_name.split(':').next_back().unwrap();
let root = root?;
let qualified = root.join(contract).join(test_name);
if canonical_replay_dirs(&qualified).iter().any(|dir| read_corpus_dir(dir).next().is_some()) {
return None;
}
let legacy = root.join(contract).join(&func.name);
canonical_replay_dirs(&legacy)
.iter()
.any(|dir| read_corpus_dir(dir).next().is_some())
.then(|| canonicalized(legacy))
}

/// Helper function to set test corpus dir and to compose persisted failure paths.
fn test_paths(
corpus_config: &mut FuzzCorpusConfig,
Expand Down
Loading
Loading