diff --git a/.github/workflows/fuzz-afl.yml b/.github/workflows/fuzz-afl.yml index f1bd066..9f520c9 100644 --- a/.github/workflows/fuzz-afl.yml +++ b/.github/workflows/fuzz-afl.yml @@ -2,18 +2,18 @@ name: AFL++ Fuzzing on: schedule: - - cron: "0 2 * * *" # nightly at 02:00 UTC - workflow_dispatch: # manual trigger + - cron: "0 2 * * *" + workflow_dispatch: push: - branches: - - feat-add-afl-fuzzing + branches: [main] env: RISC0_DEV_MODE: "1" + CARGO_TERM_COLOR: always jobs: # ──────────────────────────────────────────────────────────────────────────── - # afl-smoke — 120-second campaign for all 15 targets + # afl-smoke — 60-second per targets # ──────────────────────────────────────────────────────────────────────────── afl-smoke: name: "AFL++ smoke — ${{ matrix.target }}" @@ -108,7 +108,7 @@ jobs: fi echo "Seed inputs: $(ls "$SEEDS" | wc -l)" - - name: Run AFL++ for 120 seconds + - name: Run AFL++ for 60 seconds env: AFL_SKIP_CPUFREQ: "1" AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES: "1" @@ -118,7 +118,7 @@ jobs: # Disable errexit so that timeout's exit code 124 (expected signal) does not # cause bash -e to abort the script before the guard below can run. set +e - timeout 120 \ + timeout 60 \ afl-fuzz \ -i afl-seeds/${TARGET} \ -o afl-output/${TARGET} \ diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 3871325..b7af96a 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -1,12 +1,11 @@ name: Fuzzing on: - push: - branches: [main, develop, feat-add-afl-fuzzing] - pull_request: schedule: - # Nightly full run - cron: "0 2 * * *" + workflow_dispatch: + push: + branches: [main] env: RISC0_DEV_MODE: "1" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..e71f72e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,78 @@ +name: Lint + +on: + push: + branches: + - main + paths-ignore: + - "**.md" + - "!.github/workflows/*.yml" + + pull_request: + paths-ignore: + - "**.md" + - "!.github/workflows/*.yml" + +env: + RISC0_DEV_MODE: "1" + CARGO_TERM_COLOR: always + +permissions: + contents: read + pull-requests: read + +jobs: + # ── rustfmt ────────────────────────────────────────────────────────────────── + fmt-rs: + name: Rust formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + + - name: Install nightly toolchain for rustfmt + run: rustup install nightly --profile minimal --component rustfmt + + - name: Check Rust files are formatted + run: cargo +nightly fmt --check + + # ── clippy ─────────────────────────────────────────────────────────────────── + lint: + name: Clippy + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + + - name: Checkout logos-execution-zone alongside lez-fuzzing + uses: actions/checkout@v4 + with: + repository: logos-blockchain/logos-execution-zone + path: logos-execution-zone + + - name: Symlink logos-execution-zone to sibling directory + run: ln -s "$GITHUB_WORKSPACE/logos-execution-zone" "$GITHUB_WORKSPACE/../logos-execution-zone" + + - name: Install logos-blockchain-circuits + uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install stable toolchain with clippy + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: lint-rust-cache + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Lint workspace + env: + RISC0_DEV_MODE: "1" + run: cargo clippy --workspace --all-targets --all-features -- -D warnings diff --git a/fuzz_props/src/arbitrary_types.rs b/fuzz_props/src/arbitrary_types.rs index 6804c34..d920d6c 100644 --- a/fuzz_props/src/arbitrary_types.rs +++ b/fuzz_props/src/arbitrary_types.rs @@ -118,13 +118,10 @@ impl<'a> Arbitrary<'a> for ArbPublicKey { // 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") - }), - ) + PublicKey::new_from_private_key(&ArbPrivateKey::arbitrary(u).map_or_else( + |_| PrivateKey::try_new([1_u8; 32]).expect("known-good seed"), + |w| w.0, + )) }); Ok(Self(pk)) } @@ -145,11 +142,11 @@ impl<'a> Arbitrary<'a> for ArbPubTxMessage { 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)) + let account_ids = std::iter::repeat_with(|| ArbAccountId::arbitrary(u).map(|a| a.0)) + .take(len) .collect::>>()?; - let nonces = (0..len) - .map(|_| ArbNonce::arbitrary(u).map(|n| n.0)) + let nonces = std::iter::repeat_with(|| ArbNonce::arbitrary(u).map(|n| n.0)) + .take(len) .collect::>>()?; let instruction_data: Vec = Vec::::arbitrary(u)?; Ok(Self(Message::new_preserialized( @@ -174,9 +171,11 @@ 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::>>()?; + let pairs = std::iter::repeat_with(|| { + Ok((ArbSignature::arbitrary(u)?.0, ArbPublicKey::arbitrary(u)?.0)) + }) + .take(n) + .collect::>>()?; Ok(Self(WitnessSet::from_raw_parts(pairs))) } } @@ -247,8 +246,8 @@ 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)) + let transactions = std::iter::repeat_with(|| ArbNSSATransaction::arbitrary(u).map(|t| t.0)) + .take(n) .collect::>>()?; Ok(Self(HashableBlockData { block_id: u64::arbitrary(u)?, diff --git a/fuzz_props/src/generators.rs b/fuzz_props/src/generators.rs index 70f7788..def495f 100644 --- a/fuzz_props/src/generators.rs +++ b/fuzz_props/src/generators.rs @@ -11,6 +11,7 @@ use testnet_initial_state::initial_pub_accounts_private_keys; /// Extract the [`AccountId`]s of all signers from a transaction's /// witness set. Used by fuzz targets that need to verify nonce /// increments after `execute_check_on_state`. +#[must_use] pub fn signer_account_ids(tx: &common::transaction::NSSATransaction) -> Vec { use common::transaction::NSSATransaction; match tx { @@ -52,15 +53,15 @@ pub struct FuzzAccount { /// has a shape controlled by the fuzzer rather than fixed at compile time. pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result> { let n = ((u8::arbitrary(u)? as usize) % 8) + 1; // 1..=8 - (0..n) - .map(|_| { - Ok(FuzzAccount { - account_id: ArbAccountId::arbitrary(u)?.0, - balance: u128::arbitrary(u)?, - private_key: ArbPrivateKey::arbitrary(u)?.0, - }) + std::iter::repeat_with(|| { + Ok(FuzzAccount { + account_id: ArbAccountId::arbitrary(u)?.0, + balance: u128::arbitrary(u)?, + private_key: ArbPrivateKey::arbitrary(u)?.0, }) - .collect() + }) + .take(n) + .collect() } /// Generate a native-transfer [`NSSATransaction`] between two accounts chosen @@ -115,8 +116,8 @@ prop_compose! { )( from_idx in 0..accounts.len(), to_idx in 0..accounts.len(), - nonce in 0u128..1_000u128, - amount in 0u128..10_000u128, + nonce in 0_u128..1_000_u128, + amount in 0_u128..10_000_u128, ) -> NSSATransaction { let (from_id, from_key) = &accounts[from_idx]; let (to_id, _) = &accounts[to_idx]; @@ -127,6 +128,7 @@ prop_compose! { } /// Return the test accounts from `testnet_initial_state` as `(AccountId, PrivateKey)` pairs. +#[must_use] pub fn test_accounts() -> Vec<(AccountId, PrivateKey)> { initial_pub_accounts_private_keys() .into_iter() @@ -168,9 +170,9 @@ prop_compose! { /// the state is left unchanged on rejection (StateIsolationOnFailure). pub fn arb_invalid_account_state_tx()( // Use a random 32-byte seed as a "phantom" account id not in genesis - phantom_id_bytes in proptest::array::uniform32(0u8..), + phantom_id_bytes in proptest::array::uniform32(0_u8..), amount in (u128::MAX / 2)..u128::MAX, // overflow-inducing amount - nonce in 0u128..10u128, + nonce in 0_u128..10_u128, ) -> NSSATransaction { let phantom_id = nssa::AccountId::new(phantom_id_bytes); // Attempt to sign with a key that has no matching on-chain account @@ -216,14 +218,14 @@ pub fn arb_duplicate_tx_sequence() -> impl Strategy pub fn arb_pathological_sequence() -> impl Strategy> { let accounts = test_accounts(); let n = accounts.len(); - proptest::collection::vec((0..n, 0..n, 0u128..5u128, any::()), 1..8_usize).prop_map( + proptest::collection::vec((0..n, 0..n, 0_u128..5_u128, any::()), 1..8_usize).prop_map( move |params| { params .into_iter() .map(|(from_idx, to_idx, nonce, zero_amount)| { let (from_id, from_key) = &accounts[from_idx]; let (to_id, _) = &accounts[to_idx]; - let amount = if zero_amount { 0u128 } else { u128::MAX }; // 0 or overflow + let amount = if zero_amount { 0_u128 } else { u128::MAX }; // 0 or overflow common::test_utils::create_transaction_native_token_transfer( *from_id, nonce, *to_id, amount, from_key, ) diff --git a/fuzz_props/src/invariants.rs b/fuzz_props/src/invariants.rs index 4f97886..a233406 100644 --- a/fuzz_props/src/invariants.rs +++ b/fuzz_props/src/invariants.rs @@ -9,7 +9,7 @@ pub struct BalanceSnapshot(pub std::collections::HashMap) impl BalanceSnapshot { /// Capture current total balance over all known accounts. pub fn total(&self) -> u128 { - self.0.values().copied().fold(0u128, u128::saturating_add) + self.0.values().copied().fold(0_u128, u128::saturating_add) } } @@ -72,9 +72,8 @@ impl ProtocolInvariant for StateIsolationOnFailure { return Some(InvariantViolation { invariant: self.name(), message: format!( - "balance changed despite tx rejection: account {:?} had \ + "balance changed despite tx rejection: account {acc_id:?} had \ {expected_balance} before, {actual_balance} after", - acc_id, ), }); } @@ -106,7 +105,7 @@ impl ProtocolInvariant for BalanceConservation { .0 .keys() .map(|&id| ctx.state_after.get_account_by_id(id).balance) - .fold(0u128, u128::saturating_add); + .fold(0_u128, u128::saturating_add); if total_before != total_after { return Some(InvariantViolation { invariant: self.name(), @@ -142,10 +141,9 @@ impl ProtocolInvariant for FailedTxNonceStability { return Some(InvariantViolation { invariant: self.name(), message: format!( - "nonce changed despite tx rejection: account {:?} nonce was \ - {:?} before, {:?} after \ - (griefing attack — victim nonce permanently burned on failed tx)", - acc_id, expected_nonce, actual_nonce, + "nonce changed despite tx rejection: account {acc_id:?} nonce was \ + {expected_nonce:?} before, {actual_nonce:?} after \ + (griefing attack \u{2014} victim nonce permanently burned on failed tx)", ), }); } @@ -241,7 +239,7 @@ pub fn assert_replay_rejection( let replay = applied_tx.execute_check_on_state(state, next_block_id, next_timestamp); assert!( replay.is_err(), - "INVARIANT VIOLATION [ReplayRejection]: transaction accepted a second time — \ + "INVARIANT VIOLATION [ReplayRejection]: transaction accepted a second time \u{2014} \ nonce replay not prevented (replay block_id={next_block_id}, \ replay timestamp={next_timestamp})", ); @@ -298,15 +296,14 @@ pub fn assert_nonce_increment_correctness( nonce_before .0 .checked_add(1) - .expect("nonce overflow — signer nonce at u128::MAX"), + .expect("nonce overflow \u{2014} signer nonce at u128::MAX"), ); assert_eq!( nonce_after, expected, - "INVARIANT VIOLATION [NonceIncrementCorrectness]: signer account {:?} nonce \ + "INVARIANT VIOLATION [NonceIncrementCorrectness]: signer account {id:?} nonce \ not incremented by 1 after successful transaction \ - — before={:?}, expected={:?}, got={:?} \ + \u{2014} before={nonce_before:?}, expected={expected:?}, got={nonce_after:?} \ (apply_state_diff failed to increment nonce exactly once)", - id, nonce_before, expected, nonce_after, ); } } @@ -389,13 +386,13 @@ mod tests { #[test] fn balance_conservation_catches_inflation_on_success() { // Arrange: one account with balance 100. - let acc_id = nssa::AccountId::new([1u8; 32]); + let acc_id = nssa::AccountId::new([1_u8; 32]); let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); // Simulate execution that inflated the balance to 200. let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 200)], vec![], 0); let mut balances = std::collections::HashMap::new(); - balances.insert(acc_id, 100u128); + balances.insert(acc_id, 100_u128); let ctx = InvariantCtx { state_before: &state_before, @@ -419,7 +416,7 @@ mod tests { #[test] fn nonce_increment_correctness_passes_when_signer_not_in_snapshot() { // Signer ID is present in the list but absent from the snapshot — skipped. - let acc_id = nssa::AccountId::new([9u8; 32]); + let acc_id = nssa::AccountId::new([9_u8; 32]); let state = make_empty_state(); // Empty snapshot → `continue` branch fires; no assertion is made. assert_nonce_increment_correctness(&[acc_id], &make_empty_nonce_snapshot(), &state); @@ -429,7 +426,7 @@ mod tests { fn nonce_increment_correctness_catches_unchanged_nonce() { // Arrange: signer has nonce 5 in the snapshot; the state returns Nonce(0) for the // same account (genesis default). expected = Nonce(6), actual = Nonce(0) → VIOLATION. - let acc_id = nssa::AccountId::new([3u8; 32]); + let acc_id = nssa::AccountId::new([3_u8; 32]); let state = V03State::new_with_genesis_accounts(&[], vec![], 0); let mut nonces = std::collections::HashMap::new(); @@ -443,7 +440,7 @@ mod tests { #[test] fn failed_tx_nonce_stability_catches_nonce_mutation() { - let acc_id = nssa::AccountId::new([2u8; 32]); + let acc_id = nssa::AccountId::new([2_u8; 32]); // before: nonce 5; after: nonce 6 (should not happen on failure) let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); let state_after = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0); @@ -455,7 +452,7 @@ mod tests { nonces.insert(acc_id, Nonce(1)); let mut balances = std::collections::HashMap::new(); - balances.insert(acc_id, 100u128); + balances.insert(acc_id, 100_u128); let ctx = InvariantCtx { state_before: &state_before, @@ -493,36 +490,33 @@ mod replay_proptest { let accounts = test_accounts(); let init_accs: Vec<(nssa::AccountId, u128)> = accounts .iter() - .map(|(id, _)| (*id, 1_000_000u128)) + .map(|(id, _)| (*id, 1_000_000_u128)) .collect(); V03State::new_with_genesis_accounts(&init_accs, vec![], 0) } proptest! { - /// **ReplayRejection** — a transaction accepted in block N must be + /// **ReplayRejection** \u{2014} a transaction accepted in block N must be /// rejected when replayed in block N+1, because the nonce is consumed /// on first acceptance. #[test] fn replay_rejection_proptest(tx in arb_native_transfer_tx(test_accounts())) { let mut state = make_test_state(); - // Stateless gate — skip structurally invalid transactions (e.g. those + // Stateless gate \u{2014} skip structurally invalid transactions (e.g. those // whose public key does not match the declared sender). - let validated_tx = match tx.transaction_stateless_check() { - Ok(v) => v, - Err(_) => return Ok(()), - }; + let Ok(validated_tx) = tx.transaction_stateless_check() else { return Ok(()) }; - // First application — may fail for state-level reasons (e.g. sender + // First application \u{2014} may fail for state-level reasons (e.g. sender // has insufficient balance, wrong nonce). In that case there is // nothing to replay. let first_result = validated_tx.execute_check_on_state(&mut state, 1, 0); - if let Ok(validated_tx) = first_result { + if let Ok(applied_tx) = first_result { // Use the shared framework function. assert_replay_rejection uses // assert!() rather than prop_assert!(); for structured proptest // inputs the framework-level panic is equivalent. - super::assert_replay_rejection(validated_tx, &mut state, 2, 1); + super::assert_replay_rejection(applied_tx, &mut state, 2, 1); } } } diff --git a/fuzz_props/src/lib.rs b/fuzz_props/src/lib.rs index 3195541..ffba17a 100644 --- a/fuzz_props/src/lib.rs +++ b/fuzz_props/src/lib.rs @@ -1,6 +1,65 @@ //! Fuzzing property library: invariant framework + input generators. -#![allow(clippy::missing_docs_in_private_items)] +#![allow( + clippy::missing_docs_in_private_items, + reason = "fuzz/test library; internal docs omitted for brevity" +)] +#![allow( + clippy::single_char_lifetime_names, + reason = "the `Arbitrary` trait uses `'a` and our impls must match its signature" +)] +#![allow( + clippy::exhaustive_structs, + reason = "fuzz-library newtype wrappers and test helpers; non_exhaustive would only add noise" +)] +#![allow( + clippy::missing_inline_in_public_items, + reason = "fuzz/test library; inlining hints have negligible effect here" +)] +#![allow( + clippy::question_mark_used, + reason = "`?` is the idiomatic Rust error-propagation operator in `Arbitrary` implementations" +)] +#![allow( + clippy::as_conversions, + reason = "u8 → usize for index arithmetic is safe and bounded in arbitrary contexts" +)] +#![allow( + clippy::integer_division_remainder_used, + reason = "modulo is the natural way to bound arbitrary u8 values to a range" +)] +#![allow( + clippy::arbitrary_source_item_ordering, + reason = "items are grouped logically rather than alphabetically for readability" +)] +#![allow( + clippy::iter_over_hash_type, + reason = "invariant checks iterate over all accounts; iteration order does not affect correctness" +)] +#![allow( + clippy::arithmetic_side_effects, + reason = "arithmetic is bounded by construction in test/fuzz helpers" +)] +#![allow( + clippy::integer_division, + reason = "u128::MAX / 2 is intentional for generating overflow-inducing test values" +)] +#![allow( + clippy::module_name_repetitions, + reason = "assert_invariants is the canonical, self-documenting name for this function" +)] +#![allow( + clippy::unused_trait_names, + reason = "named `Arbitrary` import needed to disambiguate from `proptest::arbitrary::Arbitrary` in generators.rs" +)] +#![allow( + clippy::let_underscore_must_use, + reason = "seed-generation IO errors are intentionally ignored in tests" +)] +#![allow( + clippy::let_underscore_untyped, + reason = "seed-generation IO errors are intentionally ignored in tests" +)] pub mod arbitrary_types; pub mod generators; @@ -51,9 +110,9 @@ mod seed_gen { for rel in &targets { let p = workspace_root.join(rel); if let Some(parent) = p.parent() { - fs::create_dir_all(parent).ok(); + let _ = fs::create_dir_all(parent); } - fs::write(&p, &bytes).ok(); + let _ = fs::write(&p, &bytes); } } }