Skip to content

Commit 4a84af8

Browse files
committed
add fuzz coverage metrics config, cleanup
1 parent 37b1114 commit 4a84af8

File tree

7 files changed

+102
-41
lines changed

7 files changed

+102
-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: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,40 @@ use proptest::{
2020
strategy::{Strategy, ValueTree},
2121
test_runner::{TestCaseError, TestRunner},
2222
};
23-
use std::{cell::RefCell, collections::BTreeMap};
23+
use serde::Serialize;
24+
use std::{collections::BTreeMap, fmt};
2425

2526
mod types;
2627
pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome};
2728

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

100+
const COVERAGE_MAP_SIZE: usize = 65536;
101+
69102
impl FuzzedExecutor {
70103
/// Instantiates a fuzzed executor given a testrunner
71104
pub fn new(
@@ -75,7 +108,14 @@ impl FuzzedExecutor {
75108
config: FuzzConfig,
76109
persisted_failure: Option<BaseCounterExample>,
77110
) -> Self {
78-
Self { executor, runner, sender, config, persisted_failure }
111+
Self {
112+
executor,
113+
runner,
114+
sender,
115+
config,
116+
persisted_failure,
117+
history_map: vec![0u8; COVERAGE_MAP_SIZE],
118+
}
79119
}
80120

81121
/// Fuzzes the provided function, assuming it is available at the contract at `address`
@@ -93,7 +133,7 @@ impl FuzzedExecutor {
93133
progress: Option<&ProgressBar>,
94134
) -> FuzzTestResult {
95135
// Stores the fuzz test execution data.
96-
let execution_data = RefCell::new(FuzzTestData::default());
136+
let mut execution_data = FuzzTestData::default();
97137
let state = self.build_fuzz_state(deployed_libs);
98138
let dictionary_weight = self.config.dictionary.dictionary_weight.min(100);
99139
let strategy = proptest::prop_oneof![
@@ -106,19 +146,20 @@ impl FuzzedExecutor {
106146

107147
// Start timer for this fuzz test.
108148
let timer = FuzzTestTimer::new(self.config.timeout);
109-
149+
let max_runs = self.config.runs;
110150
let continue_campaign = |runs: u32| {
111151
// If timeout is configured, then perform fuzz runs until expires.
112-
if self.config.timeout.is_some() {
152+
if timer.is_enabled() {
113153
return !timer.is_timed_out();
114154
}
115155
// If no timeout configured then loop until configured runs.
116-
runs < self.config.runs
156+
runs < max_runs
117157
};
118158

119159
let mut runs = 0;
120160
let mut rejects = 0;
121161
let mut run_failure = None;
162+
let mut coverage_metrics = FuzzCoverageMetrics::default();
122163

123164
'stop: while continue_campaign(runs) {
124165
// If counterexample recorded, replay it first, without incrementing runs.
@@ -128,6 +169,10 @@ impl FuzzedExecutor {
128169
// If running with progress, then increment current run.
129170
if let Some(progress) = progress {
130171
progress.inc(1);
172+
// Display metrics in progress bar.
173+
if self.config.show_edge_coverage {
174+
progress.set_message(format!("{}", &coverage_metrics));
175+
}
131176
};
132177

133178
runs += 1;
@@ -140,39 +185,38 @@ impl FuzzedExecutor {
140185
strategy.current()
141186
};
142187

143-
match self.single_fuzz(address, input) {
188+
match self.single_fuzz(address, input, &mut coverage_metrics) {
144189
Ok(fuzz_outcome) => match fuzz_outcome {
145190
FuzzOutcome::Case(case) => {
146-
let mut data = execution_data.borrow_mut();
147-
data.gas_by_case.push((case.case.gas, case.case.stipend));
191+
execution_data.gas_by_case.push((case.case.gas, case.case.stipend));
148192

149-
if data.first_case.is_none() {
150-
data.first_case.replace(case.case);
193+
if execution_data.first_case.is_none() {
194+
execution_data.first_case.replace(case.case);
151195
}
152196

153197
if let Some(call_traces) = case.traces {
154-
if data.traces.len() == max_traces_to_collect {
155-
data.traces.pop();
198+
if execution_data.traces.len() == max_traces_to_collect {
199+
execution_data.traces.pop();
156200
}
157-
data.traces.push(call_traces);
158-
data.breakpoints.replace(case.breakpoints);
201+
execution_data.traces.push(call_traces);
202+
execution_data.breakpoints.replace(case.breakpoints);
159203
}
160204

161205
if show_logs {
162-
data.logs.extend(case.logs);
206+
execution_data.logs.extend(case.logs);
163207
}
164208

165-
HitMaps::merge_opt(&mut data.coverage, case.coverage);
166-
data.deprecated_cheatcodes = case.deprecated_cheatcodes;
209+
HitMaps::merge_opt(&mut execution_data.coverage, case.coverage);
210+
execution_data.deprecated_cheatcodes = case.deprecated_cheatcodes;
167211
}
168212
FuzzOutcome::CounterExample(CounterExampleOutcome {
169213
exit_reason: status,
170214
counterexample: outcome,
171215
..
172216
}) => {
173217
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;
218+
execution_data.logs.extend(outcome.1.logs.clone());
219+
execution_data.counterexample = outcome;
176220
run_failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
177221
break 'stop;
178222
}
@@ -198,30 +242,29 @@ impl FuzzedExecutor {
198242
}
199243
}
200244

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

204-
let mut traces = fuzz_result.traces;
247+
let mut traces = execution_data.traces;
205248
let (last_run_traces, last_run_breakpoints) = if run_failure.is_none() {
206-
(traces.pop(), fuzz_result.breakpoints)
249+
(traces.pop(), execution_data.breakpoints)
207250
} else {
208251
(call.traces.clone(), call.cheatcodes.map(|c| c.breakpoints))
209252
};
210253

211254
let mut result = FuzzTestResult {
212-
first_case: fuzz_result.first_case.unwrap_or_default(),
213-
gas_by_case: fuzz_result.gas_by_case,
255+
first_case: execution_data.first_case.unwrap_or_default(),
256+
gas_by_case: execution_data.gas_by_case,
214257
success: run_failure.is_none(),
215258
skipped: false,
216259
reason: None,
217260
counterexample: None,
218-
logs: fuzz_result.logs,
261+
logs: execution_data.logs,
219262
labeled_addresses: call.labels,
220263
traces: last_run_traces,
221264
breakpoints: last_run_breakpoints,
222265
gas_report_traces: traces.into_iter().map(|a| a.arena).collect(),
223-
line_coverage: fuzz_result.coverage,
224-
deprecated_cheatcodes: fuzz_result.deprecated_cheatcodes,
266+
line_coverage: execution_data.coverage,
267+
deprecated_cheatcodes: execution_data.deprecated_cheatcodes,
225268
};
226269

227270
match run_failure {
@@ -258,16 +301,24 @@ impl FuzzedExecutor {
258301

259302
/// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
260303
/// or a `CounterExampleOutcome`
261-
pub fn single_fuzz(
262-
&self,
304+
fn single_fuzz(
305+
&mut self,
263306
address: Address,
264-
calldata: alloy_primitives::Bytes,
307+
calldata: Bytes,
308+
coverage_metrics: &mut FuzzCoverageMetrics,
265309
) -> Result<FuzzOutcome, TestCaseError> {
266310
let mut call = self
267311
.executor
268312
.call_raw(self.sender, address, calldata.clone(), U256::ZERO)
269313
.map_err(|e| TestCaseError::fail(e.to_string()))?;
270314

315+
if self.config.show_edge_coverage {
316+
let (new_coverage, is_edge) = call.merge_edge_coverage(&mut self.history_map);
317+
if new_coverage {
318+
coverage_metrics.update_seen(is_edge);
319+
}
320+
}
321+
271322
// Handle `vm.assume`.
272323
if call.result.as_ref() == MAGIC_ASSUME {
273324
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)