From 8bd0a1a612d52400372a528777cdb84efd64fb72 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 15 Apr 2026 15:47:01 +0800 Subject: [PATCH] fix: add new fuzz targets - template for adding targets --- .github/workflows/fuzz.yml | 17 +- Cargo.lock | 1 + Justfile | 83 ++++-- docs/fuzzing.md | 87 ++++-- fuzz/Cargo.lock | 1 + fuzz/Cargo.toml | 30 ++ fuzz/fuzz_targets/_template.rs | 23 ++ fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs | 45 +++ fuzz/fuzz_targets/fuzz_replay_prevention.rs | 42 +++ .../fuzz_signature_verification.rs | 55 ++++ .../fuzz_state_diff_computation.rs | 46 ++++ .../fuzz_validate_execute_consistency.rs | 105 +++++++ fuzz_props/Cargo.toml | 1 + fuzz_props/src/arbitrary_types.rs | 256 ++++++++++++++++++ fuzz_props/src/lib.rs | 1 + scripts/add_fuzz_target.py | 177 ++++++++++++ 16 files changed, 936 insertions(+), 34 deletions(-) create mode 100644 fuzz/fuzz_targets/_template.rs create mode 100644 fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs create mode 100644 fuzz/fuzz_targets/fuzz_replay_prevention.rs create mode 100644 fuzz/fuzz_targets/fuzz_signature_verification.rs create mode 100644 fuzz/fuzz_targets/fuzz_state_diff_computation.rs create mode 100644 fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs create mode 100644 fuzz_props/src/arbitrary_types.rs create mode 100644 scripts/add_fuzz_target.py diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index d21b6a4..6a78986 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -25,6 +25,11 @@ jobs: - 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 steps: - uses: actions/checkout@v4 @@ -78,6 +83,11 @@ jobs: - 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 steps: - uses: actions/checkout@v4 - name: Checkout logos-execution-zone alongside lez-fuzzing @@ -130,7 +140,12 @@ jobs: fuzz_transaction_decoding \ fuzz_stateless_verification \ fuzz_state_transition \ - fuzz_block_verification; do + fuzz_block_verification \ + fuzz_encoding_roundtrip \ + fuzz_signature_verification \ + fuzz_replay_prevention \ + fuzz_state_diff_computation \ + fuzz_validate_execute_consistency; 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/Cargo.lock b/Cargo.lock index 2acc035..5c97fac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1738,6 +1738,7 @@ dependencies = [ "borsh", "common", "nssa", + "nssa_core", "proptest", "testnet_initial_state", ] diff --git a/Justfile b/Justfile index 58c21e5..2dc0ac9 100644 --- a/Justfile +++ b/Justfile @@ -1,19 +1,30 @@ # ── Fuzzing ─────────────────────────────────────────────────────────────────── export RISC0_DEV_MODE := "1" -# Run all fuzz targets for TIME seconds each (default: 30) -fuzz TIME="30": - cargo fuzz run fuzz_transaction_decoding -- -max_total_time={{TIME}} - cargo fuzz run fuzz_stateless_verification -- -max_total_time={{TIME}} - cargo fuzz run fuzz_state_transition -- -max_total_time={{TIME}} - cargo fuzz run fuzz_block_verification -- -max_total_time={{TIME}} +# List all registered fuzz targets (reads fuzz/Cargo.toml via cargo-fuzz) +list-targets: + cargo fuzz list -# Re-run the saved corpus (regression mode, no new mutations) +# Run all fuzz targets for TIME seconds each (default: 30). +# Targets are discovered automatically from fuzz/Cargo.toml — no edit needed here +# when a new [[bin]] entry is added. +fuzz TIME="30": + #!/bin/bash + set -euo pipefail + for target in $(cargo fuzz list 2>/dev/null); do + echo "=== fuzzing $target for {{TIME}}s ===" + cargo fuzz run "$target" -- -max_total_time={{TIME}} + done + +# Re-run the saved corpus for every target (regression mode, no new mutations) fuzz-regression: - cargo fuzz run fuzz_transaction_decoding fuzz/corpus/fuzz_transaction_decoding -- -runs=0 - cargo fuzz run fuzz_stateless_verification fuzz/corpus/fuzz_stateless_verification -- -runs=0 - cargo fuzz run fuzz_state_transition fuzz/corpus/fuzz_state_transition -- -runs=0 - cargo fuzz run fuzz_block_verification fuzz/corpus/fuzz_block_verification -- -runs=0 + #!/bin/bash + set -euo pipefail + for target in $(cargo fuzz list 2>/dev/null); do + echo "=== regression $target ===" + mkdir -p "fuzz/corpus/$target" + cargo fuzz run "$target" "fuzz/corpus/$target" -- -runs=0 + done # Minimise a crash artifact # Usage: just fuzz-tmin fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-XXX @@ -30,18 +41,58 @@ update-lez: # ── Corpus management ───────────────────────────────────────────────────────── -# Minimise the corpus for all four targets (removes dominated inputs) +# Minimise the corpus for all targets (removes dominated inputs) corpus-cmin: - cargo fuzz cmin fuzz_transaction_decoding - cargo fuzz cmin fuzz_stateless_verification - cargo fuzz cmin fuzz_state_transition - cargo fuzz cmin fuzz_block_verification + #!/bin/bash + set -euo pipefail + for target in $(cargo fuzz list 2>/dev/null); do + echo "=== cmin $target ===" + cargo fuzz cmin "$target" + done # Minimise the corpus for a single target # Usage: just corpus-cmin-target fuzz_state_transition corpus-cmin-target TARGET: cargo fuzz cmin {{TARGET}} +# ── Adding a new target ─────────────────────────────────────────────────────── + +# Scaffold a new fuzz target — fully automated, no manual edits required. +# +# Steps performed automatically: +# 1. Creates fuzz/corpus// +# 2. Copies fuzz/fuzz_targets/_template.rs → fuzz/fuzz_targets/.rs +# 3. Appends the [[bin]] entry to fuzz/Cargo.toml +# 4. Inserts into every strategy matrix in .github/workflows/fuzz.yml +# +# Usage: just new-target my_feature +# (the "fuzz_" prefix is added automatically) +new-target NAME: + #!/bin/bash + set -euo pipefail + TARGET="fuzz_{{NAME}}" + TEMPLATE="fuzz/fuzz_targets/_template.rs" + RS_FILE="fuzz/fuzz_targets/${TARGET}.rs" + CORPUS_DIR="fuzz/corpus/${TARGET}" + + # ── 1. Create corpus directory ──────────────────────────────────────────── + mkdir -p "$CORPUS_DIR" + echo "[1/4] Created corpus directory: $CORPUS_DIR" + + # ── 2. Copy the typed fuzz target template ──────────────────────────────── + if [ -f "$RS_FILE" ]; then + echo "SKIP [2/4]: $RS_FILE already exists — not overwriting." + else + cp "$TEMPLATE" "$RS_FILE" + echo "[2/4] Created target from template: $RS_FILE" + fi + + # ── 3 & 4. Update Cargo.toml and fuzz.yml automatically ────────────────── + python3 scripts/add_fuzz_target.py "$TARGET" + echo "" + echo "Done! Verify the build with:" + echo " RISC0_DEV_MODE=1 cargo fuzz build ${TARGET}" + # ── Housekeeping ────────────────────────────────────────────────────────────── # Remove all Cargo build artefacts (workspace + fuzz sub-crate) diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 2c59c22..6abefbe 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -72,26 +72,79 @@ just fuzz-regression ## How to Add a New Fuzz Target -1. Create `fuzz/fuzz_targets/fuzz_.rs` using the template below. -2. Add a `[[bin]]` entry to `fuzz/Cargo.toml`. -3. Create an empty seed corpus directory: `mkdir -p fuzz/corpus/fuzz_`. -4. Add the target to the CI matrix in `.github/workflows/fuzz.yml`. -5. Run `RISC0_DEV_MODE=1 cargo fuzz build fuzz_` to verify it compiles. +### Step 1 — Scaffold with `just new-target` -**Template:** - -```rust -#![no_main] -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - // 1. Parse / decode `data` into your target type - // 2. Call the function under test - // 3. Assert invariants using `fuzz_props::invariants::assert_invariants()` - // 4. Never panic on invalid input; only panic on invariant violations -}); +```bash +just new-target my_feature ``` +This single command does four things automatically: + +| What | Where | +|---|---| +| 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 | `fuzz/Cargo.toml` | +| Inserts target into every CI matrix + perf loop | `.github/workflows/fuzz.yml` | + +The generated template uses `ArbNSSATransaction` from `fuzz_props::arbitrary_types` +so libfuzzer drives every field of `NSSATransaction` independently — no manual +`Unstructured` wiring required. + +### Step 2 — Implement the target + +Edit `fuzz/fuzz_targets/fuzz_my_feature.rs`. Replace the placeholder with the +function under test and any invariant assertions. Use the typed wrappers from +[`fuzz_props::arbitrary_types`](../fuzz_props/src/arbitrary_types.rs) for +structured input, or the proptest generators from +[`fuzz_props::generators`](../fuzz_props/src/generators.rs) for richer strategies. + +### Step 3 — Register the binary (automated) + +`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) +automatically. Once present, `cargo fuzz list` (and therefore `just fuzz`, +`just fuzz-regression`, `just corpus-cmin`) pick up the target automatically — no +further Justfile edits required. + +> **Manual fallback:** if you create a target without `just new-target`, add the +> entry yourself: +> +> ```toml +> [[bin]] +> name = "fuzz_my_feature" +> path = "fuzz_targets/fuzz_my_feature.rs" +> test = false +> bench = false +> ``` + +### Step 4 — Add to CI matrix (automated) + +`just new-target` also inserts `fuzz_my_feature` into every strategy matrix and the +perf-baseline shell loop in [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml) +automatically via `scripts/add_fuzz_target.py`. + +> **Manual fallback:** if you created the target without `just new-target`, add +> `- fuzz_my_feature` to the `target:` list in the three places shown in +> `.github/workflows/fuzz.yml` (smoke-fuzz, regression, perf-baseline). + +### Step 5 — Verify + +```bash +RISC0_DEV_MODE=1 cargo fuzz build fuzz_my_feature +just fuzz-regression # runs the new target against its (empty) corpus +``` + +### Quick reference: what to touch + +| File | Action | Automated? | +|---|---|---| +| `fuzz/fuzz_targets/fuzz_.rs` | Create | ✅ `just new-target` | +| `fuzz/corpus/fuzz_/` | Create | ✅ `just new-target` | +| `fuzz/Cargo.toml` | Add `[[bin]]` | ✅ `just new-target` | +| `Justfile` | Nothing — auto-discovers | ✅ automatic | +| `.github/workflows/fuzz.yml` | Add to 3 matrix lists | ✅ `just new-target` | + --- ## Updating the LEZ Dependency diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 486a5d6..1ea5efd 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1754,6 +1754,7 @@ dependencies = [ "borsh", "common", "nssa", + "nssa_core", "proptest", "testnet_initial_state", ] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index f657a9a..7178ee0 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -47,3 +47,33 @@ testnet_initial_state = { path = "../../logos-execution-zone/testnet_initial_sta [profile.release] debug = true opt-level = 3 + +[[bin]] +name = "fuzz_encoding_roundtrip" +path = "fuzz_targets/fuzz_encoding_roundtrip.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_signature_verification" +path = "fuzz_targets/fuzz_signature_verification.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_replay_prevention" +path = "fuzz_targets/fuzz_replay_prevention.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_state_diff_computation" +path = "fuzz_targets/fuzz_state_diff_computation.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_validate_execute_consistency" +path = "fuzz_targets/fuzz_validate_execute_consistency.rs" +test = false +bench = false diff --git a/fuzz/fuzz_targets/_template.rs b/fuzz/fuzz_targets/_template.rs new file mode 100644 index 0000000..e4a4bcc --- /dev/null +++ b/fuzz/fuzz_targets/_template.rs @@ -0,0 +1,23 @@ +#![no_main] + +use fuzz_props::arbitrary_types::ArbNSSATransaction; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|wrapped: ArbNSSATransaction| { + let tx = wrapped.0; + + // ── Stateless gate ──────────────────────────────────────────────────────── + // Remove this block to fuzz malformed / unsigned transactions too. + let Ok(tx) = tx.transaction_stateless_check() else { + return; + }; + + // ── Call the function under test ────────────────────────────────────────── + // Example: + // let mut state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); + // let result = tx.execute_check_on_state(&mut state, block_id, timestamp); + + // ── Assert invariants ───────────────────────────────────────────────────── + // Use fuzz_props::invariants::assert_invariants(&ctx) or inline assertions. + let _ = tx; // replace once the target body is implemented +}); diff --git a/fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs b/fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs new file mode 100644 index 0000000..599a489 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs @@ -0,0 +1,45 @@ +#![no_main] +//! Fuzz target: encoding round-trip for all transaction types. +//! +//! Invariant: `decode(encode(tx)) == Ok(tx)` and `encode(decode(encode(tx))) == encode(tx)` +//! for every `PublicTransaction` and `ProgramDeploymentTransaction`. +//! +//! `PrivacyPreservingTransaction` is excluded because its ZK receipt cannot be +//! reconstructed in a fuzzing loop. + +use arbitrary::{Arbitrary, Unstructured}; +use fuzz_props::arbitrary_types::{ArbProgramDeploymentTransaction, ArbPublicTransaction}; +use libfuzzer_sys::fuzz_target; +use nssa::{ProgramDeploymentTransaction, PublicTransaction}; + +fuzz_target!(|data: &[u8]| { + let mut u = Unstructured::new(data); + + // ── Test 1: PublicTransaction round-trip ────────────────────────────────── + if let Ok(wrapped) = ArbPublicTransaction::arbitrary(&mut u) { + let tx = wrapped.0; + let encoded = tx.to_bytes(); + let decoded = PublicTransaction::from_bytes(&encoded) + .expect("INVARIANT VIOLATION: PublicTransaction::to_bytes() produced un-decodable output"); + let re_encoded = decoded.to_bytes(); + assert_eq!( + encoded, + re_encoded, + "INVARIANT VIOLATION: encode(decode(encode(tx))) != encode(tx) for PublicTransaction" + ); + } + + // ── Test 2: ProgramDeploymentTransaction round-trip ─────────────────────── + if let Ok(wrapped) = ArbProgramDeploymentTransaction::arbitrary(&mut u) { + let tx = wrapped.0; + let encoded = tx.to_bytes(); + let decoded = ProgramDeploymentTransaction::from_bytes(&encoded) + .expect("INVARIANT VIOLATION: ProgramDeploymentTransaction::to_bytes() produced un-decodable output"); + let re_encoded = decoded.to_bytes(); + assert_eq!( + encoded, + re_encoded, + "INVARIANT VIOLATION: encode(decode(encode(tx))) != encode(tx) for ProgramDeploymentTransaction" + ); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_replay_prevention.rs b/fuzz/fuzz_targets/fuzz_replay_prevention.rs new file mode 100644 index 0000000..547e21d --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_replay_prevention.rs @@ -0,0 +1,42 @@ +#![no_main] +//! Fuzz target: transaction replay prevention. +//! +//! Invariant: a transaction that is accepted in block N must be rejected when +//! replayed in block N+1, because the nonce is consumed on first acceptance. +//! +//! `execute_check_on_state` returns the transaction back on success (`Ok(tx)`), +//! so we can feed the same struct to the second application without cloning. + +use arbitrary::Unstructured; +use fuzz_props::generators::arbitrary_transaction; +use libfuzzer_sys::fuzz_target; +use nssa::V03State; +use testnet_initial_state::initial_accounts; + +fuzz_target!(|data: &[u8]| { + let mut u = Unstructured::new(data); + + let accs_data = initial_accounts(); + let init_accs: Vec<(nssa::AccountId, u128)> = accs_data + .iter() + .map(|a| (a.account_id, a.balance)) + .collect(); + let mut state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); + + let Ok(tx) = arbitrary_transaction(&mut u) else { return; }; + + // Stateless gate: skip structurally malformed transactions. + let Ok(tx) = tx.transaction_stateless_check() else { return; }; + + // First application — may legitimately fail for state-level reasons. + let result = tx.execute_check_on_state(&mut state, 1, 0); + + if let Ok(tx) = result { + // tx is returned on success; try applying the identical transaction again. + let result2 = tx.execute_check_on_state(&mut state, 2, 1); + assert!( + result2.is_err(), + "INVARIANT VIOLATION: transaction accepted a second time — nonce replay not prevented" + ); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_signature_verification.rs b/fuzz/fuzz_targets/fuzz_signature_verification.rs new file mode 100644 index 0000000..97a53ff --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_signature_verification.rs @@ -0,0 +1,55 @@ +#![no_main] +//! Fuzz target: signature creation and verification. +//! +//! Invariants exercised: +//! +//! 1. **Correctness** — `Signature::new(key, msg).is_valid_for(msg, pub_key)` is always `true` +//! for the matching public key. +//! 2. **No panics** — random (possibly invalid) signatures and public keys must never cause a +//! panic in `is_valid_for`. +//! 3. **Cross-key soundness** — signing with key A and verifying against key B must not panic +//! (the result may be `false` or, with negligible probability, accidentally `true`). + +use arbitrary::{Arbitrary, Unstructured}; +use fuzz_props::arbitrary_types::{ArbPrivateKey, ArbPublicKey, ArbSignature}; +use libfuzzer_sys::fuzz_target; +use nssa::{PublicKey, Signature}; + +fuzz_target!(|data: &[u8]| { + let mut u = Unstructured::new(data); + + // ── 1. Freshly signed message always verifies with the correct key ───────── + if let Ok(key_wrap) = ArbPrivateKey::arbitrary(&mut u) { + let private_key = key_wrap.0; + let public_key = PublicKey::new_from_private_key(&private_key); + let msg: Vec = u.arbitrary().unwrap_or_default(); + + let sig = Signature::new(&private_key, &msg); + assert!( + sig.is_valid_for(&msg, &public_key), + "INVARIANT VIOLATION: Signature::new + is_valid_for returned false for the signing key" + ); + } + + // ── 2. Random bytes as signature must never panic ────────────────────────── + if let (Ok(sig_wrap), Ok(pk_wrap)) = ( + ArbSignature::arbitrary(&mut u), + ArbPublicKey::arbitrary(&mut u), + ) { + let msg: Vec = u.arbitrary().unwrap_or_default(); + // The result may be true or false — we only assert no panic. + let _ = sig_wrap.0.is_valid_for(&msg, &pk_wrap.0); + } + + // ── 3. Cross-key verification must not panic ─────────────────────────────── + if let (Ok(key_a_wrap), Ok(key_b_wrap)) = ( + ArbPrivateKey::arbitrary(&mut u), + ArbPrivateKey::arbitrary(&mut u), + ) { + let public_b = PublicKey::new_from_private_key(&key_b_wrap.0); + let msg: Vec = u.arbitrary().unwrap_or_default(); + let sig_from_a = Signature::new(&key_a_wrap.0, &msg); + // Must not panic regardless of key mismatch. + let _ = sig_from_a.is_valid_for(&msg, &public_b); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_state_diff_computation.rs b/fuzz/fuzz_targets/fuzz_state_diff_computation.rs new file mode 100644 index 0000000..e6b4c5b --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_state_diff_computation.rs @@ -0,0 +1,46 @@ +#![no_main] +//! Fuzz target: state diff isolation. +//! +//! Invariant: `ValidatedStateDiff::from_public_transaction` must only produce +//! account changes for accounts that appear in `tx.affected_public_account_ids()`. +//! +//! A diff that modifies an account outside that set would allow a transaction +//! to silently corrupt unrelated accounts' balances. + +use fuzz_props::arbitrary_types::ArbPublicTransaction; +use libfuzzer_sys::fuzz_target; +use nssa::{V03State, ValidatedStateDiff}; +use testnet_initial_state::initial_accounts; + +fuzz_target!(|wrapped: ArbPublicTransaction| { + let pub_tx = wrapped.0; + + let accs_data = initial_accounts(); + let init_accs: Vec<(nssa::AccountId, u128)> = accs_data + .iter() + .map(|a| (a.account_id, a.balance)) + .collect(); + let state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); + + // Collect the set of accounts the transaction claims to touch. + let affected = pub_tx.affected_public_account_ids(); + + match ValidatedStateDiff::from_public_transaction(&pub_tx, &state, 1, 0) { + Ok(diff) => { + // INVARIANT: every key in the public diff must be in `affected`. + let public_diff = diff.public_diff(); + for changed_id in public_diff.keys() { + assert!( + affected.contains(changed_id), + "INVARIANT VIOLATION: diff modified account {:?} which is not in \ + affected_public_account_ids() {:?}", + changed_id, + affected + ); + } + } + Err(_) => { + // Validation failure is expected for structurally or semantically invalid inputs. + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs new file mode 100644 index 0000000..e20158f --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs @@ -0,0 +1,105 @@ +#![no_main] +//! Fuzz target: `validate_on_state` and `execute_check_on_state` consistency. +//! +//! Invariants: +//! +//! 1. **Agreement** — both methods must agree on success or failure for the same +//! transaction and state. A divergence (one succeeds, the other fails) is a bug. +//! +//! 2. **Diff accuracy (bidirectional)** — when both succeed: +//! - every account change recorded in the `ValidatedStateDiff` returned by +//! `validate_on_state` must exactly match the post-execution state, AND +//! - every account changed by `execute_check_on_state` must appear in the diff; +//! a silent state-widening bug (execute touches an extra account not declared +//! in the diff) is caught by the reverse check. + +use fuzz_props::arbitrary_types::ArbNSSATransaction; +use libfuzzer_sys::fuzz_target; +use nssa::V03State; +use testnet_initial_state::initial_accounts; + +fuzz_target!(|wrapped: ArbNSSATransaction| { + let tx = wrapped.0; + + // Stateless gate — skip structurally malformed transactions. + let Ok(tx) = tx.transaction_stateless_check() else { return; }; + + let accs_data = initial_accounts(); + let init_accs: Vec<(nssa::AccountId, u128)> = accs_data + .iter() + .map(|a| (a.account_id, a.balance)) + .collect(); + let state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); + + // validate_on_state borrows `tx` and `state` — does NOT mutate state. + let validate_result = tx.validate_on_state(&state, 1, 0); + + // execute_check_on_state consumes `tx` and mutates `exec_state`. + let mut exec_state = state.clone(); + let execute_result = tx.execute_check_on_state(&mut exec_state, 1, 0); + + // INVARIANT 1: both must agree on success vs failure. + match (validate_result, execute_result) { + (Ok(diff), Ok(_)) => { + let public_diff = diff.public_diff(); + + // INVARIANT 2a (forward): every account in the diff matches the post-execute state. + for (account_id, expected_account) in &public_diff { + let actual = exec_state.get_account_by_id(*account_id); + assert_eq!( + *expected_account, + actual, + "INVARIANT VIOLATION: validate diff and execute state disagree \ + for account {:?}", + account_id + ); + } + + // INVARIANT 2b (reverse): every account changed by execute_check_on_state must + // be captured in the validate diff. A silent state-widening bug — where + // execute modifies accounts that validate_on_state did not declare — would + // pass the forward check above but is caught here. + // + // We check a superset of accounts: genesis accounts PLUS any account the + // diff explicitly declares. This covers the common case of both mutations + // to existing accounts and accounts the diff itself declares as new. + // + // Known limitation: if execute_check_on_state creates a brand-new account + // that is absent from both the genesis set and the diff, that state-widening + // will NOT be detected here. Full detection would require iterating over all + // accounts in exec_state, which V03State does not currently expose. + let mut all_checked_ids: std::collections::HashSet = + init_accs.iter().map(|&(id, _)| id).collect(); + for acc_id in public_diff.keys() { + all_checked_ids.insert(*acc_id); + } + for acc_id in all_checked_ids { + let before = state.get_account_by_id(acc_id); + let after = exec_state.get_account_by_id(acc_id); + if before != after { + assert!( + public_diff.contains_key(&acc_id), + "INVARIANT VIOLATION: execute_check_on_state modified account {:?} \ + which is absent from validate_on_state diff", + acc_id + ); + } + } + } + (Err(_), Err(_)) => { + // Both failed — correct. + } + (Ok(_), Err(e)) => { + panic!( + "INVARIANT VIOLATION: validate_on_state succeeded but \ + execute_check_on_state failed: {e:?}" + ); + } + (Err(e), Ok(_)) => { + panic!( + "INVARIANT VIOLATION: validate_on_state failed but \ + execute_check_on_state succeeded: {e:?}" + ); + } + } +}); diff --git a/fuzz_props/Cargo.toml b/fuzz_props/Cargo.toml index a715b32..ad96ec6 100644 --- a/fuzz_props/Cargo.toml +++ b/fuzz_props/Cargo.toml @@ -8,6 +8,7 @@ workspace = true [dependencies] nssa = { workspace = true } +nssa_core = { workspace = true } common = { workspace = true } borsh = { workspace = true } proptest = "1.4" diff --git a/fuzz_props/src/arbitrary_types.rs b/fuzz_props/src/arbitrary_types.rs new file mode 100644 index 0000000..0176da4 --- /dev/null +++ b/fuzz_props/src/arbitrary_types.rs @@ -0,0 +1,256 @@ +//! Newtype wrappers that implement [`arbitrary::Arbitrary`] for LEZ types. +//! +//! **No changes to `../logos-execution-zone` are required.** +//! +//! The Rust orphan rule forbids `impl Arbitrary for NSSATransaction` when both +//! the trait and the type come from external crates. Using newtypes (`ArbXxx`) +//! sidesteps the restriction entirely. +//! +//! # Usage in a fuzz target +//! +//! ```rust,ignore +//! #![no_main] +//! use fuzz_props::arbitrary_types::ArbNSSATransaction; +//! use libfuzzer_sys::fuzz_target; +//! +//! fuzz_target!(|wrapped: ArbNSSATransaction| { +//! let tx = wrapped.0; +//! let Ok(valid_tx) = tx.transaction_stateless_check() else { return; }; +//! // … +//! }); +//! ``` + +use arbitrary::{Arbitrary, Result as ArbResult, Unstructured}; +use common::{HashType, block::HashableBlockData, transaction::NSSATransaction}; +use nssa::{ + AccountId, PrivateKey, PublicKey, Signature, + program_deployment_transaction::ProgramDeploymentTransaction, + public_transaction::{Message, PublicTransaction, WitnessSet}, +}; +use nssa_core::account::Nonce; + +// ── AccountId ───────────────────────────────────────────────────────────────── +// `AccountId::new([u8; 32])` accepts any byte array — no validity constraint. + +/// Newtype wrapper providing [`Arbitrary`] for [`AccountId`]. +#[derive(Debug)] +pub struct ArbAccountId(pub AccountId); + +impl<'a> Arbitrary<'a> for ArbAccountId { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + Ok(Self(AccountId::new(<[u8; 32]>::arbitrary(u)?))) + } +} + +// ── Nonce ───────────────────────────────────────────────────────────────────── +// `Nonce` wraps `u128` and exposes `From`. + +/// Newtype wrapper providing [`Arbitrary`] for [`Nonce`]. +#[derive(Debug)] +pub struct ArbNonce(pub Nonce); + +impl<'a> Arbitrary<'a> for ArbNonce { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + Ok(Self(Nonce::from(u128::arbitrary(u)?))) + } +} + +// ── Signature ───────────────────────────────────────────────────────────────── +// `Signature.value` is `pub [u8; 64]`, so we can construct a value directly. +// Cryptographic validity is only checked at verification time, meaning invalid +// byte patterns are legal at the struct level and will exercise the rejection +// path in `WitnessSet::is_valid_for`. + +/// Newtype wrapper providing [`Arbitrary`] for [`Signature`]. +#[derive(Debug)] +pub struct ArbSignature(pub Signature); + +impl<'a> Arbitrary<'a> for ArbSignature { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + Ok(Self(Signature { value: <[u8; 64]>::arbitrary(u)? })) + } +} + +// ── PrivateKey ──────────────────────────────────────────────────────────────── +// `PrivateKey::try_new` succeeds for almost all non-zero 32-byte values: only +// the zero scalar and values ≥ the secp256k1 group order (< 2⁻¹²⁸ of the +// input space) are rejected. A known-good fallback handles the rare failure. + +/// Newtype wrapper providing [`Arbitrary`] for [`PrivateKey`]. +#[derive(Debug)] +pub struct ArbPrivateKey(pub PrivateKey); + +impl<'a> Arbitrary<'a> for ArbPrivateKey { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + let bytes = <[u8; 32]>::arbitrary(u)?; + let key = PrivateKey::try_new(bytes) + .unwrap_or_else(|_| PrivateKey::try_new([1_u8; 32]).expect("known-good seed")); + Ok(Self(key)) + } +} + +// ── PublicKey ───────────────────────────────────────────────────────────────── +// `PublicKey::try_new` validates that the bytes form a valid secp256k1 +// x-coordinate (roughly 50% of random inputs succeed). Two modes: +// 1. Derive from a valid `PrivateKey` → exercises the happy-path verification. +// 2. Use raw bytes → exercises the rejection path in `is_valid_for`; on +// construction failure falls back to a derived key so upstream callers +// (ArbWitnessSet, ArbPublicTransaction) are not silently discarded. + +/// Newtype wrapper providing [`Arbitrary`] for [`PublicKey`]. +#[derive(Debug)] +pub struct ArbPublicKey(pub PublicKey); + +impl<'a> Arbitrary<'a> for ArbPublicKey { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + if bool::arbitrary(u)? { + // Valid key pair — exercises happy-path signature verification. + let pk = PublicKey::new_from_private_key(&ArbPrivateKey::arbitrary(u)?.0); + Ok(Self(pk)) + } else { + // Raw bytes — may be an invalid x-coordinate, exercises the rejection + // path in `is_valid_for`. On failure we fall back to a key derived + // from a valid private key so that upstream callers (ArbWitnessSet, + // ArbPublicTransaction) are not silently discarded ~25% of the time. + // The ArbSignature type (random bytes) already exercises the full + // rejection path in `is_valid_for` independently. + let bytes = <[u8; 32]>::arbitrary(u)?; + let pk = PublicKey::try_new(bytes) + .unwrap_or_else(|_| PublicKey::new_from_private_key(&ArbPrivateKey::arbitrary(u).map(|w| w.0).unwrap_or_else(|_| PrivateKey::try_new([1_u8; 32]).expect("known-good seed")))); + Ok(Self(pk)) + } + } +} + +// ── Message (public transaction) ────────────────────────────────────────────── +// `Message::new_preserialized` takes all fields directly without any validity +// constraint — any combination of program_id, account_ids, nonces, and +// instruction_data is accepted. + +/// Newtype wrapper providing [`Arbitrary`] for the public-transaction [`Message`]. +#[derive(Debug)] +pub struct ArbPubTxMessage(pub Message); + +impl<'a> Arbitrary<'a> for ArbPubTxMessage { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + let program_id: [u32; 8] = <[u32; 8]>::arbitrary(u)?; + // Generate 0–7 accounts; nonces vector is given the same length. + let len = (u8::arbitrary(u)? as usize) % 8; + let account_ids = (0..len) + .map(|_| ArbAccountId::arbitrary(u).map(|a| a.0)) + .collect::>>()?; + let nonces = (0..len) + .map(|_| ArbNonce::arbitrary(u).map(|n| n.0)) + .collect::>>()?; + let instruction_data: Vec = Vec::::arbitrary(u)?; + Ok(Self(Message::new_preserialized( + program_id, + account_ids, + nonces, + instruction_data, + ))) + } +} + +// ── WitnessSet ──────────────────────────────────────────────────────────────── +// `WitnessSet::from_raw_parts` accepts any `Vec<(Signature, PublicKey)>`. +// We deliberately mix valid and invalid pairs so the fuzzer exercises both +// the accept and reject branches of `WitnessSet::is_valid_for`. + +/// Newtype wrapper providing [`Arbitrary`] for [`WitnessSet`]. +#[derive(Debug)] +pub struct ArbWitnessSet(pub WitnessSet); + +impl<'a> Arbitrary<'a> for ArbWitnessSet { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + // 0–3 (signature, public_key) pairs + let n = (u8::arbitrary(u)? as usize) % 4; + let pairs = (0..n) + .map(|_| { + Ok(( + ArbSignature::arbitrary(u)?.0, + ArbPublicKey::arbitrary(u)?.0, + )) + }) + .collect::>>()?; + Ok(Self(WitnessSet::from_raw_parts(pairs))) + } +} + +// ── PublicTransaction ───────────────────────────────────────────────────────── + +/// Newtype wrapper providing [`Arbitrary`] for [`PublicTransaction`]. +#[derive(Debug)] +pub struct ArbPublicTransaction(pub PublicTransaction); + +impl<'a> Arbitrary<'a> for ArbPublicTransaction { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + Ok(Self(PublicTransaction::new( + ArbPubTxMessage::arbitrary(u)?.0, + ArbWitnessSet::arbitrary(u)?.0, + ))) + } +} + +// ── ProgramDeploymentTransaction ────────────────────────────────────────────── +// `ProgramDeploymentTransaction` wraps a single `Message { bytecode: Vec }`. + +/// Newtype wrapper providing [`Arbitrary`] for [`ProgramDeploymentTransaction`]. +#[derive(Debug)] +pub struct ArbProgramDeploymentTransaction(pub ProgramDeploymentTransaction); + +impl<'a> Arbitrary<'a> for ArbProgramDeploymentTransaction { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + let bytecode = Vec::::arbitrary(u)?; + let msg = nssa::program_deployment_transaction::Message::new(bytecode); + Ok(Self(ProgramDeploymentTransaction::new(msg))) + } +} + +// ── NSSATransaction ─────────────────────────────────────────────────────────── +// `PrivacyPreservingTransaction` is intentionally excluded: it embeds a risc0 +// ZK receipt that cannot be generated inside a hot fuzzing loop. This matches +// the known limitation documented in `docs/fuzzing.md`. + +/// Newtype wrapper providing [`Arbitrary`] for [`NSSATransaction`]. +/// +/// Generates `Public` and `ProgramDeployment` variants only. +#[derive(Debug)] +pub struct ArbNSSATransaction(pub NSSATransaction); + +impl<'a> Arbitrary<'a> for ArbNSSATransaction { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + match u8::arbitrary(u)? % 2 { + 0 => Ok(Self(NSSATransaction::Public( + ArbPublicTransaction::arbitrary(u)?.0, + ))), + _ => Ok(Self(NSSATransaction::ProgramDeployment( + ArbProgramDeploymentTransaction::arbitrary(u)?.0, + ))), + } + } +} + +// ── HashableBlockData ───────────────────────────────────────────────────────── +// All fields of `HashableBlockData` are `pub`, so we can construct it with a +// struct literal after generating each field independently. + +/// Newtype wrapper providing [`Arbitrary`] for [`HashableBlockData`]. +#[derive(Debug)] +pub struct ArbHashableBlockData(pub HashableBlockData); + +impl<'a> Arbitrary<'a> for ArbHashableBlockData { + fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult { + // 0–7 transactions per block + let n = (u8::arbitrary(u)? as usize) % 8; + let transactions = (0..n) + .map(|_| ArbNSSATransaction::arbitrary(u).map(|t| t.0)) + .collect::>>()?; + Ok(Self(HashableBlockData { + block_id: u64::arbitrary(u)?, + prev_block_hash: HashType(<[u8; 32]>::arbitrary(u)?), + timestamp: u64::arbitrary(u)?, + transactions, + })) + } +} diff --git a/fuzz_props/src/lib.rs b/fuzz_props/src/lib.rs index 75157b1..6bfa46e 100644 --- a/fuzz_props/src/lib.rs +++ b/fuzz_props/src/lib.rs @@ -2,6 +2,7 @@ #![allow(clippy::missing_docs_in_private_items)] +pub mod arbitrary_types; pub mod generators; pub mod invariants; diff --git a/scripts/add_fuzz_target.py b/scripts/add_fuzz_target.py new file mode 100644 index 0000000..52bd5a6 --- /dev/null +++ b/scripts/add_fuzz_target.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Fully automates registering a new cargo-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 + 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 + +Run from the repository root. +""" + +import re +import sys +from pathlib import Path + +# Target names must follow the cargo-fuzz binary naming convention. +_TARGET_RE = re.compile(r"^fuzz_[a-z][a-z0-9_]*$") + + +def append_cargo_bin(target: str, cargo_toml: Path) -> None: + """Append a [[bin]] entry to fuzz/Cargo.toml if not already present.""" + content = cargo_toml.read_text() + if f'name = "{target}"' in content: + print(f" SKIP fuzz/Cargo.toml — [[bin]] {target!r} already present") + return + + entry = ( + f"\n[[bin]]\n" + f'name = "{target}"\n' + f'path = "fuzz_targets/{target}.rs"\n' + f"test = false\n" + f"bench = false\n" + ) + cargo_toml.write_text(content.rstrip("\n") + "\n" + entry) + 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) + sys.exit(1) + + target = sys.argv[1] + + if not _TARGET_RE.match(target): + print( + f"ERROR: target name must match fuzz_[a-z][a-z0-9_]*, got: {target!r}", + file=sys.stderr, + ) + sys.exit(1) + + 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) + + +if __name__ == "__main__": + main()