Skip to content

Commit 36fa94c

Browse files
committed
add fuzz coverage metrics config, cleanup
1 parent 37b1114 commit 36fa94c

File tree

7 files changed

+101
-41
lines changed

7 files changed

+101
-41
lines changed

crates/config/src/fuzz.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub struct FuzzConfig {
3030
pub show_logs: bool,
3131
/// Optional timeout (in seconds) for each property test
3232
pub timeout: Option<u32>,
33+
/// Whether to collect and display edge coverage metrics.
34+
pub show_edge_coverage: bool,
3335
}
3436

3537
impl Default for FuzzConfig {
@@ -44,6 +46,7 @@ impl Default for FuzzConfig {
4446
failure_persist_dir: None,
4547
show_logs: false,
4648
timeout: None,
49+
show_edge_coverage: false,
4750
}
4851
}
4952
}

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

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,39 @@ use proptest::{
2020
strategy::{Strategy, ValueTree},
2121
test_runner::{TestCaseError, TestRunner},
2222
};
23-
use std::{cell::RefCell, collections::BTreeMap};
23+
use std::{collections::BTreeMap, fmt};
2424

2525
mod types;
2626
pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome};
2727

28+
#[derive(Default)]
29+
struct FuzzCoverageMetrics {
30+
// Number of edges seen during the fuzz test.
31+
cumulative_edges_seen: usize,
32+
// Number of features (new hitcount bin of previously hit edge) seen during the fuzz test.
33+
cumulative_features_seen: usize,
34+
}
35+
36+
impl fmt::Display for FuzzCoverageMetrics {
37+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38+
writeln!(f)?;
39+
writeln!(f, " - cumulative edges seen: {}", self.cumulative_edges_seen)?;
40+
writeln!(f, " - cumulative features seen: {}", self.cumulative_features_seen)?;
41+
Ok(())
42+
}
43+
}
44+
45+
impl FuzzCoverageMetrics {
46+
/// Records number of new edges or features explored during the campaign.
47+
pub fn update_seen(&mut self, is_edge: bool) {
48+
if is_edge {
49+
self.cumulative_edges_seen += 1;
50+
} else {
51+
self.cumulative_features_seen += 1;
52+
}
53+
}
54+
}
55+
2856
/// Contains data collected during fuzz test runs.
2957
#[derive(Default)]
3058
pub struct FuzzTestData {
@@ -64,8 +92,12 @@ pub struct FuzzedExecutor {
6492
config: FuzzConfig,
6593
/// The persisted counterexample to be replayed, if any.
6694
persisted_failure: Option<BaseCounterExample>,
95+
/// History of binned hitcount of edges seen during fuzzing.
96+
history_map: Vec<u8>,
6797
}
6898

99+
const COVERAGE_MAP_SIZE: usize = 65536;
100+
69101
impl FuzzedExecutor {
70102
/// Instantiates a fuzzed executor given a testrunner
71103
pub fn new(
@@ -75,7 +107,14 @@ impl FuzzedExecutor {
75107
config: FuzzConfig,
76108
persisted_failure: Option<BaseCounterExample>,
77109
) -> Self {
78-
Self { executor, runner, sender, config, persisted_failure }
110+
Self {
111+
executor,
112+
runner,
113+
sender,
114+
config,
115+
persisted_failure,
116+
history_map: vec![0u8; COVERAGE_MAP_SIZE],
117+
}
79118
}
80119

81120
/// Fuzzes the provided function, assuming it is available at the contract at `address`
@@ -93,7 +132,7 @@ impl FuzzedExecutor {
93132
progress: Option<&ProgressBar>,
94133
) -> FuzzTestResult {
95134
// Stores the fuzz test execution data.
96-
let execution_data = RefCell::new(FuzzTestData::default());
135+
let mut execution_data = FuzzTestData::default();
97136
let state = self.build_fuzz_state(deployed_libs);
98137
let dictionary_weight = self.config.dictionary.dictionary_weight.min(100);
99138
let strategy = proptest::prop_oneof![
@@ -106,19 +145,20 @@ impl FuzzedExecutor {
106145

107146
// Start timer for this fuzz test.
108147
let timer = FuzzTestTimer::new(self.config.timeout);
109-
148+
let max_runs = self.config.runs;
110149
let continue_campaign = |runs: u32| {
111150
// If timeout is configured, then perform fuzz runs until expires.
112-
if self.config.timeout.is_some() {
151+
if timer.is_enabled() {
113152
return !timer.is_timed_out();
114153
}
115154
// If no timeout configured then loop until configured runs.
116-
runs < self.config.runs
155+
runs < max_runs
117156
};
118157

119158
let mut runs = 0;
120159
let mut rejects = 0;
121160
let mut run_failure = None;
161+
let mut coverage_metrics = FuzzCoverageMetrics::default();
122162

123163
'stop: while continue_campaign(runs) {
124164
// If counterexample recorded, replay it first, without incrementing runs.
@@ -128,6 +168,10 @@ impl FuzzedExecutor {
128168
// If running with progress, then increment current run.
129169
if let Some(progress) = progress {
130170
progress.inc(1);
171+
// Display metrics in progress bar.
172+
if self.config.show_edge_coverage {
173+
progress.set_message(format!("{}", &coverage_metrics));
174+
}
131175
};
132176

133177
runs += 1;
@@ -140,39 +184,38 @@ impl FuzzedExecutor {
140184
strategy.current()
141185
};
142186

143-
match self.single_fuzz(address, input) {
187+
match self.single_fuzz(address, input, &mut coverage_metrics) {
144188
Ok(fuzz_outcome) => match fuzz_outcome {
145189
FuzzOutcome::Case(case) => {
146-
let mut data = execution_data.borrow_mut();
147-
data.gas_by_case.push((case.case.gas, case.case.stipend));
190+
execution_data.gas_by_case.push((case.case.gas, case.case.stipend));
148191

149-
if data.first_case.is_none() {
150-
data.first_case.replace(case.case);
192+
if execution_data.first_case.is_none() {
193+
execution_data.first_case.replace(case.case);
151194
}
152195

153196
if let Some(call_traces) = case.traces {
154-
if data.traces.len() == max_traces_to_collect {
155-
data.traces.pop();
197+
if execution_data.traces.len() == max_traces_to_collect {
198+
execution_data.traces.pop();
156199
}
157-
data.traces.push(call_traces);
158-
data.breakpoints.replace(case.breakpoints);
200+
execution_data.traces.push(call_traces);
201+
execution_data.breakpoints.replace(case.breakpoints);
159202
}
160203

161204
if show_logs {
162-
data.logs.extend(case.logs);
205+
execution_data.logs.extend(case.logs);
163206
}
164207

165-
HitMaps::merge_opt(&mut data.coverage, case.coverage);
166-
data.deprecated_cheatcodes = case.deprecated_cheatcodes;
208+
HitMaps::merge_opt(&mut execution_data.coverage, case.coverage);
209+
execution_data.deprecated_cheatcodes = case.deprecated_cheatcodes;
167210
}
168211
FuzzOutcome::CounterExample(CounterExampleOutcome {
169212
exit_reason: status,
170213
counterexample: outcome,
171214
..
172215
}) => {
173216
let reason = rd.maybe_decode(&outcome.1.result, status);
174-
execution_data.borrow_mut().logs.extend(outcome.1.logs.clone());
175-
execution_data.borrow_mut().counterexample = outcome;
217+
execution_data.logs.extend(outcome.1.logs.clone());
218+
execution_data.counterexample = outcome;
176219
run_failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
177220
break 'stop;
178221
}
@@ -198,30 +241,29 @@ impl FuzzedExecutor {
198241
}
199242
}
200243

201-
let fuzz_result = execution_data.into_inner();
202-
let (calldata, call) = fuzz_result.counterexample;
244+
let (calldata, call) = execution_data.counterexample;
203245

204-
let mut traces = fuzz_result.traces;
246+
let mut traces = execution_data.traces;
205247
let (last_run_traces, last_run_breakpoints) = if run_failure.is_none() {
206-
(traces.pop(), fuzz_result.breakpoints)
248+
(traces.pop(), execution_data.breakpoints)
207249
} else {
208250
(call.traces.clone(), call.cheatcodes.map(|c| c.breakpoints))
209251
};
210252

211253
let mut result = FuzzTestResult {
212-
first_case: fuzz_result.first_case.unwrap_or_default(),
213-
gas_by_case: fuzz_result.gas_by_case,
254+
first_case: execution_data.first_case.unwrap_or_default(),
255+
gas_by_case: execution_data.gas_by_case,
214256
success: run_failure.is_none(),
215257
skipped: false,
216258
reason: None,
217259
counterexample: None,
218-
logs: fuzz_result.logs,
260+
logs: execution_data.logs,
219261
labeled_addresses: call.labels,
220262
traces: last_run_traces,
221263
breakpoints: last_run_breakpoints,
222264
gas_report_traces: traces.into_iter().map(|a| a.arena).collect(),
223-
line_coverage: fuzz_result.coverage,
224-
deprecated_cheatcodes: fuzz_result.deprecated_cheatcodes,
265+
line_coverage: execution_data.coverage,
266+
deprecated_cheatcodes: execution_data.deprecated_cheatcodes,
225267
};
226268

227269
match run_failure {
@@ -258,16 +300,24 @@ impl FuzzedExecutor {
258300

259301
/// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
260302
/// or a `CounterExampleOutcome`
261-
pub fn single_fuzz(
262-
&self,
303+
fn single_fuzz(
304+
&mut self,
263305
address: Address,
264-
calldata: alloy_primitives::Bytes,
306+
calldata: Bytes,
307+
coverage_metrics: &mut FuzzCoverageMetrics,
265308
) -> Result<FuzzOutcome, TestCaseError> {
266309
let mut call = self
267310
.executor
268311
.call_raw(self.sender, address, calldata.clone(), U256::ZERO)
269312
.map_err(|e| TestCaseError::fail(e.to_string()))?;
270313

314+
if self.config.show_edge_coverage {
315+
let (new_coverage, is_edge) = call.merge_edge_coverage(&mut self.history_map);
316+
if new_coverage {
317+
coverage_metrics.update_seen(is_edge);
318+
}
319+
}
320+
271321
// Handle `vm.assume`.
272322
if call.result.as_ref() == MAGIC_ASSUME {
273323
return Err(TestCaseError::reject(FuzzError::TooManyRejects(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ impl<'a> InvariantExecutor<'a> {
350350
let mut last_metrics_report = Instant::now();
351351
let continue_campaign = |runs: u32| {
352352
// If timeout is configured, then perform invariant runs until expires.
353-
if self.config.timeout.is_some() {
353+
if timer.is_enabled() {
354354
return !timer.is_timed_out();
355355
}
356356
// If no timeout configured then loop until configured runs.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,11 @@ impl FuzzTestTimer {
10951095
Self { inner: timeout.map(|timeout| (Instant::now(), Duration::from_secs(timeout.into()))) }
10961096
}
10971097

1098+
/// Whether the fuzz test timer is enabled.
1099+
pub fn is_enabled(&self) -> bool {
1100+
self.inner.is_some()
1101+
}
1102+
10981103
/// Whether the current fuzz test timed out and should be stopped.
10991104
pub fn is_timed_out(&self) -> bool {
11001105
self.inner.is_some_and(|(start, duration)| start.elapsed() > duration)

crates/forge/src/runner.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -933,17 +933,16 @@ impl<'a> FunctionRunner<'a> {
933933
&func.name,
934934
);
935935

936+
let mut executor = self.executor.into_owned();
937+
// Enable edge coverage if running with coverage guided fuzzing or with edge coverage
938+
// metrics (useful for benchmarking the fuzzer).
939+
executor.inspector_mut().collect_edge_coverage(fuzz_config.show_edge_coverage);
936940
// Load persisted counterexample, if any.
937941
let persisted_failure =
938942
foundry_common::fs::read_json_file::<BaseCounterExample>(failure_file.as_path()).ok();
939943
// Run fuzz test.
940-
let mut fuzzed_executor = FuzzedExecutor::new(
941-
self.executor.into_owned(),
942-
runner,
943-
self.tcfg.sender,
944-
fuzz_config,
945-
persisted_failure,
946-
);
944+
let mut fuzzed_executor =
945+
FuzzedExecutor::new(executor, runner, self.tcfg.sender, fuzz_config, persisted_failure);
947946
let result = fuzzed_executor.fuzz(
948947
func,
949948
&self.setup.fuzz_fixtures,

crates/forge/tests/cli/config.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,7 @@ max_fuzz_dictionary_values = 6553600
10991099
gas_report_samples = 256
11001100
failure_persist_dir = "cache/fuzz"
11011101
show_logs = false
1102+
show_edge_coverage = false
11021103
11031104
[invariant]
11041105
runs = 256
@@ -1210,7 +1211,8 @@ exclude = []
12101211
"gas_report_samples": 256,
12111212
"failure_persist_dir": "cache/fuzz",
12121213
"show_logs": false,
1213-
"timeout": null
1214+
"timeout": null,
1215+
"show_edge_coverage": false
12141216
},
12151217
"invariant": {
12161218
"runs": 256,

crates/forge/tests/it/test_helpers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ impl ForgeTestProfile {
129129
failure_persist_dir: Some(tempfile::tempdir().unwrap().keep()),
130130
show_logs: false,
131131
timeout: None,
132+
show_edge_coverage: false,
132133
};
133134
config.invariant = InvariantConfig {
134135
runs: 256,

0 commit comments

Comments
 (0)