diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 3f8133b..e8e1db5 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -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 | diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 4b61548..d5219a5 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -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 diff --git a/fuzz/fuzz_targets/fuzz_common_invariants.rs b/fuzz/fuzz_targets/fuzz_common_invariants.rs deleted file mode 100644 index d4337ba..0000000 --- a/fuzz/fuzz_targets/fuzz_common_invariants.rs +++ /dev/null @@ -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", - ); -}); diff --git a/fuzz/fuzz_targets/fuzz_genesis_invariants.rs b/fuzz/fuzz_targets/fuzz_genesis_invariants.rs deleted file mode 100644 index fafb53b..0000000 --- a/fuzz/fuzz_targets/fuzz_genesis_invariants.rs +++ /dev/null @@ -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", - ); -}); diff --git a/fuzz/fuzz_targets/fuzz_system_account_protection.rs b/fuzz/fuzz_targets/fuzz_system_account_protection.rs deleted file mode 100644 index 8555b92..0000000 --- a/fuzz/fuzz_targets/fuzz_system_account_protection.rs +++ /dev/null @@ -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_diff→HashMap::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})", - ); - } - } - } -}); diff --git a/scripts/mutants-corpus-test.sh b/scripts/mutants-corpus-test.sh index bb04eee..b1409ba 100755 --- a/scripts/mutants-corpus-test.sh +++ b/scripts/mutants-corpus-test.sh @@ -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.).