Skip to content

Commit ed0c58f

Browse files
0xKarl98mattssedecofe
authored
feat: Group invariant campaigns by test contract (#14844)
* fix: make invariant campaigns contract-level * fix: preserve separate invariants without assert_all * Fix contract-level invariant suite grouping * Optimize invariant result reporting * Fix invariant suite corpus reporting edge cases * Document invariant junit and suite helpers * fix(forge): reduce invariant runner allocations * refactor(forge): simplify invariant junit expansion * Improve invariant report section labels * Use contract-level invariant campaigns * Fix invariant predicate rerun filters * Polish invariant corpus and report labels * Fix invariant clippy branch order * fix(forge): preserve skipped invariant predicates * test(forge): redact invariant failure temp path * fix clippy iter_with_drain warning * test(forge): update invariant snapshots --------- Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de> Co-authored-by: Derek <256792747+decofe@users.noreply.github.com>
1 parent bbdc514 commit ed0c58f

15 files changed

Lines changed: 1719 additions & 559 deletions

File tree

crates/config/src/invariant.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,6 @@ pub struct InvariantConfig {
4949
///
5050
/// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call.
5151
pub check_interval: u32,
52-
/// Assert every invariant declared in the current test suite, continuing the campaign after
53-
/// the first failure until all invariants have been broken (or normal limits are hit).
54-
/// When `false`, the campaign aborts on the first broken invariant (legacy behavior).
55-
pub assert_all: bool,
5652
}
5753

5854
impl Default for InvariantConfig {
@@ -74,7 +70,6 @@ impl Default for InvariantConfig {
7470
max_time_delay: None,
7571
max_block_delay: None,
7672
check_interval: 1,
77-
assert_all: true,
7873
}
7974
}
8075
}

crates/evm/evm/src/executors/corpus.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,13 @@ impl WorkerCorpus {
369369
}
370370

371371
// Master worker loads the initial corpus, if it exists.
372+
if fuzzed_contracts.is_some() && has_legacy_invariant_corpus_dirs(corpus_dir) {
373+
let _ = sh_warn!(
374+
"Ignoring legacy invariant corpus directories under {}; new corpus entries are persisted under the contract-level corpus directory.",
375+
corpus_dir.display(),
376+
);
377+
}
378+
372379
// Then, [distribute]s it to workers.
373380
let executor = executor.expect("Executor required for master worker");
374381
'corpus_replay: for entry in read_corpus_dir(corpus_dir) {
@@ -1335,6 +1342,17 @@ fn read_corpus_dir(path: &Path) -> impl Iterator<Item = CorpusDirEntry> {
13351342
.into_iter()
13361343
}
13371344

1345+
fn has_legacy_invariant_corpus_dirs(path: &Path) -> bool {
1346+
std::fs::read_dir(path).is_ok_and(|entries| {
1347+
entries.flatten().any(|entry| {
1348+
let path = entry.path();
1349+
path.is_dir()
1350+
&& entry.file_name().to_str().is_some_and(|name| !name.starts_with(WORKER))
1351+
&& !path.join(OPTIMIZATION_BEST_FILE).is_file()
1352+
})
1353+
})
1354+
}
1355+
13381356
struct CorpusDirEntry {
13391357
path: PathBuf,
13401358
uuid: Uuid,
@@ -1471,6 +1489,30 @@ mod tests {
14711489
(manager, seed_uuid)
14721490
}
14731491

1492+
#[test]
1493+
fn detects_legacy_invariant_corpus_dirs_without_matching_worker_dirs() {
1494+
let corpus_root = temp_corpus_dir();
1495+
fs::create_dir_all(corpus_root.join("worker0")).unwrap();
1496+
assert!(!has_legacy_invariant_corpus_dirs(&corpus_root));
1497+
1498+
fs::create_dir_all(corpus_root.join("invariant_a")).unwrap();
1499+
assert!(has_legacy_invariant_corpus_dirs(&corpus_root));
1500+
}
1501+
1502+
#[test]
1503+
fn ignores_optimization_invariant_corpus_dirs_when_detecting_legacy_dirs() {
1504+
let corpus_root = temp_corpus_dir();
1505+
fs::create_dir_all(corpus_root.join("worker0")).unwrap();
1506+
let optimization_dir = corpus_root.join("invariant_optimize");
1507+
fs::create_dir_all(optimization_dir.join("worker0")).unwrap();
1508+
fs::write(optimization_dir.join(OPTIMIZATION_BEST_FILE), "{}").unwrap();
1509+
1510+
assert!(!has_legacy_invariant_corpus_dirs(&corpus_root));
1511+
1512+
fs::create_dir_all(corpus_root.join("invariant_legacy").join("worker0")).unwrap();
1513+
assert!(has_legacy_invariant_corpus_dirs(&corpus_root));
1514+
}
1515+
14741516
#[test]
14751517
fn favored_sets_true_and_metrics_increment_when_ratio_gt_threshold() {
14761518
let (mut manager, uuid) = new_manager_with_single_corpus();

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

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,7 @@ impl InvariantFailureMetrics {
190190

191191
/// Bridges newly-recorded invariant breaks from `failures.errors` into the pulse
192192
/// `failure_metrics` so the live progress stream reflects breaks as they happen.
193-
///
194-
/// Without this, `unique_failures` only updates when the campaign is *forced to
195-
/// stop* (i.e., `can_continue` returns false — which only happens once *all*
196-
/// invariants are broken under `assert_all`). Iterates in declaration order so
197-
/// the emitted "failure" events are deterministic.
193+
/// Iterates in declaration order so the emitted "failure" events are deterministic.
198194
fn record_new_invariant_failures(
199195
failure_metrics: &mut InvariantFailureMetrics,
200196
invariant_contract: &InvariantContract<'_>,
@@ -511,6 +507,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
511507
'stop: while continue_campaign(runs) {
512508
// Per-run failure count snapshot used to gate `afterInvariant` below.
513509
let failures_before_run = invariant_test.test_data.failures.invariant_count();
510+
let mut stop_after_run = false;
514511

515512
let initial_seq = corpus_manager.new_inputs(
516513
&mut invariant_test.test_data.branch_runner,
@@ -750,8 +747,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
750747
invariant_test.set_last_run_inputs(&current_run.inputs);
751748
}
752749
// Bridge newly-recorded predicate breaks into `failure_metrics` even when
753-
// `continues == true` (under `assert_all`, breaks accumulate per tick until
754-
// the last invariant falls).
750+
// `continues == true` in multi-predicate campaigns.
755751
if invariant_test.test_data.failures.invariant_count() > errors_before_check
756752
|| broken.is_some()
757753
{
@@ -761,15 +757,13 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
761757
&invariant_test.test_data.failures,
762758
);
763759
}
764-
// Keep the campaign running after a recorded predicate failure only when
765-
// `assert_all && !fail_on_revert`; otherwise preserve legacy early-exit.
766-
// Handler-side assertions never reach this branch — they go through
767-
// `failures.broken_handlers` and leave `continues == true`.
768760
if !continues {
769-
if self.config.assert_all && !self.config.fail_on_revert {
761+
if invariant_contract.invariant_fns.len() > 1 && !self.config.fail_on_revert
762+
{
770763
break;
771764
}
772-
break 'stop;
765+
stop_after_run = true;
766+
break;
773767
}
774768
current_run.depth += 1;
775769
}
@@ -797,8 +791,8 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
797791
);
798792

799793
// Call `afterInvariant` only if declared and the current run produced no new
800-
// failure. Under `assert_all` the campaign keeps running after earlier failures,
801-
// but the hook must still execute on subsequent runs.
794+
// failure. Multi-predicate campaigns keep running after earlier failures, but the
795+
// hook must still execute on subsequent runs.
802796
if invariant_contract.call_after_invariant
803797
&& invariant_test.test_data.failures.invariant_count() == failures_before_run
804798
{
@@ -878,6 +872,9 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
878872
}
879873

880874
runs += 1;
875+
if stop_after_run {
876+
break 'stop;
877+
}
881878
}
882879

883880
trace!(?fuzz_fixtures);
@@ -1004,18 +1001,6 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
10041001
if let Some(fuzzer) = self.executor.inspector_mut().fuzzer.as_mut() {
10051002
fuzz_state.collect_values(fuzzer.drain_collected_values());
10061003
}
1007-
// First broken invariant in declaration order (anchor first, then secondaries).
1008-
// Iterates `invariant_fns` so the lookup is deterministic, unlike HashMap iteration.
1009-
if let Some(error) =
1010-
invariant_contract.invariant_fns.iter().find_map(|(f, _)| failures.get_failure(f))
1011-
{
1012-
// Under `assert_all` we record the preflight break and let the campaign run for
1013-
// the full budget; legacy mode aborts on the first broken invariant.
1014-
if !self.config.assert_all {
1015-
return Err(eyre!(error.revert_reason().unwrap_or_default()));
1016-
}
1017-
}
1018-
10191004
// NOW enable call_override after the initial invariant check has passed.
10201005
// This allows `override_call_strat` to inject calls during actual fuzz runs
10211006
// for reentrancy vulnerability detection.

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,9 @@ pub struct InvariantContract<'a> {
280280
/// Stored in **source declaration order** so failure-event attribution and report
281281
/// rendering match user expectations.
282282
pub invariant_fns: Vec<(&'a Function, bool)>,
283-
/// Index into [`Self::invariant_fns`] of the campaign anchor — the function chosen by
284-
/// the `--mt` filter (or the only one). Used for corpus and persistence file paths and
285-
/// for the legacy single-invariant `TestResult.{reason, counterexample}` fields.
283+
/// Index into [`Self::invariant_fns`] of the stable campaign anchor. Boolean invariant
284+
/// suites use a deterministic contract-local anchor so test filters do not affect
285+
/// corpus/failure namespaces.
286286
pub anchor_idx: usize,
287287
/// If true, `afterInvariant` function is called after each invariant run.
288288
pub call_after_invariant: bool,
@@ -305,8 +305,7 @@ impl<'a> InvariantContract<'a> {
305305
Self { address, name, invariant_fns, anchor_idx, call_after_invariant, abi }
306306
}
307307

308-
/// Returns the campaign anchor — the invariant matched by `--mt` (or the only one).
309-
/// Used for corpus and persistence file paths and for legacy primary `TestResult` fields.
308+
/// Returns the stable campaign anchor.
310309
pub fn anchor(&self) -> &'a Function {
311310
self.invariant_fns[self.anchor_idx].0
312311
}

0 commit comments

Comments
 (0)