mirror of
https://github.com/logos-blockchain/lez-fuzzing.git
synced 2026-07-23 01:59:30 +00:00
Merge pull request #14 from logos-blockchain/test-transaction-ordering-independence
test: Transaction ordering independence
This commit is contained in:
commit
eb71679d57
35
.github/workflows/fuzz-afl.yml
vendored
35
.github/workflows/fuzz-afl.yml
vendored
@ -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
|
||||
|
||||
83
.github/workflows/fuzz.yml
vendored
83
.github/workflows/fuzz.yml
vendored
@ -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
|
||||
|
||||
25
.github/workflows/mutants.yml
vendored
25
.github/workflows/mutants.yml
vendored
@ -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
|
||||
|
||||
|
||||
17
README.md
17
README.md
@ -6,7 +6,7 @@
|
||||
[Logos Execution Zone (LEZ)](https://github.com/logos-blockchain/logos-execution-zone) protocol.**
|
||||
|
||||
[](rust-toolchain.toml)
|
||||
[](#-fuzz-targets)
|
||||
[](#-fuzz-targets)
|
||||
[](.github/workflows/mutants.yml)
|
||||
[](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/<name>.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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
corpus/libfuzz/fuzz_state_transition/seed_empty_tx
generated
BIN
corpus/libfuzz/fuzz_state_transition/seed_empty_tx
generated
Binary file not shown.
BIN
corpus/libfuzz/fuzz_stateless_verification/seed_empty_tx
generated
BIN
corpus/libfuzz/fuzz_stateless_verification/seed_empty_tx
generated
Binary file not shown.
BIN
corpus/libfuzz/fuzz_transaction_decoding/seed_empty_tx
generated
BIN
corpus/libfuzz/fuzz_transaction_decoding/seed_empty_tx
generated
Binary file not shown.
@ -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 |
|
||||
|
||||
|
||||
@ -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_<name>/` | 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`
|
||||
|
||||
@ -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
|
||||
|
||||
133
fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs
Normal file
133
fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs
Normal file
@ -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> {
|
||||
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",
|
||||
);
|
||||
});
|
||||
@ -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<Commitment>,
|
||||
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))
|
||||
}
|
||||
|
||||
@ -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<FuzzAccount> {
|
||||
(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<AccountId> {
|
||||
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<AccountId> = 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<AccountId> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <TARGET_NAME>
|
||||
|
||||
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 <TARGET>
|
||||
- AFL++ build: cd fuzz && cargo afl build \\
|
||||
--no-default-features --features fuzzer-afl \\
|
||||
--release --bin <TARGET>
|
||||
|
||||
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 `` - <target>`` 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]} <TARGET_NAME>", 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")
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user