Skip to content

Commit 6b0691a

Browse files
committed
Persist and replay fuzz failure
1 parent 4eddd93 commit 6b0691a

File tree

2 files changed

+85
-81
lines changed

2 files changed

+85
-81
lines changed

crates/evm/evm/src/executors/fuzz/mod.rs

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,12 @@ impl FuzzedExecutor {
7979
/// If `should_fail` is set to `true`, then it will stop only when there's a success
8080
/// test case.
8181
///
82-
/// Returns a list of all the consumed gas and calldata of every fuzz case
82+
/// Returns a list of all the consumed gas and calldata of every fuzz case.
83+
#[warn(clippy::too_many_arguments)]
8384
pub fn fuzz(
8485
&mut self,
8586
func: &Function,
87+
persisted_input: &mut Option<BaseCounterExample>,
8688
fuzz_fixtures: &FuzzFixtures,
8789
deployed_libs: &[Address],
8890
address: Address,
@@ -116,57 +118,64 @@ impl FuzzedExecutor {
116118
let mut runs = 0;
117119
let mut rejects = 0;
118120
let mut run_failure = None;
121+
119122
'stop: while continue_campaign(runs) {
120-
let Ok(strategy) = strategy.new_tree(&mut self.runner) else {
121-
run_failure = Some(TestCaseError::fail("no input generated to call fuzzed target"));
122-
break 'stop;
123-
};
123+
// If we have a persisted counterexample, then replay it first and do not increment run.
124+
let input = if let Some(persisted) = persisted_input.take() {
125+
persisted.calldata
126+
} else {
127+
// If running with progress, then increment current run.
128+
if let Some(progress) = progress {
129+
progress.inc(1);
130+
};
124131

125-
// If running with progress then increment current run.
126-
if let Some(progress) = progress {
127-
progress.inc(1);
128-
};
132+
runs += 1;
129133

130-
match self.single_fuzz(address, strategy.current()) {
131-
Ok(fuzz_outcome) => {
132-
match fuzz_outcome {
133-
FuzzOutcome::Case(case) => {
134-
let mut data = execution_data.borrow_mut();
135-
data.gas_by_case.push((case.case.gas, case.case.stipend));
134+
let Ok(strategy) = strategy.new_tree(&mut self.runner) else {
135+
run_failure =
136+
Some(TestCaseError::fail("no input generated to call fuzzed target"));
137+
break 'stop;
138+
};
139+
strategy.current()
140+
};
136141

137-
if data.first_case.is_none() {
138-
data.first_case.replace(case.case);
139-
}
142+
match self.single_fuzz(address, input) {
143+
Ok(fuzz_outcome) => match fuzz_outcome {
144+
FuzzOutcome::Case(case) => {
145+
let mut data = execution_data.borrow_mut();
146+
data.gas_by_case.push((case.case.gas, case.case.stipend));
140147

141-
if let Some(call_traces) = case.traces {
142-
if data.traces.len() == max_traces_to_collect {
143-
data.traces.pop();
144-
}
145-
data.traces.push(call_traces);
146-
data.breakpoints.replace(case.breakpoints);
147-
}
148+
if data.first_case.is_none() {
149+
data.first_case.replace(case.case);
150+
}
148151

149-
if show_logs {
150-
data.logs.extend(case.logs);
152+
if let Some(call_traces) = case.traces {
153+
if data.traces.len() == max_traces_to_collect {
154+
data.traces.pop();
151155
}
152-
153-
HitMaps::merge_opt(&mut data.coverage, case.coverage);
154-
data.deprecated_cheatcodes = case.deprecated_cheatcodes;
156+
data.traces.push(call_traces);
157+
data.breakpoints.replace(case.breakpoints);
155158
}
156-
FuzzOutcome::CounterExample(CounterExampleOutcome {
157-
exit_reason: status,
158-
counterexample: outcome,
159-
..
160-
}) => {
161-
let reason = rd.maybe_decode(&outcome.1.result, status);
162-
execution_data.borrow_mut().logs.extend(outcome.1.logs.clone());
163-
execution_data.borrow_mut().counterexample = outcome;
164-
// HACK: we have to use an empty string here to denote `None`.
165-
run_failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
166-
break 'stop;
159+
160+
if show_logs {
161+
data.logs.extend(case.logs);
167162
}
163+
164+
HitMaps::merge_opt(&mut data.coverage, case.coverage);
165+
data.deprecated_cheatcodes = case.deprecated_cheatcodes;
168166
}
169-
}
167+
FuzzOutcome::CounterExample(CounterExampleOutcome {
168+
exit_reason: status,
169+
counterexample: outcome,
170+
..
171+
}) => {
172+
let reason = rd.maybe_decode(&outcome.1.result, status);
173+
execution_data.borrow_mut().logs.extend(outcome.1.logs.clone());
174+
execution_data.borrow_mut().counterexample = outcome;
175+
run_failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
176+
break 'stop;
177+
}
178+
},
170179
Err(err) => {
171180
match err {
172181
TestCaseError::Fail(_) => {
@@ -188,8 +197,6 @@ impl FuzzedExecutor {
188197
}
189198
}
190199
}
191-
192-
runs += 1;
193200
}
194201

195202
let fuzz_result = execution_data.into_inner();

crates/forge/src/runner.rs

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use alloy_primitives::{Address, Bytes, U256, address, map::HashMap};
1313
use eyre::Result;
1414
use foundry_common::{TestFunctionExt, TestFunctionKind, contracts::ContractsByAddress};
1515
use foundry_compilers::utils::canonicalized;
16-
use foundry_config::{Config, InvariantConfig};
16+
use foundry_config::Config;
1717
use foundry_evm::{
1818
constants::CALLER,
1919
decode::RevertDecoder,
@@ -31,9 +31,7 @@ use foundry_evm::{
3131
traces::{TraceKind, TraceMode, load_contracts},
3232
};
3333
use itertools::Itertools;
34-
use proptest::test_runner::{
35-
FailurePersistence, FileFailurePersistence, RngAlgorithm, TestError, TestRng, TestRunner,
36-
};
34+
use proptest::test_runner::{RngAlgorithm, TestError, TestRng, TestRunner};
3735
use rayon::prelude::*;
3836
use serde::{Deserialize, Serialize};
3937
use std::{
@@ -726,8 +724,8 @@ impl<'a> FunctionRunner<'a> {
726724
abi: &self.cr.contract.abi,
727725
};
728726

729-
let (failure_dir, failure_file) = invariant_failure_paths(
730-
invariant_config,
727+
let (failure_dir, failure_file) = test_failure_paths(
728+
invariant_config.failure_persist_dir.clone().unwrap(),
731729
self.cr.name,
732730
&invariant_contract.invariant_function.name,
733731
);
@@ -929,17 +927,40 @@ impl<'a> FunctionRunner<'a> {
929927
fuzz_config.runs,
930928
);
931929

930+
let (failure_dir, failure_file) = test_failure_paths(
931+
fuzz_config.failure_persist_dir.clone().unwrap(),
932+
self.cr.name,
933+
&func.name,
934+
);
935+
936+
// Load persisted counterexample, if any.
937+
let mut failed_input =
938+
foundry_common::fs::read_json_file::<BaseCounterExample>(failure_file.as_path()).ok();
939+
932940
// Run fuzz test.
933941
let mut fuzzed_executor =
934942
FuzzedExecutor::new(self.executor.into_owned(), runner, self.tcfg.sender, fuzz_config);
935943
let result = fuzzed_executor.fuzz(
936944
func,
945+
&mut failed_input,
937946
&self.setup.fuzz_fixtures,
938947
&self.setup.deployed_libs,
939948
self.address,
940949
&self.cr.mcr.revert_decoder,
941950
progress.as_ref(),
942951
);
952+
953+
// Record counterexample.
954+
if let Some(CounterExample::Single(counterexample)) = &result.counterexample {
955+
if let Err(err) = foundry_common::fs::create_dir_all(failure_dir) {
956+
error!(%err, "Failed to create fuzz failure dir");
957+
} else if let Err(err) =
958+
foundry_common::fs::write_json_file(failure_file.as_path(), counterexample)
959+
{
960+
error!(%err, "Failed to record call sequence");
961+
}
962+
}
963+
943964
self.result.fuzz_result(result);
944965
self.result
945966
}
@@ -995,40 +1016,21 @@ impl<'a> FunctionRunner<'a> {
9951016

9961017
fn fuzz_runner(&self) -> TestRunner {
9971018
let config = &self.config.fuzz;
998-
let failure_persist_path = config
999-
.failure_persist_dir
1000-
.as_ref()
1001-
.unwrap()
1002-
.join(config.failure_persist_file.as_ref().unwrap())
1003-
.into_os_string()
1004-
.into_string()
1005-
.unwrap();
1006-
fuzzer_with_cases(
1007-
config.seed,
1008-
config.runs,
1009-
config.max_test_rejects,
1010-
Some(Box::new(FileFailurePersistence::Direct(failure_persist_path.leak()))),
1011-
)
1019+
fuzzer_with_cases(config.seed, config.runs, config.max_test_rejects)
10121020
}
10131021

10141022
fn invariant_runner(&self) -> TestRunner {
10151023
let config = &self.config.invariant;
1016-
fuzzer_with_cases(self.config.fuzz.seed, config.runs, config.max_assume_rejects, None)
1024+
fuzzer_with_cases(self.config.fuzz.seed, config.runs, config.max_assume_rejects)
10171025
}
10181026

10191027
fn clone_executor(&self) -> Executor {
10201028
self.executor.clone().into_owned()
10211029
}
10221030
}
10231031

1024-
fn fuzzer_with_cases(
1025-
seed: Option<U256>,
1026-
cases: u32,
1027-
max_global_rejects: u32,
1028-
file_failure_persistence: Option<Box<dyn FailurePersistence>>,
1029-
) -> TestRunner {
1032+
fn fuzzer_with_cases(seed: Option<U256>, cases: u32, max_global_rejects: u32) -> TestRunner {
10301033
let config = proptest::test_runner::Config {
1031-
failure_persistence: file_failure_persistence,
10321034
cases,
10331035
max_global_rejects,
10341036
// Disable proptest shrink: for fuzz tests we provide single counterexample,
@@ -1077,18 +1079,13 @@ fn persisted_call_sequence(path: &Path, bytecode: &Bytes) -> Option<Vec<BaseCoun
10771079
)
10781080
}
10791081

1080-
/// Helper functions to return canonicalized invariant failure paths.
1081-
fn invariant_failure_paths(
1082-
config: &InvariantConfig,
1082+
/// Helper functions to return canonicalized test failure paths.
1083+
fn test_failure_paths(
1084+
persist_dir: PathBuf,
10831085
contract_name: &str,
10841086
invariant_name: &str,
10851087
) -> (PathBuf, PathBuf) {
1086-
let dir = config
1087-
.failure_persist_dir
1088-
.clone()
1089-
.unwrap()
1090-
.join("failures")
1091-
.join(contract_name.split(':').next_back().unwrap());
1088+
let dir = persist_dir.join("failures").join(contract_name.split(':').next_back().unwrap());
10921089
let dir = canonicalized(dir);
10931090
let file = canonicalized(dir.join(invariant_name));
10941091
(dir, file)

0 commit comments

Comments
 (0)