fix: add new fuzz targets

- template for adding targets
This commit is contained in:
Roman 2026-04-15 15:47:01 +08:00
parent 1cb6d314f1
commit 8bd0a1a612
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
16 changed files with 936 additions and 34 deletions

View File

@ -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

1
Cargo.lock generated
View File

@ -1738,6 +1738,7 @@ dependencies = [
"borsh",
"common",
"nssa",
"nssa_core",
"proptest",
"testnet_initial_state",
]

View File

@ -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/<TARGET>/
# 2. Copies fuzz/fuzz_targets/_template.rs → fuzz/fuzz_targets/<TARGET>.rs
# 3. Appends the [[bin]] entry to fuzz/Cargo.toml
# 4. Inserts <TARGET> 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)

View File

@ -72,26 +72,79 @@ just fuzz-regression
## How to Add a New Fuzz Target
1. Create `fuzz/fuzz_targets/fuzz_<name>.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_<name>`.
4. Add the target to the CI matrix in `.github/workflows/fuzz.yml`.
5. Run `RISC0_DEV_MODE=1 cargo fuzz build fuzz_<name>` 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_<name>.rs` | Create | ✅ `just new-target` |
| `fuzz/corpus/fuzz_<name>/` | 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

1
fuzz/Cargo.lock generated
View File

@ -1754,6 +1754,7 @@ dependencies = [
"borsh",
"common",
"nssa",
"nssa_core",
"proptest",
"testnet_initial_state",
]

View File

@ -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

View File

@ -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
});

View File

@ -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"
);
}
});

View File

@ -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"
);
}
});

View File

@ -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<u8> = 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<u8> = 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<u8> = 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);
}
});

View File

@ -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.
}
}
});

View File

@ -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<nssa::AccountId> =
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:?}"
);
}
}
});

View File

@ -8,6 +8,7 @@ workspace = true
[dependencies]
nssa = { workspace = true }
nssa_core = { workspace = true }
common = { workspace = true }
borsh = { workspace = true }
proptest = "1.4"

View File

@ -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<Self> {
Ok(Self(AccountId::new(<[u8; 32]>::arbitrary(u)?)))
}
}
// ── Nonce ─────────────────────────────────────────────────────────────────────
// `Nonce` wraps `u128` and exposes `From<u128>`.
/// 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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
let program_id: [u32; 8] = <[u32; 8]>::arbitrary(u)?;
// Generate 07 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::<ArbResult<Vec<_>>>()?;
let nonces = (0..len)
.map(|_| ArbNonce::arbitrary(u).map(|n| n.0))
.collect::<ArbResult<Vec<_>>>()?;
let instruction_data: Vec<u32> = Vec::<u32>::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<Self> {
// 03 (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::<ArbResult<Vec<_>>>()?;
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<Self> {
Ok(Self(PublicTransaction::new(
ArbPubTxMessage::arbitrary(u)?.0,
ArbWitnessSet::arbitrary(u)?.0,
)))
}
}
// ── ProgramDeploymentTransaction ──────────────────────────────────────────────
// `ProgramDeploymentTransaction` wraps a single `Message { bytecode: Vec<u8> }`.
/// 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<Self> {
let bytecode = Vec::<u8>::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<Self> {
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<Self> {
// 07 transactions per block
let n = (u8::arbitrary(u)? as usize) % 8;
let transactions = (0..n)
.map(|_| ArbNSSATransaction::arbitrary(u).map(|t| t.0))
.collect::<ArbResult<Vec<_>>>()?;
Ok(Self(HashableBlockData {
block_id: u64::arbitrary(u)?,
prev_block_hash: HashType(<[u8; 32]>::arbitrary(u)?),
timestamp: u64::arbitrary(u)?,
transactions,
}))
}
}

View File

@ -2,6 +2,7 @@
#![allow(clippy::missing_docs_in_private_items)]
pub mod arbitrary_types;
pub mod generators;
pub mod invariants;

177
scripts/add_fuzz_target.py Normal file
View File

@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Fully automates registering a new cargo-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
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 `` - <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)
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()