fix: remove input-independent targets

- create unit tests in lez repo instead
This commit is contained in:
Roman 2026-06-11 15:43:31 +08:00
parent 9cb0d43c40
commit 3c260eeef0
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
6 changed files with 34 additions and 494 deletions

View File

@ -632,6 +632,40 @@ flag stubs out ZK proof generation and replaces it with a fast mock implementati
---
## Mutation testing — the two planes
Mutation testing here runs in two distinct planes, answering two different questions:
- **Plane A — "does a test catch this mutant?"** Run with a standard `cargo test`
oracle against the `lee` crate's own unit tests.
- **Plane B — "does the committed fuzz corpus catch this mutant?"** Run with
`just mutants-protocol`, which swaps `cargo test` for a fuzz-corpus replay
(`cargo fuzz run … -runs=0`) as the oracle.
A mutant surviving Plane B is **not automatically a corpus gap to fill.** Some
mutations are only reachable by a fully-valid executing transaction or by a
deliberately-misbehaving program — neither of which a fuzzer can synthesise from
random bytes, and both of which are better pinned by deterministic unit tests in
the `lee` crate. Encoding such scenarios as input-independent fuzz targets only
duplicates those tests and slows every corpus replay.
The mutants that are **expected** to survive Plane B (and where each is actually
covered) are catalogued in [`mutants-not-fuzzable.md`](mutants-not-fuzzable.md).
Reconcile new `mutants-protocol` runs against that list: only a surviving mutant
**not** on it warrants a new corpus input.
**No input-independent targets.** A fuzz target whose closure ignores its input
(`|_data|`) is a deterministic unit test, not a fuzzer — it belongs in the LEZ
crate that owns the code. Three such targets once existed
(`fuzz_common_invariants`, `fuzz_genesis_invariants`,
`fuzz_system_account_protection`); their invariants were ported to LEZ unit tests
and the targets removed. The mutant→test mapping and verification are recorded in
[`input-independent-target-coverage.md`](input-independent-target-coverage.md).
When adding a target, drive it from `data`; if a check doesn't depend on the
input, write it as a unit test in `logos-execution-zone` instead.
---
## Known Limitations & Future Work
| Item | Notes |

View File

@ -126,18 +126,6 @@ path = "fuzz_targets/fuzz_merkle_tree.rs"
test = false
bench = false
[[bin]]
name = "fuzz_genesis_invariants"
path = "fuzz_targets/fuzz_genesis_invariants.rs"
test = false
bench = false
[[bin]]
name = "fuzz_common_invariants"
path = "fuzz_targets/fuzz_common_invariants.rs"
test = false
bench = false
[[bin]]
name = "fuzz_transaction_properties"
path = "fuzz_targets/fuzz_transaction_properties.rs"
@ -161,9 +149,3 @@ name = "fuzz_nullifier_set_roundtrip"
path = "fuzz_targets/fuzz_nullifier_set_roundtrip.rs"
test = false
bench = false
[[bin]]
name = "fuzz_system_account_protection"
path = "fuzz_targets/fuzz_system_account_protection.rs"
test = false
bench = false

View File

@ -1,171 +0,0 @@
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
//! Fuzz target: common-crate and low-level type invariants.
//!
//! This target is **input-independent**: the fuzz input is always ignored.
//! It asserts deterministic invariants about types in `lez/common` and
//! low-level `lee` types that are not exercised by higher-level state-transition
//! targets.
//!
//! # Corpus note
//!
//! A single `\x00` seed file is sufficient — the input bytes are never read.
use common::{HashType, config::BasicAuth};
use nssa::{
privacy_preserving_transaction::circuit::Proof,
program::Program,
program_deployment_transaction::Message as DeployMessage,
program_methods::{
AUTHENTICATED_TRANSFER_ELF, TOKEN_ELF,
},
};
fuzz_props::fuzz_entry!(|_data: &[u8]| {
// ── INVARIANT [HashTypeAsRefLength] ────────────────────────────────────────
// `HashType::as_ref()` must always return exactly 32 bytes.
// Catches mutations that return an empty slice or a slice of the wrong size.
let all_ones = HashType([1_u8; 32]);
assert_eq!(
all_ones.as_ref().len(),
32,
"INVARIANT VIOLATION [HashTypeAsRefLength]: HashType::as_ref must return 32 bytes",
);
let zero = HashType::default();
assert_eq!(
zero.as_ref().len(),
32,
"INVARIANT VIOLATION [HashTypeAsRefLength]: HashType::as_ref on default must return 32 bytes",
);
// ── INVARIANT [HashTypeAsRefBytes] ────────────────────────────────────────
// `HashType::as_ref()` must return the exact inner bytes.
// Catches mutations that return `vec![0]` or `vec![1]` instead of `&self.0`.
let known = [0x42_u8; 32];
let hash = HashType(known);
assert_eq!(
hash.as_ref(),
&known,
"INVARIANT VIOLATION [HashTypeAsRefBytes]: HashType::as_ref must return the inner [u8;32]",
);
// ── INVARIANT [BasicAuthPasswordPreserved] ───────────────────────────────
// Parsing "user:password" must preserve the non-empty password as `Some`.
// Catches the mutation that deletes `!` in the `.filter(|p| !p.is_empty())`
// predicate, which would flip the logic and accept only empty passwords.
let auth: BasicAuth = "user:secret"
.parse()
.expect("INVARIANT VIOLATION: 'user:secret' must parse as BasicAuth");
assert_eq!(
auth.password.as_deref(),
Some("secret"),
"INVARIANT VIOLATION [BasicAuthPasswordPreserved]: \
parsing 'user:secret' must give password = Some(\"secret\")",
);
let auth2: BasicAuth = "alice:hunter2"
.parse()
.expect("INVARIANT VIOLATION: 'alice:hunter2' must parse");
assert_eq!(
auth2.password.as_deref(),
Some("hunter2"),
"INVARIANT VIOLATION [BasicAuthPasswordPreserved]: \
password must match the part after the colon",
);
// ── INVARIANT [BasicAuthEmptyPasswordIsNone] ─────────────────────────────
// Parsing "user:" (empty password) must give `password = None`.
// With the `!` deleted, this would become `Some("")` instead of `None`.
let auth_empty: BasicAuth = "user:"
.parse()
.expect("INVARIANT VIOLATION: 'user:' must parse as BasicAuth");
assert_eq!(
auth_empty.password,
None,
"INVARIANT VIOLATION [BasicAuthEmptyPasswordIsNone]: \
an empty password (trailing colon) must give password = None",
);
// ── INVARIANT [ProgramElfNonEmpty] ───────────────────────────────────────
// `Program::elf()` must return a non-empty byte slice.
// Catches the mutation that returns `Vec::leak(Vec::new())`.
let at_prog = Program::authenticated_transfer_program();
assert!(
!at_prog.elf().is_empty(),
"INVARIANT VIOLATION [ProgramElfNonEmpty]: \
Program::authenticated_transfer_program().elf() must not be empty",
);
let token_prog = Program::token();
assert!(
!token_prog.elf().is_empty(),
"INVARIANT VIOLATION [ProgramElfNonEmpty]: \
Program::token().elf() must not be empty",
);
// ── INVARIANT [ProgramElfCorrect] ────────────────────────────────────────
// `Program::elf()` must return exactly the compile-time bytecode constant.
// Catches the mutations that return `vec![0]` or `vec![1]`.
assert_eq!(
at_prog.elf(),
AUTHENTICATED_TRANSFER_ELF,
"INVARIANT VIOLATION [ProgramElfCorrect]: \
Program::authenticated_transfer_program().elf() must equal AUTHENTICATED_TRANSFER_ELF",
);
assert_eq!(
token_prog.elf(),
TOKEN_ELF,
"INVARIANT VIOLATION [ProgramElfCorrect]: \
Program::token().elf() must equal TOKEN_ELF",
);
// ── INVARIANT [ProofIntoInnerRoundtrip] ──────────────────────────────────
// `Proof::from_inner(bytes).into_inner()` must return the original bytes.
// Catches the mutations that return `vec![]`, `vec![0]`, or `vec![1]`.
let proof_bytes = vec![0xDE_u8, 0xAD, 0xBE, 0xEF];
let proof = Proof::from_inner(proof_bytes.clone());
assert_eq!(
proof.into_inner(),
proof_bytes,
"INVARIANT VIOLATION [ProofIntoInnerRoundtrip]: \
Proof::from_inner(b).into_inner() must return b",
);
// Also test with an empty proof (round-trip must preserve emptiness).
let empty_proof = Proof::from_inner(vec![]);
assert!(
empty_proof.into_inner().is_empty(),
"INVARIANT VIOLATION [ProofIntoInnerRoundtrip]: \
empty Proof::from_inner(vec![]).into_inner() must be empty",
);
// And with a single non-zero byte:
let single = Proof::from_inner(vec![0xFF]);
assert_eq!(
single.into_inner(),
vec![0xFF_u8],
"INVARIANT VIOLATION [ProofIntoInnerRoundtrip]: \
Proof from single byte must round-trip correctly",
);
// ── INVARIANT [DeployMessageBytecodeRoundtrip] ────────────────────────────
// `Message::new(bytecode).into_bytecode()` must return the original bytecode.
// Catches the mutations that return `vec![]`, `vec![0]`, or `vec![1]`.
let bytecode = vec![0x7F_u8, 0x45, 0x4C, 0x46]; // ELF magic
let msg = DeployMessage::new(bytecode.clone());
assert_eq!(
msg.into_bytecode(),
bytecode,
"INVARIANT VIOLATION [DeployMessageBytecodeRoundtrip]: \
Message::new(b).into_bytecode() must return b",
);
// Empty bytecode round-trip:
let empty_msg = DeployMessage::new(vec![]);
assert!(
empty_msg.into_bytecode().is_empty(),
"INVARIANT VIOLATION [DeployMessageBytecodeRoundtrip]: \
empty bytecode must round-trip as empty",
);
});

View File

@ -1,137 +0,0 @@
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
//! Fuzz target: genesis-state and system-account invariants.
//!
//! This target is **input-independent**: the fuzz input is always ignored.
//! It asserts deterministic invariants about the genesis state produced by
//! `V03State::new_with_genesis_accounts`, `system_faucet_account_id`,
//! `system_bridge_account_id`, and `V03State::add_pinata_token_program`.
//!
//! # Covered mutations (from `lee/state_machine/src/state.rs`)
//!
//! | Line | Mutation | Assertion that catches it |
//! |------|--------------------------------------------------------|-----------------------------------------------------|
//! | 312 | `commitment_set_digest → Default::default()` | `[CommitmentSetDigestNonDefault]` |
//! | 368 | delete `program_owner` from `add_pinata_token_program` | `[PinataTokenProgramOwner]` |
//! | 370 | delete `data` from `add_pinata_token_program` | `[PinataTokenData]` |
//! | 385 | `system_faucet_account → Default::default()` | `[FaucetBalance]` + `[FaucetProgramOwner]` |
//! | 386 | delete `program_owner` from `system_faucet_account` | `[FaucetProgramOwner]` |
//! | 387 | delete `balance` from `system_faucet_account` | `[FaucetBalance]` |
//! | 393 | `system_bridge_account → Default::default()` | `[BridgeProgramOwner]` |
//! | 394 | delete `program_owner` from `system_bridge_account` | `[BridgeProgramOwner]` |
//! | 406 | `system_bridge_account_id → Default::default()` | `[BridgeIdNonDefault]` + `[SystemAccountIdDistinct]` |
//!
//! # Corpus note
//!
//! A single `\x00` seed file is sufficient — the input bytes are never read.
//! The seed is required by `cargo fuzz run -runs=0` so that the replay phase
//! has at least one execution to check against.
use nssa::{Account, AccountId, V03State, system_bridge_account_id, system_faucet_account_id};
fuzz_props::fuzz_entry!(|_data: &[u8]| {
let default_account = Account::default();
// ── INVARIANT [BridgeIdNonDefault] ────────────────────────────────────────
// `system_bridge_account_id()` must return a non-default `AccountId`.
// Catches the mutation at state.rs:406 that replaces the function body with
// `Default::default()`.
let bridge_id = system_bridge_account_id();
assert_ne!(
bridge_id,
AccountId::default(),
"INVARIANT VIOLATION [BridgeIdNonDefault]: \
system_bridge_account_id() must not return AccountId::default()",
);
// The two system account IDs must also be distinct so that they occupy
// separate entries in the public-state map.
let faucet_id = system_faucet_account_id();
assert_ne!(
faucet_id,
bridge_id,
"INVARIANT VIOLATION [SystemAccountIdDistinct]: \
system_faucet_account_id() and system_bridge_account_id() must differ",
);
// Build the genesis state with no extra accounts.
let state = V03State::new_with_genesis_accounts(&[], vec![], 0);
// ── INVARIANT [FaucetBalance] ─────────────────────────────────────────────
// The system faucet account must hold `u128::MAX` tokens.
// Catches state.rs:385 (whole account → Default) and
// state.rs:387 (delete `balance` field from struct literal).
let faucet = state.get_account_by_id(faucet_id);
assert_eq!(
faucet.balance,
u128::MAX,
"INVARIANT VIOLATION [FaucetBalance]: \
system_faucet_account must have balance == u128::MAX, got {}",
faucet.balance,
);
// ── INVARIANT [FaucetProgramOwner] ────────────────────────────────────────
// The system faucet account must have a non-default `program_owner`.
// Catches state.rs:385 (whole account → Default) and
// state.rs:386 (delete `program_owner` field from struct literal).
assert_ne!(
faucet.program_owner,
default_account.program_owner,
"INVARIANT VIOLATION [FaucetProgramOwner]: \
system_faucet_account must have a non-default program_owner",
);
// ── INVARIANT [BridgeProgramOwner] ───────────────────────────────────────
// The system bridge account must have a non-default `program_owner`.
// Catches state.rs:393 (whole account → Default) and
// state.rs:394 (delete `program_owner` field from struct literal).
let bridge = state.get_account_by_id(bridge_id);
assert_ne!(
bridge.program_owner,
default_account.program_owner,
"INVARIANT VIOLATION [BridgeProgramOwner]: \
system_bridge_account must have a non-default program_owner",
);
// ── INVARIANT [CommitmentSetDigestNonDefault] ─────────────────────────────
// A freshly created empty state has an all-zero Merkle root, which equals
// `CommitmentSetDigest::default()`. The genesis state inserts
// `DUMMY_COMMITMENT` via SHA-256, producing a strictly different root.
// Catches state.rs:312 that replaces `commitment_set_digest()` with
// `Default::default()`.
let empty_digest = V03State::new().commitment_set_digest();
let genesis_digest = state.commitment_set_digest();
assert_ne!(
genesis_digest,
empty_digest,
"INVARIANT VIOLATION [CommitmentSetDigestNonDefault]: \
commitment_set_digest of genesis state must differ from the empty state's \
all-zero root",
);
// ── INVARIANT [PinataTokenProgramOwner] ──────────────────────────────────
// An account created by `add_pinata_token_program` must have a non-default
// `program_owner` field.
// Catches state.rs:368 (delete `program_owner` from the struct literal).
//
// ── INVARIANT [PinataTokenData] ──────────────────────────────────────────
// An account created by `add_pinata_token_program` must have non-default
// `data` (specifically `vec![3; 33]` encoded as `Data`).
// Catches state.rs:370 (delete `data` from the struct literal).
let pt_id = AccountId::new([0xABu8; 32]);
let mut pinata_state = V03State::new_with_genesis_accounts(&[], vec![], 0);
pinata_state.add_pinata_token_program(pt_id);
let pt = pinata_state.get_account_by_id(pt_id);
assert_ne!(
pt.program_owner,
default_account.program_owner,
"INVARIANT VIOLATION [PinataTokenProgramOwner]: \
add_pinata_token_program must set a non-default program_owner on the account",
);
assert_ne!(
pt.data,
default_account.data,
"INVARIANT VIOLATION [PinataTokenData]: \
add_pinata_token_program must set non-default data on the account",
);
});

View File

@ -1,165 +0,0 @@
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
//! Fuzz target: system-account modification protection.
//!
//! `LeeTransaction::validate_on_state` must reject any transaction that modifies
//! a system account (faucet, bridge, or clock accounts). This is enforced by
//! `validate_doesnt_modify_account` which inspects `ValidatedStateDiff::public_diff()`.
//!
//! # Corpus note
//!
//! This target is **input-independent**. A single `\x00` seed is sufficient.
//!
//! **Performance note**: the `[SystemAccountModificationRejected]` invariant
//! executes a RISC0 program (a native transfer). This is inherently slow
//! (~seconds). Only one corpus file is needed, so the corpus-regression oracle
//! costs one program execution per mutant under test.
use common::transaction::LeeTransaction;
use nssa::{
AccountId, PrivateKey, PublicKey, V03State, ValidatedStateDiff,
CLOCK_01_PROGRAM_ACCOUNT_ID, system_bridge_account_id, system_faucet_account_id,
};
fuzz_props::fuzz_entry!(|_data: &[u8]| {
// ── INVARIANT [SystemAccountIdsDistinct] ──────────────────────────────────
let faucet_id = system_faucet_account_id();
let bridge_id = system_bridge_account_id();
assert_ne!(
faucet_id,
AccountId::default(),
"INVARIANT VIOLATION [SystemAccountIdsDistinct]: faucet account ID must be non-default",
);
assert_ne!(
bridge_id,
AccountId::default(),
"INVARIANT VIOLATION [SystemAccountIdsDistinct]: bridge account ID must be non-default",
);
assert_ne!(
faucet_id,
bridge_id,
"INVARIANT VIOLATION [SystemAccountIdsDistinct]: faucet and bridge must be distinct",
);
// ── INVARIANT [ClockInvocationRejected] ──────────────────────────────────
// A native transfer that CREDITS a clock system account modifies exactly one
// system account — the clock account — and that account is *changed* (its
// balance increases from 0). No other system account appears in the diff, so
// this isolates the `validate_doesnt_modify_account` rejection cleanly.
//
// Why not a clock invocation? The clock program writes all three clock
// accounts, but the 01/10/50 clocks tick at different rates, so its diff
// contains BOTH changed and unchanged system accounts. The `!=`→`==`
// mutation then still rejects (citing an unchanged account), so a clock
// invocation cannot distinguish the mutant. Crediting a single clock account
// gives a single, changed system account, which the mutant must accept.
//
// Why not credit the faucet? The faucet holds u128::MAX, so any credit
// overflows and the program execution fails before the protection check is
// reached. A clock account starts at balance 0, so a small credit succeeds.
//
// With mutation `!=` → `==` at transaction.rs:182:
// The clock account is changed (post != pre), so the mutated `post == pre`
// check is false → no error → validate_on_state returns Ok → our assert fires.
//
// With mutation `public_diff → HashMap::new()` at validated_state_diff.rs:479:
// validate_doesnt_modify_account sees an empty map → can never find the
// clock account → returns Ok for every transaction → our assert fires.
{
let sender_key = PrivateKey::try_new([5_u8; 32]).expect("known-good key");
let sender_pub = PublicKey::new_from_private_key(&sender_key);
let sender_id = AccountId::from(&sender_pub);
// Fund the sender; clock accounts already exist in genesis (balance 0).
let state = V03State::new_with_genesis_accounts(&[(sender_id, 10_000_u128)], vec![], 0);
// Transfer tokens TO a clock account — credits (changes) that system account.
let tx = common::test_utils::create_transaction_native_token_transfer(
sender_id,
0, // nonce
CLOCK_01_PROGRAM_ACCOUNT_ID,
100, // amount credited to the clock account
&sender_key,
);
let result = tx.validate_on_state(&state, 1, 0);
assert!(
result.is_err(),
"INVARIANT VIOLATION [SystemAccountModificationRejected]: \
validate_on_state must reject a transfer that credits a clock system \
account. If this fires, either validate_doesnt_modify_account has a logic \
inversion (!===) or public_diff() returns an empty map",
);
}
// ── INVARIANT [PublicDiffNonEmptyOnSuccess] ────────────────────────────────
// For a valid public transaction with signers, the signer accounts must appear
// in public_diff after successful validation (nonces are updated in the diff).
//
// With mutation `public_diff → HashMap::new()`:
// The map is empty → `contains_key(&signer)` returns false → assert fires.
//
// Uses `common::test_utils::create_transaction_native_token_transfer` to
// construct a semantically valid transaction (correct instruction type).
{
let key = PrivateKey::try_new([7_u8; 32]).expect("known-good key");
let pubkey = PublicKey::new_from_private_key(&key);
let addr = AccountId::from(&pubkey);
let key2 = PrivateKey::try_new([8_u8; 32]).expect("known-good key");
let pubkey2 = PublicKey::new_from_private_key(&key2);
let addr2 = AccountId::from(&pubkey2);
let state = V03State::new_with_genesis_accounts(
&[(addr, 10_000_u128), (addr2, 10_000_u128)],
vec![],
0,
);
// Use the test utility to build a valid native token transfer.
// This uses the correct authenticated_transfer_core::Instruction::Transfer,
// which the program can actually execute without panicking.
let lee_tx = common::test_utils::create_transaction_native_token_transfer(
addr,
0, // nonce = 0 (matches initial state nonce)
addr2,
100, // amount
&key,
);
if let LeeTransaction::Public(pub_tx) = &lee_tx {
if let Ok(diff) = ValidatedStateDiff::from_public_transaction(&pub_tx, &state, 1, 0) {
let public_diff = diff.public_diff();
// The signer/sender (addr) must be in the diff: a native transfer
// debits its balance, so it MUST appear in public_diff.
// If public_diff() returns an empty HashMap, this assert fires.
//
// Note: nonce increments are applied separately during
// `apply_state_diff` via `signer_account_ids` and are NOT recorded
// in `public_diff`, so we do not assert on the nonce field here.
assert!(
public_diff.contains_key(&addr),
"INVARIANT VIOLATION [PublicDiffNonEmptyOnSuccess]: \
public_diff must contain the sender {:?} (its balance is debited) \
after a successful native transfer \
(mutation public_diffHashMap::new() detected)",
addr,
);
// The diff must reflect the balance debit on the sender — the
// balance recorded in the diff must differ from the pre-state.
let pre_balance = state.get_account_by_id(addr).balance;
let post_balance = public_diff[&addr].balance;
assert_ne!(
post_balance,
pre_balance,
"INVARIANT VIOLATION [PublicDiffNonEmptyOnSuccess]: \
sender balance in the diff must differ from pre-state after a transfer \
(pre={pre_balance}, post={post_balance})",
);
}
}
}
});

View File

@ -37,13 +37,10 @@ targets=(
fuzz_multi_block_state_sequence
fuzz_sequencer_vs_replayer
fuzz_merkle_tree
fuzz_genesis_invariants
fuzz_common_invariants
fuzz_transaction_properties
fuzz_privacy_preserving_witness
fuzz_encoding_privacy_preserving
fuzz_nullifier_set_roundtrip
fuzz_system_account_protection
)
# cargo-fuzz requires the nightly toolchain (-Zsanitizer=address etc.).