From 68c5b66bb95dd040c0b10d80017bff0bd45dc176 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Sat, 29 Aug 2026 14:33:32 +0100 Subject: [PATCH 1/3] feat(fuzzer): implement checksum-corruption mutator Flip bits in the trailing CRC-16 only and require InvalidChecksum. Skip coincidentally valid checksums so they are not false positives. --- examples/rust-address-fuzzer/src/generate.rs | 39 +- examples/rust-address-fuzzer/src/main.rs | 71 ++- .../src/mutators/checksum.rs | 478 ++++++++++++++++++ .../rust-address-fuzzer/src/mutators/mod.rs | 1 + examples/rust-address-fuzzer/src/report.rs | 19 + 5 files changed, 586 insertions(+), 22 deletions(-) create mode 100644 examples/rust-address-fuzzer/src/mutators/checksum.rs diff --git a/examples/rust-address-fuzzer/src/generate.rs b/examples/rust-address-fuzzer/src/generate.rs index 24fd9973..dc895ccd 100644 --- a/examples/rust-address-fuzzer/src/generate.rs +++ b/examples/rust-address-fuzzer/src/generate.rs @@ -11,7 +11,7 @@ //! | Kind | Payload | Payload len | Total (+ 2B CRC) | Strkey chars | //! |------|--------------------------------------------|-------------|------------------|--------------| //! | G | version(1) + ed25519_key(32) | 33 | 35 | 56 | -//! | M | version(1) + muxed_id_BE(8) + ed25519_key(32) | 41 | 43 | 69 | +//! | M | version(1) + ed25519_key(32) + muxed_id_BE(8) | 41 | 43 | 69 | //! | C | version(1) + contract_hash(32) | 33 | 35 | 56 | use prism_core::address::AddressKind; @@ -19,9 +19,9 @@ use rand::Rng; // ── version bytes (top 5 bits of the first byte, as used by prism-core) ────── -const VERSION_G: u8 = 6 << 3; // 0x30 +const VERSION_G: u8 = 6 << 3; // 0x30 const VERSION_M: u8 = 12 << 3; // 0x60 -const VERSION_C: u8 = 2 << 3; // 0x10 +const VERSION_C: u8 = 2 << 3; // 0x10 // ── CRC-16 (CCITT, same polynomial as prism-core) ──────────────────────────── @@ -106,10 +106,10 @@ pub fn random_valid_address(kind: AddressKind, rng: &mut impl Rng) -> String { let mut key = [0u8; 32]; rng.fill(&mut key); - // M payload body = muxed_id_BE(8) || ed25519_key(32) + // SEP-0023 MuxedAccount body = ed25519_key(32) || muxed_id_BE(8) let mut body = Vec::with_capacity(40); - body.extend_from_slice(&muxed_id.to_be_bytes()); body.extend_from_slice(&key); + body.extend_from_slice(&muxed_id.to_be_bytes()); build_strkey(VERSION_M, &body) } @@ -125,9 +125,7 @@ pub fn random_valid_address(kind: AddressKind, rng: &mut impl Rng) -> String { // If our own generator produces an address that does not parse, the seed // corpus is broken. Panic loudly so the problem is noticed immediately. let parsed = prism_core::address::parse(&address).unwrap_or_else(|e| { - panic!( - "generator produced an invalid {kind:?} address ({address:?}): {e}" - ); + panic!("generator produced an invalid {kind:?} address ({address:?}): {e}"); }); assert_eq!( parsed.kind(), @@ -145,8 +143,8 @@ pub fn random_valid_address(kind: AddressKind, rng: &mut impl Rng) -> String { mod tests { use super::*; use prism_core::address::AddressKind; - use rand::SeedableRng; use rand::rngs::StdRng; + use rand::SeedableRng; fn seeded() -> StdRng { StdRng::seed_from_u64(0xDEAD_BEEF_CAFE_1234) @@ -156,7 +154,12 @@ mod tests { fn g_address_has_correct_length() { let mut rng = seeded(); let addr = random_valid_address(AddressKind::G, &mut rng); - assert_eq!(addr.len(), 56, "G address must be 56 chars, got {}", addr.len()); + assert_eq!( + addr.len(), + 56, + "G address must be 56 chars, got {}", + addr.len() + ); assert!(addr.starts_with('G'), "G address must start with 'G'"); } @@ -164,7 +167,12 @@ mod tests { fn m_address_has_correct_length() { let mut rng = seeded(); let addr = random_valid_address(AddressKind::M, &mut rng); - assert_eq!(addr.len(), 69, "M address must be 69 chars, got {}", addr.len()); + assert_eq!( + addr.len(), + 69, + "M address must be 69 chars, got {}", + addr.len() + ); assert!(addr.starts_with('M'), "M address must start with 'M'"); } @@ -172,7 +180,12 @@ mod tests { fn c_address_has_correct_length() { let mut rng = seeded(); let addr = random_valid_address(AddressKind::C, &mut rng); - assert_eq!(addr.len(), 56, "C address must be 56 chars, got {}", addr.len()); + assert_eq!( + addr.len(), + 56, + "C address must be 56 chars, got {}", + addr.len() + ); assert!(addr.starts_with('C'), "C address must start with 'C'"); } @@ -220,8 +233,8 @@ mod tests { let key = [0u8; 32]; // fixed key for reproducibility let mut body = Vec::with_capacity(40); - body.extend_from_slice(&id.to_be_bytes()); body.extend_from_slice(&key); + body.extend_from_slice(&id.to_be_bytes()); let addr = build_strkey(VERSION_M, &body); let parsed = prism_core::address::parse(&addr) .unwrap_or_else(|e| panic!("boundary id {id} failed: {e}")); diff --git a/examples/rust-address-fuzzer/src/main.rs b/examples/rust-address-fuzzer/src/main.rs index 9b6a4a8b..b7469c3f 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -12,7 +12,11 @@ use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; #[derive(ClapParser, Debug)] -#[command(name = "rust-address-fuzzer", version, about = "Fuzz-tests the prism-core Stellar address parser")] +#[command( + name = "rust-address-fuzzer", + version, + about = "Fuzz-tests the prism-core Stellar address parser" +)] struct Cli { /// Generate N random strings and parse each one #[arg(long, value_name = "N", conflicts_with_all = ["corpus", "stdin_mode"])] @@ -73,8 +77,7 @@ fn main() { }; stats.report.inputs_run = stats.total; - stats.report.findings_count = stats.panics; // In the future, logic errors would also go here. - + eprintln!( "Done – {} inputs | {} ok | {} err | {} findings", stats.total, stats.ok, stats.err, stats.report.findings_count @@ -104,6 +107,7 @@ fn run_random(rng: &mut StdRng, n: usize, verbose: bool) -> Stats { random_string(rng) }; fuzz_one(&input, verbose, &mut stats); + fuzz_checksum_corruption(&input, rng, verbose, &mut stats); } stats } @@ -113,23 +117,33 @@ fn run_corpus(path: &PathBuf, verbose: bool, max_iters: Option) -> Stats eprintln!("error: cannot open corpus file {}: {e}", path.display()); std::process::exit(2); }); + let mut rng = StdRng::seed_from_u64(0xC0DE_5EED); let mut stats = Stats::default(); for (i, line) in io::BufReader::new(file).lines().enumerate() { if let Some(m) = max_iters { - if i >= m { break; } + if i >= m { + break; + } } - fuzz_one(&line.unwrap_or_default(), verbose, &mut stats); + let input = line.unwrap_or_default(); + fuzz_one(&input, verbose, &mut stats); + fuzz_checksum_corruption(&input, &mut rng, verbose, &mut stats); } stats } fn run_stdin(verbose: bool, max_iters: Option) -> Stats { + let mut rng = StdRng::seed_from_u64(0xC0DE_5EED); let mut stats = Stats::default(); for (i, line) in io::stdin().lock().lines().enumerate() { if let Some(m) = max_iters { - if i >= m { break; } + if i >= m { + break; + } } - fuzz_one(&line.unwrap_or_default(), verbose, &mut stats); + let input = line.unwrap_or_default(); + fuzz_one(&input, verbose, &mut stats); + fuzz_checksum_corruption(&input, &mut rng, verbose, &mut stats); } stats } @@ -138,7 +152,7 @@ fn fuzz_one(input: &str, verbose: bool, stats: &mut Stats) { stats.total += 1; let input_owned = input.to_owned(); let res = std::panic::catch_unwind(|| parse::parse(&input_owned)); - + match res { Ok(Ok(addr)) => { stats.ok += 1; @@ -154,12 +168,49 @@ fn fuzz_one(input: &str, verbose: bool, stats: &mut Stats) { } Err(_) => { stats.panics += 1; + stats.report.findings_count += 1; eprintln!("PANIC ← {input:?}"); let _ = std::fs::write("reproducer.txt", input); } } } +/// Flip bits in the trailing CRC-16 of `input` and assert the parser rejects +/// the result as `InvalidChecksum`. Coincidentally-valid CRCs are skipped +/// so they are not logged as false-positive findings. +fn fuzz_checksum_corruption(input: &str, rng: &mut StdRng, verbose: bool, stats: &mut Stats) { + let (mutated, check, finding) = mutators::checksum::fuzz_one(input, rng); + match check { + mutators::checksum::ChecksumCheck::SkippedValidChecksum => { + if verbose { + eprintln!("SKIP checksum still valid ← {mutated:?}"); + } + } + mutators::checksum::ChecksumCheck::RejectedChecksum + | mutators::checksum::ChecksumCheck::RejectedOther => { + stats.total += 1; + stats.err += 1; + if verbose { + eprintln!("ERR checksum ← {mutated:?}"); + } + } + mutators::checksum::ChecksumCheck::Accepted => { + stats.total += 1; + stats.ok += 1; + if let Some(finding) = finding { + stats.report.record_finding(finding); + } + } + mutators::checksum::ChecksumCheck::Panicked => { + stats.total += 1; + stats.panics += 1; + if let Some(finding) = finding { + stats.report.record_finding(finding); + } + } + } +} + const STRKEY_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; fn random_string(rng: &mut StdRng) -> String { @@ -177,6 +228,8 @@ fn random_string(rng: &mut StdRng) -> String { format!("{prefix}{body}") } else { let len = rng.gen_range(0..=128); - (0..len).map(|_| rng.gen_range(0x20u8..=0x7e) as char).collect() + (0..len) + .map(|_| rng.gen_range(0x20u8..=0x7e) as char) + .collect() } } diff --git a/examples/rust-address-fuzzer/src/mutators/checksum.rs b/examples/rust-address-fuzzer/src/mutators/checksum.rs new file mode 100644 index 00000000..35e44c55 --- /dev/null +++ b/examples/rust-address-fuzzer/src/mutators/checksum.rs @@ -0,0 +1,478 @@ +//! Checksum-corruption mutator for Stellar StrKey addresses. +//! +//! StrKey's CRC-16 (XMODEM) is the first line of defence: after a string +//! passes the prefix, length, and base-32 checks, the parser must reject +//! any payload whose trailing two bytes do not match `crc16(payload)`. +//! +//! `corrupt_checksum` flips bits in those trailing checksum bytes **only** +//! (the version byte and payload are left untouched) and re-encodes. The +//! resulting string must never be accepted, and the parser must never panic. +//! +//! A random flip has a 1/65536 chance of landing on the correct CRC (or of +//! flipping zero bits). Those cases are **skipped**, not recorded as +//! findings — otherwise a coincidentally-valid checksum would look like a +//! parser bug. + +use rand::Rng; + +use prism_core::address::ParseError; + +use crate::parse; +use crate::report::Finding; + +// ── Encode / decode helpers (same alphabet and CRC as prism-core) ───────────── + +/// Decode a Stellar StrKey string into raw bytes (version || payload || crc16). +/// Returns `None` if the string contains non-base-32 characters or is too short. +fn strkey_decode(addr: &str) -> Option> { + let s = addr.to_uppercase(); + let s = s.as_bytes(); + let mut bits: u32 = 0; + let mut bit_count: u32 = 0; + let mut out = Vec::with_capacity(s.len() * 5 / 8 + 1); + for &ch in s { + let val: u8 = match ch { + b'A'..=b'Z' => ch - b'A', + b'2'..=b'7' => ch - b'2' + 26, + _ => return None, + }; + bits = (bits << 5) | (val as u32); + bit_count += 5; + if bit_count >= 8 { + bit_count -= 8; + out.push((bits >> bit_count) as u8); + bits &= (1 << bit_count) - 1; + } + } + if out.len() < 3 { + None + } else { + Some(out) + } +} + +/// Encode raw bytes (version || payload || crc16) back to a StrKey string. +fn strkey_encode(data: &[u8]) -> String { + const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = String::with_capacity(data.len() * 8 / 5 + 2); + let mut bits: u32 = 0; + let mut bit_count: u32 = 0; + for &byte in data { + bits = (bits << 8) | (byte as u32); + bit_count += 8; + while bit_count >= 5 { + bit_count -= 5; + out.push(ALPHA[((bits >> bit_count) & 0x1F) as usize] as char); + } + } + if bit_count > 0 { + out.push(ALPHA[((bits << (5 - bit_count)) & 0x1F) as usize] as char); + } + out +} + +/// CRC-16/XMODEM variant used by Stellar StrKey. +fn crc16(data: &[u8]) -> u16 { + let mut crc: u16 = 0x0000; + for &byte in data { + let mut x = (crc >> 8) ^ (byte as u16); + x ^= x >> 4; + crc = (crc << 8) ^ (x << 12) ^ (x << 5) ^ x; + } + crc +} + +/// Returns `true` when `addr` decodes and its trailing two bytes equal +/// `crc16(version || payload)`. This is the CRC check only — it does not +/// run the rest of the parser. +pub fn has_valid_crc(addr: &str) -> bool { + let decoded = match strkey_decode(addr) { + Some(d) if d.len() >= 3 => d, + _ => return false, + }; + let split = decoded.len() - 2; + let stored = u16::from_le_bytes([decoded[split], decoded[split + 1]]); + stored == crc16(&decoded[..split]) +} + +// ── Public mutator ──────────────────────────────────────────────────────────── + +/// Decode `addr`, flip bits in the trailing two CRC-16 bytes only, and +/// re-encode. +/// +/// Each of the 16 checksum bits is XORed with an independent random bit, so +/// the stored CRC is unchanged with probability 1/65536. Callers **must** +/// run [`has_valid_crc`] on the result and skip those cases rather than +/// treating a subsequent `Ok` as a parser bug. +/// +/// If `addr` cannot be decoded, the original string is returned unchanged +/// (the caller will then skip or reject it via the normal parse path). +pub fn corrupt_checksum(addr: &str, rng: &mut impl Rng) -> String { + corrupt_checksum_with_mask(addr, rng.gen()) +} + +/// XOR the trailing CRC-16 with `mask` and re-encode. `mask == 0` is a +/// no-op on a round-trippable address (used by tests to exercise the skip +/// path). +fn corrupt_checksum_with_mask(addr: &str, mask: u16) -> String { + match strkey_decode(addr) { + Some(mut decoded) if decoded.len() >= 3 => { + let n = decoded.len(); + decoded[n - 2] ^= (mask & 0xFF) as u8; + decoded[n - 1] ^= (mask >> 8) as u8; + strkey_encode(&decoded) + } + _ => addr.to_string(), + } +} + +// ── Outcome classification ──────────────────────────────────────────────────── + +/// Result of parsing one checksum-corrupted address. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChecksumCheck { + /// The mutated string still has a matching CRC. Skip — not a finding. + SkippedValidChecksum, + /// Parser correctly returned `ParseError::InvalidChecksum`. + RejectedChecksum, + /// Parser rejected the input for a different reason. Still a rejection, + /// so not a finding (the expected result is "rejected as an error"). + RejectedOther, + /// Parser accepted a CRC-invalid address. This is a finding. + Accepted, + /// Parser panicked. This is a finding. + Panicked, +} + +impl ChecksumCheck { + /// `Accepted` and `Panicked` must be recorded as fuzzer findings. + pub fn is_finding(&self) -> bool { + matches!(self, Self::Accepted | Self::Panicked) + } +} + +/// Classify a (possibly mutated) address: skip coincidentally-valid CRCs, +/// otherwise parse under `catch_unwind` and report the outcome. +pub fn check_corrupted_address(mutated: &str) -> ChecksumCheck { + if has_valid_crc(mutated) { + return ChecksumCheck::SkippedValidChecksum; + } + + let mutated_owned = mutated.to_owned(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + parse::parse(&mutated_owned) + })) { + Ok(Err(ParseError::InvalidChecksum)) => ChecksumCheck::RejectedChecksum, + Ok(Err(_)) => ChecksumCheck::RejectedOther, + Ok(Ok(_)) => ChecksumCheck::Accepted, + Err(_) => ChecksumCheck::Panicked, + } +} + +/// Apply [`corrupt_checksum`], classify the result, and build a [`Finding`] +/// when the parser accepts the corrupted address or panics. +/// +/// Returns `None` for skips and clean rejections; `Some(finding)` when the +/// parser misbehaved. +pub fn fuzz_one(addr: &str, rng: &mut impl Rng) -> (String, ChecksumCheck, Option) { + let mutated = corrupt_checksum(addr, rng); + let check = check_corrupted_address(&mutated); + let finding = match &check { + ChecksumCheck::Accepted => Some(Finding { + input: mutated.clone(), + mutator: "corrupt_checksum".to_string(), + message: "parser accepted an address whose CRC-16 does not match the payload" + .to_string(), + }), + ChecksumCheck::Panicked => Some(Finding { + input: mutated.clone(), + mutator: "corrupt_checksum".to_string(), + message: "parser panicked on a checksum-corrupted address".to_string(), + }), + _ => None, + }; + (mutated, check, finding) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use prism_core::address::{parse, AddressKind}; + use rand::rngs::StdRng; + use rand::SeedableRng; + + /// A valid G-address (verified against the Stellar reference decoder). + const VALID_G: &str = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"; + /// A valid M-address (spec-vector, parseable by prism-core). + const VALID_M: &str = "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAD672"; + + fn seeded_rng(seed: u64) -> StdRng { + StdRng::seed_from_u64(seed) + } + + /// Deterministic valid C-address (the public test vectors are not 56-char strkeys). + fn valid_c() -> String { + crate::generate::random_valid_address(AddressKind::C, &mut seeded_rng(0xC0C0_C0C0)) + } + + fn decode(addr: &str) -> Vec { + strkey_decode(addr).expect("fixture must decode") + } + + // ── fixtures ────────────────────────────────────────────────────────────── + + #[test] + fn base_addresses_are_valid() { + let valid_c = valid_c(); + for (addr, kind) in [ + (VALID_G, AddressKind::G), + (VALID_M, AddressKind::M), + (valid_c.as_str(), AddressKind::C), + ] { + let parsed = parse(addr).unwrap_or_else(|e| panic!("{addr} must parse: {e}")); + assert_eq!(parsed.kind(), kind); + assert!(has_valid_crc(addr), "{addr} must have a matching CRC"); + } + } + + #[test] + fn strkey_encode_decode_roundtrip() { + let valid_c = valid_c(); + for addr in [VALID_G, VALID_M, valid_c.as_str()] { + let raw = decode(addr); + assert_eq!( + strkey_encode(&raw), + addr, + "round-trip must be exact for {addr}" + ); + } + } + + // ── mutator contract ────────────────────────────────────────────────────── + + #[test] + fn only_trailing_checksum_bytes_change() { + let mut rng = seeded_rng(42); + let valid_c = valid_c(); + for addr in [VALID_G, VALID_M, valid_c.as_str()] { + let original = decode(addr); + for _ in 0..64 { + let mutated = corrupt_checksum(addr, &mut rng); + let decoded = decode(&mutated); + assert_eq!( + decoded.len(), + original.len(), + "checksum corruption must not change decoded length" + ); + assert_eq!( + &decoded[..decoded.len() - 2], + &original[..original.len() - 2], + "version byte and payload must be untouched" + ); + } + } + } + + #[test] + fn prefix_and_string_length_are_preserved() { + let mut rng = seeded_rng(7); + let valid_c = valid_c(); + for addr in [VALID_G, VALID_M, valid_c.as_str()] { + let mutated = corrupt_checksum(addr, &mut rng); + assert_eq!(&mutated[..1], &addr[..1], "prefix must be preserved"); + assert_eq!( + mutated.len(), + addr.len(), + "encoded length must be preserved" + ); + } + } + + #[test] + fn undecodable_input_is_returned_unchanged() { + let mut rng = seeded_rng(1); + assert_eq!(corrupt_checksum("not-valid!!!", &mut rng), "not-valid!!!"); + assert_eq!(corrupt_checksum("", &mut rng), ""); + } + + // ── skip path: coincidentally valid CRC ─────────────────────────────────── + + #[test] + fn zero_mask_is_skipped_not_a_finding() { + // mask == 0 leaves the CRC untouched. parse() would return Ok, which + // must be skipped rather than logged as a false-positive finding. + let mutated = corrupt_checksum_with_mask(VALID_G, 0); + assert_eq!(mutated, VALID_G); + assert!(has_valid_crc(&mutated)); + assert_eq!( + check_corrupted_address(&mutated), + ChecksumCheck::SkippedValidChecksum + ); + assert!( + parse(&mutated).is_ok(), + "zero-mask round-trip must still parse" + ); + } + + #[test] + fn restoring_the_correct_crc_is_skipped_not_a_finding() { + // Start from a *wrong* checksum and apply the mask that restores the + // correct CRC. parse() returns Ok — without the skip this would be + // a false positive. + let original = decode(VALID_G); + let n = original.len(); + let correct = u16::from_le_bytes([original[n - 2], original[n - 1]]); + let wrong_mask = 0xA5A5; + let corrupted = corrupt_checksum_with_mask(VALID_G, wrong_mask); + assert!( + !has_valid_crc(&corrupted), + "pre-condition: corrupted string must fail the CRC check" + ); + assert_eq!( + check_corrupted_address(&corrupted), + ChecksumCheck::RejectedChecksum + ); + + let restored = corrupt_checksum_with_mask(&corrupted, wrong_mask); + assert!( + has_valid_crc(&restored), + "restoring the mask must yield a valid CRC" + ); + assert_eq!( + u16::from_le_bytes({ + let d = decode(&restored); + let k = d.len(); + [d[k - 2], d[k - 1]] + }), + correct + ); + assert_eq!( + check_corrupted_address(&restored), + ChecksumCheck::SkippedValidChecksum, + "accidentally-valid CRC must be skipped, not recorded as a finding" + ); + assert!( + parse(&restored).is_ok(), + "restored CRC would parse Ok — that is exactly the false-positive case" + ); + } + + // ── parser must reject real corruptions ─────────────────────────────────── + + fn assert_rejected_as_checksum(addr: &str, seed: u64) { + let mut rng = seeded_rng(seed); + let mutated = corrupt_checksum(addr, &mut rng); + match check_corrupted_address(&mutated) { + ChecksumCheck::SkippedValidChecksum => {} + ChecksumCheck::RejectedChecksum => {} + ChecksumCheck::RejectedOther => panic!( + "checksum-only corruption of a valid address must not fail for \ + a different reason; mutated={mutated:?} seed={seed}" + ), + ChecksumCheck::Accepted => panic!( + "FINDING: parser accepted checksum-corrupted address {mutated:?} (seed={seed})" + ), + ChecksumCheck::Panicked => panic!( + "FINDING: parser panicked on checksum-corrupted address {mutated:?} (seed={seed})" + ), + } + } + + #[test] + fn corrupt_g_always_checksum_err_no_panic() { + for seed in 0..200 { + assert_rejected_as_checksum(VALID_G, seed); + } + } + + #[test] + fn corrupt_m_always_checksum_err_no_panic() { + for seed in 0..200 { + assert_rejected_as_checksum(VALID_M, seed); + } + } + + #[test] + fn corrupt_c_always_checksum_err_no_panic() { + let valid_c = valid_c(); + for seed in 0..200 { + assert_rejected_as_checksum(&valid_c, seed); + } + } + + #[test] + fn every_nonzero_mask_is_rejected_as_invalid_checksum() { + // Exhaustive over a slice of masks: every non-zero XOR of a valid + // address's CRC must produce InvalidChecksum, never Ok, never panic. + let original = decode(VALID_G); + let payload = &original[..original.len() - 2]; + let stored = + u16::from_le_bytes([original[original.len() - 2], original[original.len() - 1]]); + assert_eq!(stored, crc16(payload), "fixture CRC must match"); + + for mask in 1u16..=256 { + let mutated = corrupt_checksum_with_mask(VALID_G, mask); + assert!( + !has_valid_crc(&mutated), + "non-zero mask 0x{mask:04x} must not leave a valid CRC" + ); + assert_eq!( + check_corrupted_address(&mutated), + ChecksumCheck::RejectedChecksum, + "mask 0x{mask:04x} mutated={mutated:?}" + ); + } + } + + #[test] + fn fuzz_one_records_no_finding_on_valid_addresses() { + let mut rng = seeded_rng(99); + let valid_c = valid_c(); + for addr in [VALID_G, VALID_M, valid_c.as_str()] { + for _ in 0..500 { + let (_mutated, check, finding) = fuzz_one(addr, &mut rng); + assert!( + !check.is_finding(), + "checksum corruption of a valid address must not be a finding: {check:?}" + ); + assert!(finding.is_none()); + } + } + } + + #[test] + fn fuzz_one_skip_produces_no_finding() { + // Force the skip path via mask 0, then confirm fuzz_one's finding + // channel stays empty when CRC is still valid. + let mutated = corrupt_checksum_with_mask(VALID_G, 0); + let check = check_corrupted_address(&mutated); + assert_eq!(check, ChecksumCheck::SkippedValidChecksum); + assert!(!check.is_finding()); + } + + #[test] + fn generated_addresses_reject_corrupted_checksums() { + use crate::generate::random_valid_address; + + let mut rng = seeded_rng(0xC0FF_EE11); + for _ in 0..100 { + for &kind in &[AddressKind::G, AddressKind::M, AddressKind::C] { + let addr = random_valid_address(kind, &mut rng); + let (_mutated, check, finding) = fuzz_one(&addr, &mut rng); + assert!( + finding.is_none(), + "finding on generated {kind:?}: {check:?}" + ); + assert!( + matches!( + check, + ChecksumCheck::SkippedValidChecksum | ChecksumCheck::RejectedChecksum + ), + "generated {kind:?} checksum corruption: {check:?}" + ); + } + } + } +} diff --git a/examples/rust-address-fuzzer/src/mutators/mod.rs b/examples/rust-address-fuzzer/src/mutators/mod.rs index e0dee59e..3e46f1be 100644 --- a/examples/rust-address-fuzzer/src/mutators/mod.rs +++ b/examples/rust-address-fuzzer/src/mutators/mod.rs @@ -1,4 +1,5 @@ // Functions are exported for external use; unused in this binary crate. #![allow(dead_code)] +pub mod checksum; pub mod length; pub mod version; diff --git a/examples/rust-address-fuzzer/src/report.rs b/examples/rust-address-fuzzer/src/report.rs index af57059c..b4ccf985 100644 --- a/examples/rust-address-fuzzer/src/report.rs +++ b/examples/rust-address-fuzzer/src/report.rs @@ -1,3 +1,12 @@ +/// A single fuzzer finding: the parser accepted a mutation it must reject, +/// or panicked while parsing it. +#[derive(Debug, Clone)] +pub struct Finding { + pub input: String, + pub mutator: String, + pub message: String, +} + #[derive(Default)] pub struct Report { pub inputs_run: usize, @@ -14,6 +23,16 @@ impl Report { } } + /// Record a finding and persist a one-line reproducer for replay. + pub fn record_finding(&mut self, finding: Finding) { + self.findings_count += 1; + eprintln!( + "FINDING [{}] {} ← {:?}", + finding.mutator, finding.message, finding.input + ); + let _ = std::fs::write("reproducer.txt", &finding.input); + } + pub fn print_json(&self) { println!( r#"{{ From 82d2c705784316b5daa80e8c32cef4ff54137343 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Sat, 29 Aug 2026 18:17:36 +0100 Subject: [PATCH 2/3] ci(rust): run cargo from crate directories The workflow invoked cargo -p from the repo root, which has no Cargo.toml. Run each step in examples/prism-core or examples/rust-address-fuzzer instead. --- .github/workflows/ci-rust.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index e8b83f2d..82ad873e 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -31,13 +31,17 @@ jobs: cache: false - name: Test prism-core (lib) - run: cargo test -p prism-core + working-directory: examples/prism-core + run: cargo test - name: Test rust-address-fuzzer - run: cargo test -p rust-address-fuzzer + working-directory: examples/rust-address-fuzzer + run: cargo test - name: Build prism-diff binary - run: cargo build -p prism-core --features diff --bin prism-diff + working-directory: examples/prism-core + run: cargo build --features diff --bin prism-diff - name: Run prism-diff smoke test (1k random inputs) - run: cargo run -p prism-core --features diff --bin prism-diff -- --random 1000 --seed 42 + working-directory: examples/prism-core + run: cargo run --features diff --bin prism-diff -- --random 1000 --seed 42 From 54ea4f1433cd4344a28039d08e2116c8fd019da9 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Mon, 31 Aug 2026 11:22:17 +0100 Subject: [PATCH 3/3] fix(fuzzer): align checksum mutator with Finding API Drop the duplicate record_finding after merging main. Use panic_message/seed/iteration, add corrupt_checksum to the mutate loop, and skip coincidentally valid CRCs instead of logging them. --- examples/rust-address-fuzzer/src/main.rs | 28 +++++++++++++-- .../src/mutators/checksum.rs | 34 +++++-------------- examples/rust-address-fuzzer/src/report.rs | 10 ------ 3 files changed, 34 insertions(+), 38 deletions(-) diff --git a/examples/rust-address-fuzzer/src/main.rs b/examples/rust-address-fuzzer/src/main.rs index 1c1492c8..e857bb8d 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -119,13 +119,17 @@ fn run_random(rng: &mut StdRng, n: usize, seed: u64, verbose: bool, findings_dir } fn mutate<'a>(base: &str, rng: &mut impl Rng) -> (&'a str, String) { - match rng.gen_range(0..3) { + match rng.gen_range(0..4) { 0 => ("truncate", mutators::length::truncate(base, rng)), 1 => ("pad", mutators::length::pad(base, rng)), - _ => match mutators::version::swap_version_byte(base, rng) { + 2 => match mutators::version::swap_version_byte(base, rng) { Some(result) => ("swap_version_byte", result.mutated), None => ("identity", base.to_owned()), }, + _ => ( + "corrupt_checksum", + mutators::checksum::corrupt_checksum(base, rng), + ), } } @@ -140,7 +144,6 @@ fn run_corpus( eprintln!("error: cannot open corpus file {}: {error}", path.display()); std::process::exit(2); }); - let mut rng = StdRng::seed_from_u64(0xC0DE_5EED); let mut stats = Stats::default(); for (iteration, line) in io::BufReader::new(file).lines().enumerate() { if max_iters.is_some_and(|max| iteration >= max) { @@ -196,6 +199,25 @@ fn fuzz_one( if verbose { eprintln!("OK {:?} ← {input:?}", address.kind()); } + // A checksum-corrupted address that still parses is a finding, + // unless the flip accidentally restored a valid CRC (skip). + if mutator == "corrupt_checksum" && !mutators::checksum::has_valid_crc(input) { + let panic_message = + "parser accepted an address whose CRC-16 does not match the payload".to_owned(); + eprintln!( + "FINDING [{mutator}] at iteration {iteration} ← {input:?}: {panic_message}" + ); + stats.report.record_finding( + report::Finding { + input: input.to_owned(), + mutator: mutator.to_owned(), + panic_message, + seed, + iteration, + }, + findings_dir, + ); + } } Ok(Err(error)) => { stats.err += 1; diff --git a/examples/rust-address-fuzzer/src/mutators/checksum.rs b/examples/rust-address-fuzzer/src/mutators/checksum.rs index 35e44c55..78d2c92f 100644 --- a/examples/rust-address-fuzzer/src/mutators/checksum.rs +++ b/examples/rust-address-fuzzer/src/mutators/checksum.rs @@ -18,7 +18,6 @@ use rand::Rng; use prism_core::address::ParseError; use crate::parse; -use crate::report::Finding; // ── Encode / decode helpers (same alphabet and CRC as prism-core) ───────────── @@ -169,29 +168,15 @@ pub fn check_corrupted_address(mutated: &str) -> ChecksumCheck { } } -/// Apply [`corrupt_checksum`], classify the result, and build a [`Finding`] -/// when the parser accepts the corrupted address or panics. +/// Apply [`corrupt_checksum`] and classify the result. /// -/// Returns `None` for skips and clean rejections; `Some(finding)` when the -/// parser misbehaved. -pub fn fuzz_one(addr: &str, rng: &mut impl Rng) -> (String, ChecksumCheck, Option) { +/// Callers must treat [`ChecksumCheck::Accepted`] and +/// [`ChecksumCheck::Panicked`] as findings. [`ChecksumCheck::SkippedValidChecksum`] +/// is a coincidentally-valid CRC and must not be logged as a false positive. +pub fn fuzz_one(addr: &str, rng: &mut impl Rng) -> (String, ChecksumCheck) { let mutated = corrupt_checksum(addr, rng); let check = check_corrupted_address(&mutated); - let finding = match &check { - ChecksumCheck::Accepted => Some(Finding { - input: mutated.clone(), - mutator: "corrupt_checksum".to_string(), - message: "parser accepted an address whose CRC-16 does not match the payload" - .to_string(), - }), - ChecksumCheck::Panicked => Some(Finding { - input: mutated.clone(), - mutator: "corrupt_checksum".to_string(), - message: "parser panicked on a checksum-corrupted address".to_string(), - }), - _ => None, - }; - (mutated, check, finding) + (mutated, check) } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -432,12 +417,11 @@ mod tests { let valid_c = valid_c(); for addr in [VALID_G, VALID_M, valid_c.as_str()] { for _ in 0..500 { - let (_mutated, check, finding) = fuzz_one(addr, &mut rng); + let (_mutated, check) = fuzz_one(addr, &mut rng); assert!( !check.is_finding(), "checksum corruption of a valid address must not be a finding: {check:?}" ); - assert!(finding.is_none()); } } } @@ -460,9 +444,9 @@ mod tests { for _ in 0..100 { for &kind in &[AddressKind::G, AddressKind::M, AddressKind::C] { let addr = random_valid_address(kind, &mut rng); - let (_mutated, check, finding) = fuzz_one(&addr, &mut rng); + let (_mutated, check) = fuzz_one(&addr, &mut rng); assert!( - finding.is_none(), + !check.is_finding(), "finding on generated {kind:?}: {check:?}" ); assert!( diff --git a/examples/rust-address-fuzzer/src/report.rs b/examples/rust-address-fuzzer/src/report.rs index 79d1082a..a02a391d 100644 --- a/examples/rust-address-fuzzer/src/report.rs +++ b/examples/rust-address-fuzzer/src/report.rs @@ -32,16 +32,6 @@ impl Report { self.findings.push(finding); } - /// Record a finding and persist a one-line reproducer for replay. - pub fn record_finding(&mut self, finding: Finding) { - self.findings_count += 1; - eprintln!( - "FINDING [{}] {} ← {:?}", - finding.mutator, finding.message, finding.input - ); - let _ = std::fs::write("reproducer.txt", &finding.input); - } - pub fn print_json(&self) { println!( "{{\n \"inputs_run\": {},\n \"findings_count\": {},\n \"divergences\": {}\n}}",