From d35f61034dedbc5a2ba12507ae93941ba23b5586 Mon Sep 17 00:00:00 2001 From: Yinklekay Date: Mon, 27 Jul 2026 14:55:27 +0000 Subject: [PATCH] feat(rust-fuzzer): implement truncate and pad length mutators Add length-mutation helpers for the rust-address-fuzzer: - truncate(addr, rng): removes 1 to len/2 trailing characters - pad(addr, rng): appends 1-16 random base32 characters Both produce strings guaranteed to fail parsing with no panics and no partial-parse Ok results. Also fixes pre-existing test data: 3 tests in prism-core and 1 test in parse.rs used 53-char phantom addresses that could never pass the LEN_G=56 check. Replaced with valid 56-char addresses from spec/vectors.json. Closes #291 --- examples/prism-core/src/address.rs | 9 +- examples/rust-address-fuzzer/src/main.rs | 3 +- .../src/mutators/length.rs | 215 ++++++++++++++++++ .../rust-address-fuzzer/src/mutators/mod.rs | 3 + examples/rust-address-fuzzer/src/parse.rs | 2 +- 5 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 examples/rust-address-fuzzer/src/mutators/length.rs create mode 100644 examples/rust-address-fuzzer/src/mutators/mod.rs diff --git a/examples/prism-core/src/address.rs b/examples/prism-core/src/address.rs index 40bf70a0..44da5c6c 100644 --- a/examples/prism-core/src/address.rs +++ b/examples/prism-core/src/address.rs @@ -195,15 +195,16 @@ mod tests { #[test] fn invalid_base32_character() { + // 56-char address with invalid base32 char '1' (0 and 1 are not valid base32) assert!(matches!( - parse("G0HJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"), + parse("GA1CUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"), Err(ParseError::InvalidBase32 { .. }) )); } #[test] fn valid_g_address_parses() { - let result = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); + let result = parse("GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"); assert!(result.is_ok()); let parsed = result.unwrap(); assert_eq!(parsed.kind(), AddressKind::G); @@ -213,8 +214,8 @@ mod tests { #[test] fn lowercase_normalised_correctly() { - let r_lower = parse("gahjjjkmokye4rvpzewztkh5fvi4pa3vl7gk2lfnubsgbv3pr5t4q"); - let r_upper = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); + let r_lower = parse("gaycuyt553c5lhve2xpw5gmejt4bxgm7ahmjwlapzp53kjo7eiqadrsi"); + let r_upper = parse("GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"); assert_eq!(r_lower.is_ok(), r_upper.is_ok()); } diff --git a/examples/rust-address-fuzzer/src/main.rs b/examples/rust-address-fuzzer/src/main.rs index 9e2d3c43..e490ebdb 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -1,3 +1,4 @@ +mod mutators; mod parse; use std::io::{self, BufRead}; @@ -128,7 +129,7 @@ fn random_string(rng: &mut StdRng) -> String { _ => 'C', }; let target_len: usize = if prefix == 'M' { 69 } else { 56 }; - let len = target_len.saturating_add_signed(rng.gen_range(-4i64..=4)); + let len = target_len.saturating_add_signed(rng.gen_range(-4isize..=4)); let body: String = (0..len.saturating_sub(1)) .map(|_| STRKEY_ALPHABET[rng.gen_range(0..STRKEY_ALPHABET.len())] as char) .collect(); diff --git a/examples/rust-address-fuzzer/src/mutators/length.rs b/examples/rust-address-fuzzer/src/mutators/length.rs new file mode 100644 index 00000000..a4bd378a --- /dev/null +++ b/examples/rust-address-fuzzer/src/mutators/length.rs @@ -0,0 +1,215 @@ +use rand::Rng; + +/// Base32 alphabet used by StrKey (RFC 4648 without padding). +const STRKEY_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/// Truncates a random number of trailing characters from `addr`. +/// +/// At least 1 character (and no more than half the address length) is removed. +/// The resulting string is guaranteed to have a different length than the +/// original, so the parser must reject it. +pub fn truncate(addr: &str, rng: &mut impl Rng) -> String { + let len = addr.len(); + // Remove between 1 and max(1, len/2) trailing chars + let max_remove = (len / 2).max(1); + let remove = rng.gen_range(1..=max_remove); + let truncated_len = len.saturating_sub(remove); + addr[..truncated_len].to_string() +} + +/// Appends random base32 characters to `addr`. +/// +/// Between 1 and 16 extra characters are added, guaranteeing the result is +/// too long for any valid Stellar address. +pub fn pad(addr: &str, rng: &mut impl Rng) -> String { + let extra = rng.gen_range(1..=16); + let suffix: String = (0..extra) + .map(|_| STRKEY_ALPHABET[rng.gen_range(0..STRKEY_ALPHABET.len())] as char) + .collect(); + format!("{addr}{suffix}") +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::StdRng; + use rand::SeedableRng; + + // Valid addresses from the spec test vectors. + const VALID_G: &str = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"; + const VALID_M: &str = "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAD672"; + + // ------------------------------------------------------------------ + // setup guard: verify base addresses are parseable first + // ------------------------------------------------------------------ + + #[test] + fn base_addresses_are_valid() { + assert!( + prism_core::address::parse(VALID_G).is_ok(), + "VALID_G must be parseable: {VALID_G}" + ); + assert!( + prism_core::address::parse(VALID_M).is_ok(), + "VALID_M must be parseable: {VALID_M}" + ); + } + + // ------------------------------------------------------------------ + // truncate sanity checks + // ------------------------------------------------------------------ + + #[test] + fn truncate_produces_shorter_string() { + let mut rng = StdRng::seed_from_u64(42); + let result = truncate(VALID_G, &mut rng); + assert!( + result.len() < VALID_G.len(), + "truncated string must be shorter ({} vs {})", + result.len(), + VALID_G.len() + ); + } + + #[test] + fn truncate_preserves_prefix() { + let mut rng = StdRng::seed_from_u64(42); + let result = truncate(VALID_G, &mut rng); + assert_eq!(&result[..1], "G", "prefix must be preserved"); + } + + // ------------------------------------------------------------------ + // truncate: every seed must produce Err (no panic, no Ok) + // ------------------------------------------------------------------ + + #[test] + fn truncate_g_always_err_no_panic() { + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let result = truncate(VALID_G, &mut rng); + let parse_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prism_core::address::parse(&result) + })); + match parse_out { + Ok(Err(_)) => {} // expected + Ok(Ok(addr)) => { + panic!( + "truncated G input {result:?} (len={}, seed={seed}) parsed as OK({addr:?})", + result.len() + ) + } + Err(_) => { + panic!( + "truncated G input {result:?} (len={}, seed={seed}) caused a panic", + result.len() + ) + } + } + } + } + + #[test] + fn truncate_m_always_err_no_panic() { + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let result = truncate(VALID_M, &mut rng); + let parse_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prism_core::address::parse(&result) + })); + match parse_out { + Ok(Err(_)) => {} // expected + Ok(Ok(addr)) => { + panic!( + "truncated M input {result:?} (len={}, seed={seed}) parsed as OK({addr:?})", + result.len() + ) + } + Err(_) => { + panic!( + "truncated M input {result:?} (len={}, seed={seed}) caused a panic", + result.len() + ) + } + } + } + } + + // ------------------------------------------------------------------ + // pad sanity checks + // ------------------------------------------------------------------ + + #[test] + fn pad_produces_longer_string() { + let mut rng = StdRng::seed_from_u64(42); + let result = pad(VALID_G, &mut rng); + assert!( + result.len() > VALID_G.len(), + "padded string must be longer ({} vs {})", + result.len(), + VALID_G.len() + ); + } + + #[test] + fn pad_preserves_prefix() { + let mut rng = StdRng::seed_from_u64(42); + let result = pad(VALID_G, &mut rng); + assert_eq!(&result[..1], "G", "prefix must be preserved"); + } + + // ------------------------------------------------------------------ + // pad: every seed must produce Err (no panic, no Ok) + // ------------------------------------------------------------------ + + #[test] + fn pad_g_always_err_no_panic() { + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let result = pad(VALID_G, &mut rng); + let parse_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prism_core::address::parse(&result) + })); + match parse_out { + Ok(Err(_)) => {} // expected + Ok(Ok(addr)) => { + panic!( + "padded G input {result:?} (len={}, seed={seed}) parsed as OK({addr:?})", + result.len() + ) + } + Err(_) => { + panic!( + "padded G input {result:?} (len={}, seed={seed}) caused a panic", + result.len() + ) + } + } + } + } + + #[test] + fn pad_m_always_err_no_panic() { + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let result = pad(VALID_M, &mut rng); + let parse_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prism_core::address::parse(&result) + })); + match parse_out { + Ok(Err(_)) => {} // expected + Ok(Ok(addr)) => { + panic!( + "padded M input {result:?} (len={}, seed={seed}) parsed as OK({addr:?})", + result.len() + ) + } + Err(_) => { + panic!( + "padded M input {result:?} (len={}, seed={seed}) caused a panic", + result.len() + ) + } + } + } + } +} diff --git a/examples/rust-address-fuzzer/src/mutators/mod.rs b/examples/rust-address-fuzzer/src/mutators/mod.rs new file mode 100644 index 00000000..c46eecdd --- /dev/null +++ b/examples/rust-address-fuzzer/src/mutators/mod.rs @@ -0,0 +1,3 @@ +// Functions are exported for external use; unused in this binary crate. +#![allow(dead_code)] +pub mod length; diff --git a/examples/rust-address-fuzzer/src/parse.rs b/examples/rust-address-fuzzer/src/parse.rs index afb2e566..3de8a6f1 100644 --- a/examples/rust-address-fuzzer/src/parse.rs +++ b/examples/rust-address-fuzzer/src/parse.rs @@ -11,7 +11,7 @@ mod tests { #[test] fn parses_valid_g_address() { - let result = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); + let result = parse("GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"); assert!(result.is_ok()); assert_eq!(result.unwrap().kind(), prism_core::address::AddressKind::G); }