diff --git a/.github/workflows/fuzz-afl.yml b/.github/workflows/fuzz-afl.yml index ce019cc3d..3a2b619b7 100644 --- a/.github/workflows/fuzz-afl.yml +++ b/.github/workflows/fuzz-afl.yml @@ -21,36 +21,13 @@ jobs: outputs: targets: ${{ steps.list.outputs.targets }} steps: - - name: Build target list + - name: Checkout repository + uses: actions/checkout@v4 + # Derive the matrix straight from fuzz/Cargo.toml (the single source of truth), + # so new [[bin]] targets are picked up with no manual list to maintain here. + - name: Resolve fuzz target matrix id: list - run: | - # Canonical, human-readable list (one target per line) → compact JSON array. - targets=$(jq -R -s -c 'split("\n") | map(select(length > 0))' <<'EOF' - fuzz_apply_state_diff_split_path - fuzz_block_verification - fuzz_encoding_roundtrip - fuzz_multi_block_state_sequence - fuzz_program_deployment_lifecycle - fuzz_replay_prevention - fuzz_sequencer_vs_replayer - fuzz_signature_verification - fuzz_state_diff_computation - fuzz_state_serialization - fuzz_state_transition - fuzz_stateless_verification - fuzz_transaction_decoding - fuzz_validate_execute_consistency - fuzz_witness_set_verification - fuzz_merkle_tree - fuzz_transaction_properties - fuzz_privacy_preserving_witness - fuzz_encoding_privacy_preserving - fuzz_nullifier_set_roundtrip - fuzz_privacy_preserving_state_transition - EOF - ) - echo "targets=$targets" >> "$GITHUB_OUTPUT" - echo "Resolved ${targets}" + uses: ./.github/actions/resolve-targets # ──────────────────────────────────────────────────────────────────────────── # afl-smoke — 60-second per targets diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 94e8cf11e..26e25fa31 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -20,35 +20,26 @@ jobs: - uses: actions/checkout@v4 - run: python3 scripts/check_target_inventory.py + # ── Resolve the fuzz target matrix from fuzz/Cargo.toml (single source of truth) ─ + resolve: + name: Resolve target matrix + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.list.outputs.targets }} + steps: + - uses: actions/checkout@v4 + - id: list + uses: ./.github/actions/resolve-targets + # ── Smoke fuzz: 60 s per target ───────────────────────────────────────────── smoke-fuzz: name: Smoke fuzz (${{ matrix.target }}) runs-on: ubuntu-latest + needs: resolve strategy: fail-fast: false matrix: - target: - - fuzz_transaction_decoding - - fuzz_stateless_verification - - fuzz_state_transition - - fuzz_block_verification - - fuzz_encoding_roundtrip - - fuzz_signature_verification - - fuzz_replay_prevention - - fuzz_state_diff_computation - - fuzz_validate_execute_consistency - - fuzz_state_serialization - - fuzz_witness_set_verification - - fuzz_program_deployment_lifecycle - - fuzz_apply_state_diff_split_path - - fuzz_multi_block_state_sequence - - fuzz_sequencer_vs_replayer - - fuzz_merkle_tree - - fuzz_transaction_properties - - fuzz_privacy_preserving_witness - - fuzz_encoding_privacy_preserving - - fuzz_nullifier_set_roundtrip - - fuzz_privacy_preserving_state_transition + target: ${{ fromJSON(needs.resolve.outputs.targets) }} steps: - uses: actions/checkout@v4 @@ -202,31 +193,11 @@ jobs: regression: name: Corpus regression (${{ matrix.target }}) runs-on: ubuntu-latest + needs: resolve strategy: fail-fast: false matrix: - target: - - fuzz_transaction_decoding - - fuzz_stateless_verification - - fuzz_state_transition - - fuzz_block_verification - - fuzz_encoding_roundtrip - - fuzz_signature_verification - - fuzz_replay_prevention - - fuzz_state_diff_computation - - fuzz_validate_execute_consistency - - fuzz_state_serialization - - fuzz_witness_set_verification - - fuzz_program_deployment_lifecycle - - fuzz_apply_state_diff_split_path - - fuzz_multi_block_state_sequence - - fuzz_sequencer_vs_replayer - - fuzz_merkle_tree - - fuzz_transaction_properties - - fuzz_privacy_preserving_witness - - fuzz_encoding_privacy_preserving - - fuzz_nullifier_set_roundtrip - - fuzz_privacy_preserving_state_transition + target: ${{ fromJSON(needs.resolve.outputs.targets) }} steps: - uses: actions/checkout@v4 - name: Checkout logos-execution-zone @@ -255,6 +226,7 @@ jobs: perf-baseline: name: Performance baseline runs-on: ubuntu-latest + needs: resolve if: github.event_name == 'schedule' steps: - uses: actions/checkout@v4 @@ -263,28 +235,7 @@ jobs: - uses: ./.github/actions/setup-libfuzzer - name: Measure throughput (30 s per target) run: | - for target in \ - fuzz_transaction_decoding \ - fuzz_stateless_verification \ - fuzz_state_transition \ - fuzz_block_verification \ - fuzz_encoding_roundtrip \ - fuzz_signature_verification \ - fuzz_replay_prevention \ - fuzz_state_diff_computation \ - fuzz_validate_execute_consistency \ - fuzz_state_serialization \ - fuzz_witness_set_verification \ - fuzz_program_deployment_lifecycle \ - fuzz_apply_state_diff_split_path \ - fuzz_multi_block_state_sequence \ - fuzz_sequencer_vs_replayer \ - fuzz_merkle_tree \ - fuzz_transaction_properties \ - fuzz_privacy_preserving_witness \ - fuzz_encoding_privacy_preserving \ - fuzz_nullifier_set_roundtrip \ - fuzz_privacy_preserving_state_transition; do + for target in $(jq -r '.[]' <<< '${{ needs.resolve.outputs.targets }}'); do echo "=== $target ===" | tee -a perf_baseline.txt cargo fuzz run "$target" -- -max_total_time=30 2>&1 \ | grep -E "exec/s|execs_per_sec" | tail -1 | tee -a perf_baseline.txt diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index 829503a34..825fff561 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -139,23 +139,18 @@ jobs: - name: Make corpus-regression wrapper executable run: chmod +x scripts/mutants-corpus-test.sh - # Build all 20 fuzz targets once before the mutation loop so that each - # mutant only needs to rebuild the mutated crate, not the fuzz harness. - # Keep this list in sync with scripts/mutants-corpus-test.sh. + # Resolve the target list from fuzz/Cargo.toml (the single source of truth) + # so this workflow needs no hand-maintained list; scripts/mutants-corpus-test.sh + # parses the same file, so the two stay in sync automatically. + - name: Resolve fuzz target matrix + id: list + uses: ./.github/actions/resolve-targets + + # Build every fuzz target once before the mutation loop so that each mutant + # only needs to rebuild the mutated crate, not the fuzz harness. - name: Pre-build fuzz targets run: | - for target in \ - fuzz_transaction_decoding fuzz_stateless_verification \ - fuzz_state_transition fuzz_block_verification \ - fuzz_encoding_roundtrip fuzz_signature_verification \ - fuzz_replay_prevention fuzz_state_diff_computation \ - fuzz_validate_execute_consistency fuzz_state_serialization \ - fuzz_witness_set_verification fuzz_program_deployment_lifecycle \ - fuzz_apply_state_diff_split_path fuzz_multi_block_state_sequence \ - fuzz_sequencer_vs_replayer fuzz_merkle_tree \ - fuzz_transaction_properties fuzz_privacy_preserving_witness \ - fuzz_encoding_privacy_preserving fuzz_nullifier_set_roundtrip \ - fuzz_privacy_preserving_state_transition; do + for target in $(jq -r '.[]' <<< '${{ steps.list.outputs.targets }}'); do cargo fuzz build "${target}" done diff --git a/README.md b/README.md index d2544640e..935b715fd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [Logos Execution Zone (LEZ)](https://github.com/logos-blockchain/logos-execution-zone) protocol.** [![Rust](https://img.shields.io/badge/rust-nightly-orange?logo=rust)](rust-toolchain.toml) -[![Fuzzing](https://img.shields.io/badge/libFuzzer%20%C2%B7%20AFL%2B%2B-21%20targets-blue)](#-fuzz-targets) +[![Fuzzing](https://img.shields.io/badge/libFuzzer%20%C2%B7%20AFL%2B%2B-22%20targets-blue)](#-fuzz-targets) [![Mutation testing](https://img.shields.io/badge/cargo--mutants-enabled-green)](.github/workflows/mutants.yml) [![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE-MIT) @@ -31,7 +31,7 @@ lez-fuzzing/ │ └── generators.rs # Arbitrary / proptest strategies ├── fuzz/ # cargo-fuzz crate (own [workspace] sentinel) │ ├── Cargo.toml -│ ├── fuzz_targets/ # 21 targets total — see table below +│ ├── fuzz_targets/ # 22 targets total — see table below │ │ ├── _template.rs # Template for `just new-target` │ │ └── fuzz_*.rs │ └── corpus/ # Curated seed inputs (one dir per target) @@ -131,6 +131,7 @@ just fuzz-props | 19 | `fuzz_encoding_privacy_preserving` | Privacy-preserving encoding: MessageEncodingRoundtrip + TxEncodingDeterministic/NonEmpty | | 20 | `fuzz_nullifier_set_roundtrip` | `NullifierSet` Borsh serialisation: NullifierSetRoundtrip (decode→encode identity for the hand-written impl) | | 21 | `fuzz_privacy_preserving_state_transition` | Path B — `NSSATransaction::PrivacyPreserving` through `execute_check_on_state` with a dev-mode passing proof: reaches commitment/nullifier checks 5–6 + `apply_state_diff`. Asserts no-panic, StateIsolationOnFailure, PrivateStateIsolationOnFailure, CommitmentInsertion, NonceIncrementCorrectness, PostStateApplied, ReplayRejection (balance conservation intentionally not asserted — the fake proof bypasses the circuit guarantee) | +| 22 | `fuzz_transaction_ordering_independence` | Transaction ordering-independence on the shielded path: builds a *nullifier-conflicting* pair (two distinct privacy-preserving txs declaring the same nullifier) and applies it in both orders on independent clones of a seeded state, at an identical `(block_id, timestamp)`. Asserts **NoDoubleSpend** (neither ordering accepts both — the shared nullifier is spendable at most once) and **OrderIndependentAcceptance** (the count of accepted txs is the same in both orderings). The nullifier check is enforced by the state machine, not the circuit, so the dev-mode fake proof does not mask it. Requires `RISC0_DEV_MODE=1` | Each target lives at `fuzz/fuzz_targets/.rs`. @@ -167,13 +168,19 @@ cp fuzz/artifacts/fuzz_state_transition/crash-abc123-minimised \ ## ➕ Adding a New Target ```bash -# Scaffold everything automatically (corpus dir, .rs file, Cargo.toml entry, CI matrix entry) +# Scaffold everything automatically (corpus dir, .rs file, Cargo.toml entry) just new-target my_feature # creates fuzz_my_feature ``` `just new-target` calls [`scripts/add_fuzz_target.py`](scripts/add_fuzz_target.py), which -appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](fuzz/Cargo.toml) and inserts the target -into every strategy matrix in [`.github/workflows/fuzz.yml`](.github/workflows/fuzz.yml). +appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](fuzz/Cargo.toml) — the **single source of +truth**. Every workflow and script derives its target list from that file at runtime (the CI +matrices and build loops via the [`resolve-targets`](.github/actions/resolve-targets) +composite action, and [`scripts/mutants-corpus-test.sh`](scripts/mutants-corpus-test.sh) via +an inline parse), so **no CI edits are needed**. The only manual step is a prose row in the +target tables of `README.md` and [`docs/fuzzing.md`](docs/fuzzing.md); +[`scripts/check_target_inventory.py`](scripts/check_target_inventory.py) (run in CI) fails the +build if either table drifts from `fuzz/Cargo.toml`. --- diff --git a/corpus/libfuzz/fuzz_state_transition/seed_empty_tx b/corpus/libfuzz/fuzz_state_transition/seed_empty_tx index b00ba8713..61f4c0a63 100644 Binary files a/corpus/libfuzz/fuzz_state_transition/seed_empty_tx and b/corpus/libfuzz/fuzz_state_transition/seed_empty_tx differ diff --git a/corpus/libfuzz/fuzz_stateless_verification/seed_empty_tx b/corpus/libfuzz/fuzz_stateless_verification/seed_empty_tx index b00ba8713..61f4c0a63 100644 Binary files a/corpus/libfuzz/fuzz_stateless_verification/seed_empty_tx and b/corpus/libfuzz/fuzz_stateless_verification/seed_empty_tx differ diff --git a/corpus/libfuzz/fuzz_transaction_decoding/seed_empty_tx b/corpus/libfuzz/fuzz_transaction_decoding/seed_empty_tx index b00ba8713..61f4c0a63 100644 Binary files a/corpus/libfuzz/fuzz_transaction_decoding/seed_empty_tx and b/corpus/libfuzz/fuzz_transaction_decoding/seed_empty_tx differ diff --git a/current_vs_alternative_approach.md b/current_vs_alternative_approach.md index f4923662f..9d43c34e6 100644 --- a/current_vs_alternative_approach.md +++ b/current_vs_alternative_approach.md @@ -11,7 +11,7 @@ The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing | Rich generators | [`fuzz_props::generators`](fuzz_props/src/generators.rs) adds `proptest` strategies for pathological sequences, phantom-account attacks, overflow amounts, replay sequences | | Protocol invariants | [`fuzz_props::invariants`](fuzz_props/src/invariants.rs) expresses zero-mutation-on-rejection and replay-rejection as reusable `ProtocolInvariant` objects | | ZK-awareness | `RISC0_DEV_MODE=1` stubs out `risc0-zkvm` proofs, enabling ~5 000–200 000 exec/sec depending on target | -| 20 dedicated targets | Covers encoding, signature verification, stateless checks, state transitions, state diffs, replay prevention, validate/execute consistency, block verification, state serialization, witness-set verification, program deployment lifecycle, split-path equivalence, multi-block sequences, sequencer-vs-replayer differential, Merkle-tree invariants, transaction properties, privacy-preserving witness/encoding, and nullifier-set round-trips. Input-independent invariant checks (genesis contents, getters, system-account guard) are kept as **LEZ unit tests**, not targets — see [`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md) | +| 22 dedicated targets | Covers encoding, signature verification, stateless checks, state transitions, state diffs, replay prevention, validate/execute consistency, block verification, state serialization, witness-set verification, program deployment lifecycle, split-path equivalence, multi-block sequences, sequencer-vs-replayer differential, Merkle-tree invariants, transaction properties, privacy-preserving witness/encoding, nullifier-set round-trips, the privacy-preserving state-transition executor, and transaction ordering-independence (shielded-path nullifier double-spend across orderings). Input-independent invariant checks (genesis contents, getters, system-account guard) are kept as **LEZ unit tests**, not targets — see [`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md) | | CI integration | GitHub Actions libFuzzer (`fuzz.yml`), AFL++ (`fuzz-afl.yml`), and mutation-testing (`mutants.yml`) workflows run on every PR / nightly | | Pre-seeded corpus | Hundreds of minimised seed files in [`fuzz/corpus/`](fuzz/corpus/) ensure regressions are caught instantly | diff --git a/docs/fuzzing.md b/docs/fuzzing.md index b1f95e7da..de9e74141 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -130,6 +130,7 @@ just fuzz-regression | `fuzz_encoding_privacy_preserving` | Privacy-preserving encoding: **MessageEncodingRoundtrip**, **TxEncodingDeterministic** / **NonEmpty** | `fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs` | | `fuzz_nullifier_set_roundtrip` | `NullifierSet` Borsh serialisation: **NullifierSetRoundtrip** (decode→encode identity for the hand-written impl) | `fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs` | | `fuzz_privacy_preserving_state_transition` | Path B — `NSSATransaction::PrivacyPreserving` through `execute_check_on_state` with a dev-mode passing proof, reaching checks 5–6 (`check_commitments_are_new` / `check_nullifiers_are_valid`) and `apply_state_diff`: **No panic**, **StateIsolationOnFailure**, **PrivateStateIsolationOnFailure**, **CommitmentInsertion**, **NonceIncrementCorrectness**, **PostStateApplied**, **ReplayRejection**. Balance conservation is intentionally *not* asserted — the synthesised fake receipt bypasses the circuit guarantee. Requires `RISC0_DEV_MODE=1` | `fuzz/fuzz_targets/fuzz_privacy_preserving_state_transition.rs` | +| `fuzz_transaction_ordering_independence` | Ordering-independence for the shielded path — the only target that compares two *orderings* of the same transactions rather than one fixed order. `arb_conflicting_nullifier_pair` builds two distinct privacy-preserving txs declaring the **same** nullifier; they are applied in both orders (`B→C` and `C→B`) on independent clones of a commitment-seeded state, at an identical `(block_id, timestamp)` so order is the only variable. Asserts **NoDoubleSpend** (a shared nullifier is spendable at most once per ordering) and **OrderIndependentAcceptance** (the number of accepted txs is order-invariant). The nullifier guard is a state-machine check, not a circuit check, so the dev-mode fake receipt does not mask a violation. Requires `RISC0_DEV_MODE=1` | `fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs` | --- @@ -147,8 +148,7 @@ This single command does four things automatically: |---|---| | Creates the corpus directory | `fuzz/corpus/fuzz_my_feature/` | | Writes a typed fuzz target template | `fuzz/fuzz_targets/fuzz_my_feature.rs` | -| Appends `[[bin]]` entry to `fuzz/Cargo.toml` | Covers **both** the libFuzzer and AFL++ lanes | -| Inserts target into every CI matrix + perf loop | `.github/workflows/fuzz.yml` | +| Appends `[[bin]]` entry to `fuzz/Cargo.toml` | Covers **both** the libFuzzer and AFL++ lanes — and every workflow/script, which derive their target lists from this file | The generated template uses `fuzz_props::fuzz_entry!` and works with both engines without modification. @@ -164,16 +164,26 @@ structured input, or the proptest generators from ### Step 3 — Automated registration (cargo-fuzz + CI) `just new-target` calls [`scripts/add_fuzz_target.py`](../scripts/add_fuzz_target.py) -which: -- Appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](../fuzz/Cargo.toml). - This **single entry** covers both the libFuzzer lane (`cargo fuzz build`) and - the AFL++ lane (`cargo afl build --no-default-features --features fuzzer-afl`). -- Inserts the target name into every strategy matrix and the perf-baseline shell - loop in [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml). +which appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](../fuzz/Cargo.toml). This +**single entry** covers both the libFuzzer lane (`cargo fuzz build`) and the AFL++ +lane (`cargo afl build --no-default-features --features fuzzer-afl`) — and it is the +**single source of truth** every workflow and script reads at runtime: + +- The CI matrices and build loops (`fuzz.yml`, `fuzz-afl.yml`, `corpus-update.yml`, + `mutants.yml`) resolve their target list through the + [`resolve-targets`](../.github/actions/resolve-targets) composite action, which + parses `fuzz/Cargo.toml`. +- [`scripts/mutants-corpus-test.sh`](../scripts/mutants-corpus-test.sh) parses the + same file inline. + +So a new `[[bin]]` needs **no workflow edits** — the only hand-authored places are the +prose target tables in this file and `README.md`, which +[`scripts/check_target_inventory.py`](../scripts/check_target_inventory.py) (run in CI) +checks against `fuzz/Cargo.toml`. > [!TIP] > **Manual fallback:** if you create a target without `just new-target`, add the -> entry yourself: +> entry yourself — that alone wires it into every lane: > > ```toml > [[bin]] @@ -206,7 +216,8 @@ cd fuzz && cargo afl build \ | `fuzz/corpus/fuzz_/` | Create | ✅ `just new-target` | | `fuzz/Cargo.toml` | Add `[[bin]]` (covers both lanes) | ✅ `just new-target` | | `Justfile` | Nothing — auto-discovers | ✅ automatic | -| `.github/workflows/fuzz.yml` | Add to 3 matrix lists | ✅ `just new-target` | +| `.github/workflows/*.yml`, `scripts/mutants-corpus-test.sh` | Nothing — target lists derive from `fuzz/Cargo.toml` | ✅ automatic | +| `README.md`, `docs/fuzzing.md` | Add a prose row to the target table | ⚠️ manual (CI-gated by `check_target_inventory.py`) | --- @@ -377,8 +388,8 @@ The nightly AFL++ CI workflow has two jobs: | Job | Triggers | Matrix | |-----|----------|--------| -| `afl-smoke` | nightly + `workflow_dispatch` | all 21 targets, 60 s each | -| `afl-coverage-aggregate` | nightly, `needs: afl-smoke` | all 21 targets merged into one LLVM HTML report | +| `afl-smoke` | nightly + `workflow_dispatch` | all 22 targets, 60 s each | +| `afl-coverage-aggregate` | nightly, `needs: afl-smoke` | all 22 targets merged into one LLVM HTML report | The smoke job (one matrix leg per target, on `ubuntu-latest`): 1. Builds AFL++ from source, then builds the target with `cargo afl build --no-default-features --features fuzzer-afl` @@ -388,7 +399,7 @@ The smoke job (one matrix leg per target, on `ubuntu-latest`): The coverage-aggregate job: 1. Downloads every smoke leg's findings -2. Rebuilds all 21 targets with `RUSTFLAGS="-C instrument-coverage"` +2. Rebuilds all 22 targets with `RUSTFLAGS="-C instrument-coverage"` 3. Runs all checked-in corpus + AFL queue inputs through each binary 4. Merges every `.profraw` → one `.profdata` → a single combined HTML report via `llvm-cov show` @@ -622,6 +633,7 @@ Measured on a 4-core x86_64 Linux runner with `RISC0_DEV_MODE=1`: | `fuzz_encoding_privacy_preserving` | ~50 000 exec/sec *(estimate)* | | `fuzz_nullifier_set_roundtrip` | ~100 000 exec/sec *(estimate)* | | `fuzz_privacy_preserving_state_transition` | slow — dev-mode proof synthesis + verification per exec dominates runtime *(estimate)* | +| `fuzz_transaction_ordering_independence` | slow — seeds the commitment set, then synthesises proofs and executes the conflicting pair in both orderings per exec *(estimate)* | > [!NOTE] > Throughput figures for the five new targets are rough estimates; run `just perf-baseline` diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index ef906cdea..a138fde00 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -157,3 +157,9 @@ name = "fuzz_privacy_preserving_state_transition" path = "fuzz_targets/fuzz_privacy_preserving_state_transition.rs" test = false bench = false + +[[bin]] +name = "fuzz_transaction_ordering_independence" +path = "fuzz_targets/fuzz_transaction_ordering_independence.rs" +test = false +bench = false diff --git a/fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs b/fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs new file mode 100644 index 000000000..9504b8059 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs @@ -0,0 +1,133 @@ +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +//! Fuzz target: transaction **ordering-independence** for the shielded (privacy-preserving) +//! path — the property no other target asserts. +//! +//! Every other target applies transactions in a single fixed order and checks the *result*. +//! None asks whether the *order* of two transactions can change which ones are accepted. This +//! target does: it applies a nullifier-conflicting pair in both orders on independent copies +//! of the same base state and asserts the outcome is order-independent. +//! +//! # Why the shielded path (and not native transfers) +//! +//! The original `fuzz_transaction_non_interference` proposal aimed this idea at native +//! transfers, but `V03State`'s only cross-transaction state is the append-only +//! `(CommitmentSet, NullifierSet)`; two disjoint public transfers touch only per-account maps +//! and commute trivially, so that target would be permanently green. The nullifier set is the +//! real shared state — it is what prevents double-spends — so that is where ordering can +//! actually matter. +//! +//! # The oracle +//! +//! `arb_conflicting_nullifier_pair` builds two *distinct* transactions `B` and `C` that +//! declare the **same** nullifier. A correct state machine enforces first-come-first-served: +//! whichever is applied first spends the nullifier, and the second is rejected at +//! `check_nullifiers_are_valid`. Both orderings are applied at an **identical** `(block_id, +//! timestamp)` so the *only* difference between them is transaction order — fixing a flaw in +//! the original sketch, which varied the clock per position and so could not distinguish +//! ordering effects from validity-window effects. +//! +//! Note on scope: this nullifier check is enforced by the *state machine*, not by the ZK +//! circuit, so the dev-mode synthesised proof (which bypasses the circuit) does **not** mask +//! it — unlike balance conservation, which this path deliberately never asserts. +//! +//! Requires `RISC0_DEV_MODE=1` (set by every `just fuzz` recipe) for the synthesised proofs. +//! +//! # Invariants asserted +//! +//! * **NoDoubleSpend** — in neither ordering are both conflicting transactions accepted; a +//! shared nullifier is spendable at most once. A violation is a literal double-spend. +//! * **OrderIndependentAcceptance** — the number of transactions accepted from the pair is the +//! same in both orderings. A violation means acceptance leaks across transactions depending +//! on order (order-dependent interference through global state). + +use arbitrary::{Arbitrary, Unstructured}; +use common::transaction::LeeTransaction; +use fuzz_props::generators::arbitrary_fuzz_state; +use fuzz_props::privacy::{arb_conflicting_nullifier_pair, arb_privacy_preserving_tx}; +use nssa::{AccountId, PrivacyPreservingTransaction}; + +/// Apply the production stateless gate and wrap for execution; `None` drops the input. +fn gate(tx: PrivacyPreservingTransaction) -> Option { + LeeTransaction::PrivacyPreserving(tx) + .transaction_stateless_check() + .ok() +} + +fuzz_props::fuzz_entry!(|data: &[u8]| { + let mut u = Unstructured::new(data); + + // Need at least two keyed accounts so the conflicting pair can use distinct signers. + let fuzz_accs = match arbitrary_fuzz_state(&mut u) { + Ok(accs) if accs.len() >= 2 => accs, + _ => return, + }; + let init_accs: Vec<(AccountId, u128)> = fuzz_accs + .iter() + .map(|a| (a.account_id, a.balance)) + .collect(); + let mut base = fuzz_props::genesis::genesis_state(&init_accs, vec![]); + + // ── Seed the commitment set ────────────────────────────────────────────────────────── + // A nullifier only passes check 6 when its digest is in `root_history`, which starts empty + // and is seeded only once a commitment-bearing transaction applies. Reuse the + // proven-reachable generator to grow it; individual outcomes don't matter here. + let n_seed: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 4; + for i in 0..n_seed { + let Ok(tx) = arb_privacy_preserving_tx(&mut u, &base, &fuzz_accs) else { + break; + }; + let Some(lee) = gate(tx) else { continue }; + let _ = lee.execute_check_on_state(&mut base, 1 + u64::from(i), u64::from(i)); + } + + // ── Build the nullifier-conflicting pair against the seeded base ───────────────────── + let Ok((tx_b, tx_c)) = arb_conflicting_nullifier_pair(&mut u, &base, &fuzz_accs) else { + return; + }; + + // Two independent instances of each so both orderings get a fresh, un-consumed copy. + let (Some(b1), Some(c1)) = (gate(tx_b.clone()), gate(tx_c.clone())) else { + return; + }; + let (Some(b2), Some(c2)) = (gate(tx_b), gate(tx_c)) else { + return; + }; + + // Identical clock for both orderings: the sole difference is transaction order. The pair's + // validity windows are unbounded, so the specific values are immaterial. + const BLOCK: u64 = 1; + const TS: u64 = 0; + + // ── Order 1: B → C ─────────────────────────────────────────────────────────────────── + let mut s_bc = base.clone(); + let rb1 = b1.execute_check_on_state(&mut s_bc, BLOCK, TS).is_ok(); + let rc1 = c1.execute_check_on_state(&mut s_bc, BLOCK, TS).is_ok(); + + // ── Order 2: C → B ─────────────────────────────────────────────────────────────────── + let mut s_cb = base.clone(); + let rc2 = c2.execute_check_on_state(&mut s_cb, BLOCK, TS).is_ok(); + let rb2 = b2.execute_check_on_state(&mut s_cb, BLOCK, TS).is_ok(); + + // ── INVARIANT [NoDoubleSpend] ──────────────────────────────────────────────────────── + // The shared nullifier must be spendable at most once, in either order. + assert!( + !(rb1 && rc1), + "INVARIANT VIOLATION [NoDoubleSpend]: both conflicting transactions accepted in order \ + B→C — the shared nullifier was double-spent" + ); + assert!( + !(rc2 && rb2), + "INVARIANT VIOLATION [NoDoubleSpend]: both conflicting transactions accepted in order \ + C→B — the shared nullifier was double-spent" + ); + + // ── INVARIANT [OrderIndependentAcceptance] ─────────────────────────────────────────── + // The count of accepted transactions from the pair must not depend on ordering. + let accepted_bc = u8::from(rb1) + u8::from(rc1); + let accepted_cb = u8::from(rb2) + u8::from(rc2); + assert_eq!( + accepted_bc, accepted_cb, + "INVARIANT VIOLATION [OrderIndependentAcceptance]: {accepted_bc} of the pair accepted \ + as B→C but {accepted_cb} as C→B — transaction acceptance is order-dependent", + ); +}); diff --git a/fuzz_props/src/privacy.rs b/fuzz_props/src/privacy.rs index f88923a78..8b1f46594 100644 --- a/fuzz_props/src/privacy.rs +++ b/fuzz_props/src/privacy.rs @@ -321,3 +321,89 @@ pub fn arb_privacy_preserving_tx( let witness_set = PPWitnessSet::for_message(&message, proof, &keys); Ok(PrivacyPreservingTransaction::new(message, witness_set)) } + +/// Build a minimal *pure-private* transaction: one signer, no public accounts, the given +/// commitments and nullifiers, and unbounded validity windows. +/// +/// Two properties matter for the ordering-independence oracle in +/// `fuzz_transaction_ordering_independence`: +/// +/// * **`public_account_ids` is empty**, so `synthesize_passing_proof` reconstructs an empty +/// `public_pre_states` and the journal does not depend on live chain state. The proof +/// therefore stays valid at check 4 even after another transaction has mutated the state — +/// i.e. it is valid whether this tx is applied *first* or *second*. That is what lets us +/// apply the same transaction in both orderings and compare outcomes soundly. +/// * The single signer's nonce is read live from `state`, so check 3c passes by construction +/// at first application; because the paired transaction uses a *different* signer, this +/// signer's nonce is untouched when this tx is applied second — so any rejection of the +/// second application is attributable to the shared nullifier, not to a nonce mismatch. +fn build_pure_private_tx( + state: &V03State, + key: &PrivateKey, + new_commitments: Vec, + new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>, +) -> PrivacyPreservingTransaction { + let signer_id = account_id_for_key(key); + let message = PPMessage { + public_account_ids: Vec::new(), + nonces: vec![state.get_account_by_id(signer_id).nonce], + public_post_states: Vec::new(), + encrypted_private_post_states: Vec::new(), + new_commitments, + new_nullifiers, + block_validity_window: ValidityWindow::new_unbounded(), + timestamp_validity_window: ValidityWindow::new_unbounded(), + }; + let proof = synthesize_passing_proof(&message, state, &[signer_id]); + let witness_set = PPWitnessSet::for_message(&message, proof, &[key]); + PrivacyPreservingTransaction::new(message, witness_set) +} + +/// Build a **nullifier-conflicting pair**: two *distinct* privacy-preserving transactions +/// (different signers, different fresh commitments) that both declare the **same** nullifier. +/// +/// The shared nullifier's digest is bound to the current commitment-set root +/// (`state.commitment_set_digest()`); this only satisfies check 6's `root_history` membership +/// once a commitment-bearing transaction has already grown the set, so callers should seed the +/// state first (see the target). The two transactions are otherwise independently valid, so a +/// correct state machine must accept *at most one* of them regardless of application order — +/// the property the ordering-independence target asserts. +/// +/// Returns `Err` when there are fewer than two distinct keyed accounts to draw signers from. +pub fn arb_conflicting_nullifier_pair( + u: &mut Unstructured<'_>, + state: &V03State, + accounts: &[FuzzAccount], +) -> ArbResult<(PrivacyPreservingTransaction, PrivacyPreservingTransaction)> { + if accounts.len() < 2 { + return Err(arbitrary::Error::IncorrectFormat); + } + // Two distinct signer accounts so the pair's nonce checks are independent. + let i = (u8::arbitrary(u)? as usize) % accounts.len(); + let mut j = (u8::arbitrary(u)? as usize) % accounts.len(); + if j == i { + j = (i + 1) % accounts.len(); + } + let key_b = &accounts[i].private_key; + let key_c = &accounts[j].private_key; + if account_id_for_key(key_b) == account_id_for_key(key_c) { + return Err(arbitrary::Error::IncorrectFormat); + } + + // One nullifier, shared by both transactions, bound to a historical commitment-set root. + let root = state.commitment_set_digest(); + let null_aid = AccountId::new(<[u8; 32]>::arbitrary(u)?); + let shared_nullifiers = vec![(Nullifier::for_account_initialization(&null_aid), root)]; + + // Distinct fresh commitments make the two transactions genuinely different (and keep them + // from colliding with each other on check 5). + let comm_b = Commitment::new(&AccountId::new(<[u8; 32]>::arbitrary(u)?), &arb_account(u)?); + let comm_c = Commitment::new(&AccountId::new(<[u8; 32]>::arbitrary(u)?), &arb_account(u)?); + if comm_b == comm_c { + return Err(arbitrary::Error::IncorrectFormat); + } + + let tx_b = build_pure_private_tx(state, key_b, vec![comm_b], shared_nullifiers.clone()); + let tx_c = build_pure_private_tx(state, key_c, vec![comm_c], shared_nullifiers); + Ok((tx_b, tx_c)) +} diff --git a/fuzz_props/src/tests/privacy.rs b/fuzz_props/src/tests/privacy.rs index 2bbde6676..d8317595a 100644 --- a/fuzz_props/src/tests/privacy.rs +++ b/fuzz_props/src/tests/privacy.rs @@ -1,8 +1,9 @@ use arbitrary::Unstructured; -use crate::generators::FuzzAccount; +use crate::generators::{FuzzAccount, account_id_for_key}; use crate::privacy::{ - arb_account, arb_privacy_preserving_tx, arb_validity_window, synthesize_passing_proof, + arb_account, arb_conflicting_nullifier_pair, arb_privacy_preserving_tx, arb_validity_window, + synthesize_passing_proof, }; use nssa::privacy_preserving_transaction::{Message as PPMessage, WitnessSet as PPWitnessSet}; use nssa::{AccountId, PrivacyPreservingTransaction, PrivateKey}; @@ -478,3 +479,140 @@ fn arb_privacy_preserving_tx_generator_invariants() { "garbage-proof rate {garbage}/{oks} is below 1/16 (expected ~1/8)" ); } + +// ── arb_conflicting_nullifier_pair ────────────────────────────────────────────────────── +// The pair builder underpins `fuzz_transaction_ordering_independence`: it must always yield +// two transactions that use *distinct* signers (so a rejection of the second application is +// attributable to the shared nullifier, not a nonce clash) and index only within the account +// set. These tests pin the account-count guard, the collision-repair branch, and the modular +// index arithmetic. + +/// `n` distinct keyed fuzz accounts (`[1; 32]`, `[2; 32]`, …). Distinct nonzero scalars give +/// distinct keys and therefore distinct key-derived signer ids. +fn keyed_accounts(n: u8) -> Vec { + (1..=n) + .map(|i| FuzzAccount { + account_id: AccountId::new([i; 32]), + balance: 1_000_000, + private_key: PrivateKey::try_new([i; 32]).expect("nonzero scalar is a valid key"), + }) + .collect() +} + +/// The key-derived signer ids the validator would recover from a transaction's witness set. +fn signer_ids(tx: &PrivacyPreservingTransaction) -> Vec { + tx.witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| AccountId::from(pk)) + .collect() +} + +/// The builder needs two distinct accounts: fewer than two is always rejected (before any +/// index arithmetic runs), exactly two always succeeds. This pins the `accounts.len() < 2` +/// guard against `==` / `>` / `<=` mutations. +#[test] +fn arb_conflicting_nullifier_pair_requires_two_accounts() { + let accounts = keyed_accounts(2); + let genesis: Vec<(AccountId, u128)> = + accounts.iter().map(|a| (a.account_id, a.balance)).collect(); + let state = crate::genesis::genesis_state(&genesis, vec![]); + + let mut rng = Rng::new(); + let mut buf = vec![0_u8; 512]; + rng.fill(&mut buf); + + // Zero accounts: rejected by the guard. A guard mutated to `== 2` / `> 2` would fall + // through and divide by `accounts.len() == 0`, panicking — also a failure this catches. + let mut u0 = Unstructured::new(&buf); + assert!( + arb_conflicting_nullifier_pair(&mut u0, &state, &[]).is_err(), + "an empty account set must be rejected" + ); + // One account: still rejected — there is no room for two distinct signers. + let mut u1 = Unstructured::new(&buf); + assert!( + arb_conflicting_nullifier_pair(&mut u1, &state, &accounts[..1]).is_err(), + "a single-account set must be rejected" + ); + // Two accounts: must succeed. A guard mutated to `== 2` / `<= 2` would reject this. + let mut u2 = Unstructured::new(&buf); + assert!( + arb_conflicting_nullifier_pair(&mut u2, &state, &accounts).is_ok(), + "two distinct accounts must yield a pair" + ); +} + +/// When both index bytes select the same account (`i == j`), the repair branch +/// `j = (i + 1) % len` must pick the *other* account so the pair keeps distinct signers. +/// Forcing `i == j == 0` over two accounts exercises that branch: the only correct outcome is +/// signers `{account 0, account 1}`. This pins the `j == i` test, the `(i + 1) % len` repair, +/// and the two distinctness guards (`== 2` mutations of them all reject this otherwise-valid +/// input, and the arithmetic mutations either repair to `j == i` again or index out of range). +#[test] +fn arb_conflicting_nullifier_pair_repairs_colliding_indices() { + let accounts = keyed_accounts(2); + let genesis: Vec<(AccountId, u128)> = + accounts.iter().map(|a| (a.account_id, a.balance)).collect(); + let state = crate::genesis::genesis_state(&genesis, vec![]); + + let mut rng = Rng::new(); + let mut buf = vec![0_u8; 512]; + rng.fill(&mut buf); + // The first two bytes are the `i` and `j` index draws; zero both so `i == j == 0`. + buf[0] = 0; + buf[1] = 0; + + let mut u = Unstructured::new(&buf); + let (tx_b, tx_c) = arb_conflicting_nullifier_pair(&mut u, &state, &accounts) + .expect("colliding indices must be repaired into two distinct signers, not rejected"); + + let sb = signer_ids(&tx_b); + let sc = signer_ids(&tx_c); + assert_eq!(sb.len(), 1, "tx_b must carry exactly one signer"); + assert_eq!(sc.len(), 1, "tx_c must carry exactly one signer"); + assert_ne!( + sb[0], sc[0], + "a conflicting pair must use two distinct signers" + ); + + let known: std::collections::HashSet = accounts + .iter() + .map(|a| account_id_for_key(&a.private_key)) + .collect(); + assert!( + known.contains(&sb[0]) && known.contains(&sc[0]), + "both signers must be drawn from the account set" + ); +} + +/// Over many random inputs the index arithmetic must stay within the account slice. A `% len` +/// mutated to `/ len` or `+ len` computes an out-of-range index and panics on `accounts[i]`; +/// the modulo keeps every draw in range and the two signers distinct. +#[test] +fn arb_conflicting_nullifier_pair_indexes_in_range() { + let accounts = keyed_accounts(2); + let genesis: Vec<(AccountId, u128)> = + accounts.iter().map(|a| (a.account_id, a.balance)).collect(); + let state = crate::genesis::genesis_state(&genesis, vec![]); + let known: std::collections::HashSet = accounts + .iter() + .map(|a| account_id_for_key(&a.private_key)) + .collect(); + + let mut rng = Rng::new(); + let mut buf = vec![0_u8; 512]; + for _ in 0..500_usize { + rng.fill(&mut buf); + let mut u = Unstructured::new(&buf); + let (tx_b, tx_c) = arb_conflicting_nullifier_pair(&mut u, &state, &accounts) + .expect("two distinct accounts always yield a pair"); + let sb = signer_ids(&tx_b); + let sc = signer_ids(&tx_c); + assert_ne!(sb[0], sc[0], "conflicting-pair signers must be distinct"); + assert!( + known.contains(&sb[0]) && known.contains(&sc[0]), + "signers must be drawn from the account set" + ); + } +} diff --git a/scripts/add_fuzz_target.py b/scripts/add_fuzz_target.py index 97a753186..20c85b79d 100644 --- a/scripts/add_fuzz_target.py +++ b/scripts/add_fuzz_target.py @@ -1,25 +1,27 @@ #!/usr/bin/env python3 -"""Fully automates registering a new cargo-fuzz / AFL++ fuzz target. +"""Register a new cargo-fuzz / AFL++ fuzz target. Usage: python3 scripts/add_fuzz_target.py Where TARGET_NAME is the full binary name, e.g. fuzz_my_feature. -Actions performed: - 1. Appends a [[bin]] entry to fuzz/Cargo.toml (one entry covers BOTH - the libFuzzer lane and the AFL++ lane — no separate Cargo.toml needed) - 2. Inserts TARGET_NAME into every YAML matrix block in - .github/workflows/fuzz.yml (smoke-fuzz, regression) - 3. Inserts TARGET_NAME into the perf-baseline shell for-loop in - .github/workflows/fuzz.yml +A single `[[bin]]` entry in fuzz/Cargo.toml is the source of truth for BOTH +engines and for every workflow/script: the CI matrices and build loops derive +their target lists from fuzz/Cargo.toml at runtime (via the +`.github/actions/resolve-targets` composite action, or an inline parse in +`scripts/mutants-corpus-test.sh`). Appending the `[[bin]]` is therefore all this +script needs to do — no workflow editing. -NOTE: A single fuzz/Cargo.toml is the source of truth for both engines. - libFuzzer build: cargo fuzz build - AFL++ build: cd fuzz && cargo afl build \\ --no-default-features --features fuzzer-afl \\ --release --bin +The only remaining manual step is the human-authored target tables in README.md +and docs/fuzzing.md, which carry a prose description per target that cannot be +auto-generated. `scripts/check_target_inventory.py` (run in CI) guards those. + Run from the repository root. """ @@ -49,107 +51,6 @@ def append_cargo_bin(target: str, cargo_toml: Path) -> None: print(f" [+] fuzz/Cargo.toml — added [[bin]] {target!r}") -def insert_into_yaml_matrices(target: str, content: str) -> tuple[str, int]: - """Insert target into YAML strategy matrix blocks. - - Matches blocks of the form:: - - target: - - fuzz_a - - fuzz_b - - and appends `` - `` after the last existing entry. - """ - pattern = re.compile( - r"( target:\n(?: - fuzz_\w+\n)+)", - re.MULTILINE, - ) - - def add_target(m: re.Match) -> str: - return m.group(0) + f" - {target}\n" - - new_content, count = pattern.subn(add_target, content) - return new_content, count - - -def insert_into_shell_loop(target: str, content: str) -> tuple[str, int]: - """Insert target into a 'for target in ... ; do' shell loop. - - The last entry in the loop ends with ``; do``. We change it to end with - a backslash continuation and append the new entry with ``; do``. - - Example — before:: - - fuzz_block_verification; do - - After:: - - fuzz_block_verification \\ - fuzz_new_target; do - """ - # Match the last fuzz target in the for-loop: " fuzz_xxx; do" - # Indentation: 12 spaces (inside a run: | block). - pattern = re.compile(r"( fuzz_\w+)(; do)", re.MULTILINE) - - # We only want to replace the *last* occurrence (the closing entry). - matches = list(pattern.finditer(content)) - if not matches: - return content, 0 - - if len(matches) > 1: - print( - f" ERROR: found {len(matches)} shell loops matching the pattern; " - "cannot determine which one to update. " - "Please edit .github/workflows/fuzz.yml manually.", - file=sys.stderr, - ) - sys.exit(1) - - m = matches[-1] - replacement = f"{m.group(1)} \\\n {target}{m.group(2)}" - new_content = content[: m.start()] + replacement + content[m.end() :] - return new_content, 1 - - -def insert_into_workflow(target: str, workflow: Path) -> None: - """Update all target lists in the fuzz workflow file.""" - content = workflow.read_text() - - if target in content: - print(f" SKIP .github/workflows/fuzz.yml — {target!r} already present") - return - - # 1. YAML matrix blocks (smoke-fuzz, regression) - content, yaml_count = insert_into_yaml_matrices(target, content) - if yaml_count: - print( - f" [+] .github/workflows/fuzz.yml — inserted {target!r} into " - f"{yaml_count} YAML matrix block(s)" - ) - else: - print( - f" ERROR: no YAML matrix blocks matched in {workflow} — please edit manually", - file=sys.stderr, - ) - sys.exit(1) - - # 2. Shell for-loop (perf-baseline) - content, loop_count = insert_into_shell_loop(target, content) - if loop_count: - print( - f" [+] .github/workflows/fuzz.yml — inserted {target!r} into " - f"perf-baseline shell loop" - ) - else: - print( - f" ERROR: perf-baseline shell loop not found in {workflow} — please edit manually", - file=sys.stderr, - ) - sys.exit(1) - - workflow.write_text(content) - - def main() -> None: if len(sys.argv) != 2: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) @@ -167,17 +68,11 @@ def main() -> None: root = Path(__file__).parent.parent # repository root cargo_toml = root / "fuzz" / "Cargo.toml" - workflow = root / ".github" / "workflows" / "fuzz.yml" - if not cargo_toml.exists(): print(f"ERROR: {cargo_toml} not found", file=sys.stderr) sys.exit(1) - if not workflow.exists(): - print(f"ERROR: {workflow} not found", file=sys.stderr) - sys.exit(1) append_cargo_bin(target, cargo_toml) - insert_into_workflow(target, workflow) # ── Print build instructions ────────────────────────────────────────────── print() @@ -197,13 +92,11 @@ def main() -> None: print(" 4. Run with libFuzzer: just fuzz-one", target) print(" Run with AFL++: just fuzz-afl", target) print() - print(" 5. This script only edits .github/workflows/fuzz.yml. Add the") - print(" target to the other enumeration sites too, then verify with:") + print(" 5. Every workflow and script derives its target list from") + print(" fuzz/Cargo.toml, so no CI edits are needed. Only add a prose row") + print(" to the two doc tables, then verify with:") print(" python3 scripts/check_target_inventory.py") print(" (the same check runs in CI and will fail the build on drift):") - print(" .github/workflows/fuzz-afl.yml") - print(" .github/workflows/mutants.yml") - print(" scripts/mutants-corpus-test.sh") print(" README.md, docs/fuzzing.md") diff --git a/scripts/check_target_inventory.py b/scripts/check_target_inventory.py index 7c53ff021..a76d90320 100755 --- a/scripts/check_target_inventory.py +++ b/scripts/check_target_inventory.py @@ -1,11 +1,18 @@ #!/usr/bin/env python3 """Fail if any fuzz target registered in fuzz/Cargo.toml is missing from a -workflow / script / doc that enumerates the target list. +human-authored doc that enumerates the target list. `fuzz/Cargo.toml` is the single source of truth: every `[[bin]] name = "fuzz_*"` -must be mentioned by name in each of the consumer files below. This guards -against the drift that `scripts/add_fuzz_target.py` cannot prevent on its own -(it only edits `.github/workflows/fuzz.yml`). +must be mentioned by name in each consumer file below. + +The CI workflows and shell scripts derive their target lists *directly* from +`fuzz/Cargo.toml` at runtime, so they cannot drift and are not checked here: + - `.github/workflows/{fuzz,fuzz-afl}.yml` and the `corpus-update.yml` matrices + use the `.github/actions/resolve-targets` composite action. + - `.github/workflows/mutants.yml` calls the same action for its build loop. + - `scripts/mutants-corpus-test.sh` parses `fuzz/Cargo.toml` inline. +Only the prose target tables in the docs below carry a hand-written description +per target and therefore need this drift gate. Usage: python3 scripts/check_target_inventory.py @@ -19,13 +26,10 @@ import re import sys from pathlib import Path -# Files that enumerate the full target list and must stay in sync with Cargo.toml. +# Human-authored docs whose prose target tables must stay in sync with Cargo.toml. +# (Workflows/scripts auto-derive their lists from Cargo.toml — see the module docstring.) # Paths are relative to the repository root. CONSUMERS = [ - ".github/workflows/fuzz.yml", - ".github/workflows/fuzz-afl.yml", - ".github/workflows/mutants.yml", - "scripts/mutants-corpus-test.sh", "README.md", "docs/fuzzing.md", ] diff --git a/scripts/mutants-corpus-test.sh b/scripts/mutants-corpus-test.sh index 663c8b6fa..0cd962743 100755 --- a/scripts/mutants-corpus-test.sh +++ b/scripts/mutants-corpus-test.sh @@ -20,29 +20,23 @@ FUZZ_REPO="${FUZZ_REPO:-"$(cd "${SCRIPT_DIR}/.." && pwd)"}" CORPUS_ROOT="${FUZZ_REPO}/corpus/libfuzz" FUZZ_DIR="${FUZZ_REPO}/fuzz" -targets=( - fuzz_transaction_decoding - fuzz_stateless_verification - fuzz_state_transition - fuzz_block_verification - fuzz_encoding_roundtrip - fuzz_signature_verification - fuzz_replay_prevention - fuzz_state_diff_computation - fuzz_validate_execute_consistency - fuzz_state_serialization - fuzz_witness_set_verification - fuzz_program_deployment_lifecycle - fuzz_apply_state_diff_split_path - fuzz_multi_block_state_sequence - fuzz_sequencer_vs_replayer - fuzz_merkle_tree - fuzz_transaction_properties - fuzz_privacy_preserving_witness - fuzz_encoding_privacy_preserving - fuzz_nullifier_set_roundtrip - fuzz_privacy_preserving_state_transition +# Derive the target list from fuzz/Cargo.toml (the single source of truth) — the same +# `[[bin]] name = "fuzz_*"` parse that .github/actions/resolve-targets uses. This keeps +# the script in sync with every workflow automatically, with no hand-maintained list. +# (A while-read loop rather than `mapfile`, so this also works under macOS' bash 3.2.) +CARGO_TOML="${FUZZ_DIR}/Cargo.toml" +targets=() +while IFS= read -r _target; do + [ -n "${_target}" ] && targets+=("${_target}") +done < <( + grep -oE 'name = "fuzz_[a-z0-9_]+"' "${CARGO_TOML}" \ + | sed -E 's/.*"(fuzz_[a-z0-9_]+)"/\1/' \ + | awk '!seen[$0]++' ) +if [ "${#targets[@]}" -eq 0 ]; then + echo "ERROR: no fuzz_* [[bin]] targets found in ${CARGO_TOML}" >&2 + exit 1 +fi # cargo-fuzz requires the nightly toolchain (-Zsanitizer=address etc.). # When this script is called by `cargo-mutants` the working directory is the