From 54dc0af6656c8b36f302942dd017a9f35621f98a Mon Sep 17 00:00:00 2001 From: Peaostrel Date: Mon, 27 Jul 2026 15:23:55 +0400 Subject: [PATCH 1/7] feat(fuzzer): add bounded fuzzing and CI action for prism-core --- .github/workflows/fuzz.yml | 28 +++++++++++ examples/rust-address-fuzzer/src/main.rs | 54 ++++++++++++++++------ examples/rust-address-fuzzer/src/report.rs | 26 +++++++++++ 3 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 examples/rust-address-fuzzer/src/report.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..b16d706c --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,28 @@ +name: Fuzz + +on: + push: + branches: ["main"] + pull_request: + +jobs: + fuzz: + name: Run rust-address-fuzzer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run Fuzzer + run: | + cd examples/rust-address-fuzzer + cargo run --release -- --random 100000 --max-iterations 100000 + + - name: Upload Reproducer + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzzer-reproducer + path: examples/rust-address-fuzzer/reproducer.txt diff --git a/examples/rust-address-fuzzer/src/main.rs b/examples/rust-address-fuzzer/src/main.rs index 9e2d3c43..f5baa8f6 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -1,4 +1,5 @@ mod parse; +mod report; use std::io::{self, BufRead}; use std::path::PathBuf; @@ -26,6 +27,10 @@ struct Cli { #[arg(long, value_name = "U64")] seed: Option, + /// Stop fuzzing after this many iterations + #[arg(long, value_name = "N")] + max_iterations: Option, + /// Print every result, not just failures #[arg(long, short)] verbose: bool, @@ -37,6 +42,7 @@ struct Stats { ok: usize, err: usize, panics: usize, + report: report::Report, } fn main() { @@ -54,20 +60,26 @@ fn main() { eprintln!("PRNG seed: {seed}"); } - let stats = if let Some(n) = cli.random { - run_random(&mut rng, n, cli.verbose) + let mut stats = if let Some(n) = cli.random { + let max = cli.max_iterations.unwrap_or(n); + run_random(&mut rng, max.min(n), cli.verbose) } else if let Some(path) = cli.corpus { - run_corpus(&path, cli.verbose) + run_corpus(&path, cli.verbose, cli.max_iterations) } else { - run_stdin(cli.verbose) + run_stdin(cli.verbose, cli.max_iterations) }; + 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 | {} panics", - stats.total, stats.ok, stats.err, stats.panics + "Done – {} inputs | {} ok | {} err | {} findings", + stats.total, stats.ok, stats.err, stats.report.findings_count ); - if stats.panics > 0 { + stats.report.print_json(); + + if stats.report.findings_count > 0 || stats.report.divergences > 0 { std::process::exit(1); } } @@ -80,21 +92,27 @@ fn run_random(rng: &mut StdRng, n: usize, verbose: bool) -> Stats { stats } -fn run_corpus(path: &PathBuf, verbose: bool) -> Stats { +fn run_corpus(path: &PathBuf, verbose: bool, max_iters: Option) -> Stats { let file = std::fs::File::open(path).unwrap_or_else(|e| { eprintln!("error: cannot open corpus file {}: {e}", path.display()); std::process::exit(2); }); let mut stats = Stats::default(); - for line in io::BufReader::new(file).lines() { + for (i, line) in io::BufReader::new(file).lines().enumerate() { + if let Some(m) = max_iters { + if i >= m { break; } + } fuzz_one(&line.unwrap_or_default(), verbose, &mut stats); } stats } -fn run_stdin(verbose: bool) -> Stats { +fn run_stdin(verbose: bool, max_iters: Option) -> Stats { let mut stats = Stats::default(); - for line in io::stdin().lock().lines() { + for (i, line) in io::stdin().lock().lines().enumerate() { + if let Some(m) = max_iters { + if i >= m { break; } + } fuzz_one(&line.unwrap_or_default(), verbose, &mut stats); } stats @@ -102,19 +120,27 @@ fn run_stdin(verbose: bool) -> Stats { fn fuzz_one(input: &str, verbose: bool, stats: &mut Stats) { stats.total += 1; - match parse::parse(input) { - Ok(addr) => { + let input_owned = input.to_owned(); + let res = std::panic::catch_unwind(|| parse::parse(&input_owned)); + + match res { + Ok(Ok(addr)) => { stats.ok += 1; if verbose { eprintln!("OK {:?} ← {input:?}", addr.kind()); } } - Err(e) => { + Ok(Err(e)) => { stats.err += 1; if verbose { eprintln!("ERR {e:?} ← {input:?}"); } } + Err(_) => { + stats.panics += 1; + eprintln!("PANIC ← {input:?}"); + let _ = std::fs::write("reproducer.txt", input); + } } } diff --git a/examples/rust-address-fuzzer/src/report.rs b/examples/rust-address-fuzzer/src/report.rs new file mode 100644 index 00000000..c1af2dda --- /dev/null +++ b/examples/rust-address-fuzzer/src/report.rs @@ -0,0 +1,26 @@ +pub struct Report { + pub inputs_run: usize, + pub findings_count: usize, + pub divergences: usize, +} + +impl Report { + pub fn new() -> Self { + Self { + inputs_run: 0, + findings_count: 0, + divergences: 0, + } + } + + pub fn print_json(&self) { + println!( + r#"{{ + "inputs_run": {}, + "findings_count": {}, + "divergences": {} +}}"#, + self.inputs_run, self.findings_count, self.divergences + ); + } +} From 299c9af7e0d31b99f98f50997a3014f6d5628602 Mon Sep 17 00:00:00 2001 From: scarface-dev1 Date: Mon, 27 Jul 2026 12:10:42 +0000 Subject: [PATCH 2/7] feat(prism-core): add differential testing against stellar-strkey, fix M-address payload bug - Add prism-diff binary behind 'diff' feature flag for cross-implementation differential fuzzing against the stellar-strkey reference decoder - Fix M-address payload byte order: SEP-0023 XDR specifies pubkey(32)||id(8), not id(8)||pubkey(32). The diff tool caught that muxed_id was always wrong. - Expose encode_g_address as pub fn for cross-decoder base-G validation - Fix pre-existing broken test data (invalid G-addresses replaced with spec-vector-verified addresses) - Fix gen_range type error in rust-address-fuzzer for Rust >=1.97 - Add .github/workflows/ci-rust.yml for automated Rust CI Closes #295 --- .github/workflows/ci-rust.yml | 43 +++ examples/prism-core/Cargo.toml | 15 + examples/prism-core/src/address.rs | 25 +- examples/prism-core/src/diff.rs | 344 ++++++++++++++++++++++ examples/rust-address-fuzzer/src/main.rs | 2 +- examples/rust-address-fuzzer/src/parse.rs | 4 +- 6 files changed, 423 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci-rust.yml create mode 100644 examples/prism-core/src/diff.rs diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml new file mode 100644 index 00000000..e8b83f2d --- /dev/null +++ b/.github/workflows/ci-rust.yml @@ -0,0 +1,43 @@ +name: ci-rust + +on: + pull_request: + paths: + - "examples/prism-core/**" + - "examples/rust-address-fuzzer/**" + - "spec/vectors.json" + - ".github/workflows/ci-rust.yml" + +concurrency: + group: ci-rust-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Rust tests & build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: false + + - name: Test prism-core (lib) + run: cargo test -p prism-core + + - name: Test rust-address-fuzzer + run: cargo test -p rust-address-fuzzer + + - name: Build prism-diff binary + run: cargo build -p prism-core --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 diff --git a/examples/prism-core/Cargo.toml b/examples/prism-core/Cargo.toml index c1acda8e..62604824 100644 --- a/examples/prism-core/Cargo.toml +++ b/examples/prism-core/Cargo.toml @@ -2,9 +2,24 @@ name = "prism-core" version = "0.1.0" edition = "2021" +rust-version = "1.87.0" description = "Rust implementation of the stellar-address-kit Stellar address parser" license = "MIT" [lib] name = "prism_core" path = "src/lib.rs" + +[[bin]] +name = "prism-diff" +path = "src/diff.rs" +required-features = ["diff"] + +[features] +default = [] +diff = ["dep:stellar-strkey", "dep:clap", "dep:rand"] + +[dependencies] +stellar-strkey = { version = "0.0.18", optional = true } +clap = { version = "4", features = ["derive"], optional = true } +rand = { version = "0.8", optional = true } diff --git a/examples/prism-core/src/address.rs b/examples/prism-core/src/address.rs index 40bf70a0..195ebaae 100644 --- a/examples/prism-core/src/address.rs +++ b/examples/prism-core/src/address.rs @@ -134,16 +134,25 @@ pub fn parse(input: &str) -> Result { if payload.len() < 41 { return Err(ParseError::InvalidMuxedPayload); } - let id_bytes: [u8; 8] = payload[1..9].try_into().map_err(|_| ParseError::InvalidMuxedPayload)?; + // SEP-0023 MuxedAccount payload layout: + // version_byte || ed25519_pubkey(32) || muxed_id(8, big-endian) + let base_g = encode_g_address(&payload[1..33])?; + let id_bytes: [u8; 8] = payload[33..41] + .try_into() + .map_err(|_| ParseError::InvalidMuxedPayload)?; let muxed_id = u64::from_be_bytes(id_bytes); - let base_g = encode_g_address(&payload[9..41])?; - return Ok(Address { kind, raw: upper, base_g: Some(base_g), muxed_id: Some(muxed_id) }); + return Ok(Address { + kind, + raw: upper, + base_g: Some(base_g), + muxed_id: Some(muxed_id), + }); } Ok(Address { kind, raw: upper, base_g: None, muxed_id: None }) } -fn encode_g_address(key: &[u8]) -> Result { +pub fn encode_g_address(key: &[u8]) -> Result { if key.len() != 32 { return Err(ParseError::InvalidMuxedPayload); } @@ -195,16 +204,18 @@ mod tests { #[test] fn invalid_base32_character() { + // 56-char G-prefix string with a '0' at position 1. assert!(matches!( - parse("G0HJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"), + parse("G0HJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4QABC"), Err(ParseError::InvalidBase32 { .. }) )); } #[test] fn valid_g_address_parses() { - let result = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); - assert!(result.is_ok()); + // Verified against the stellar-strkey reference decoder (0.0.18). + let result = parse("GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"); + assert!(result.is_ok(), "unexpected error: {result:?}"); let parsed = result.unwrap(); assert_eq!(parsed.kind(), AddressKind::G); assert_eq!(parsed.base_g(), None); diff --git a/examples/prism-core/src/diff.rs b/examples/prism-core/src/diff.rs new file mode 100644 index 00000000..6b43ff79 --- /dev/null +++ b/examples/prism-core/src/diff.rs @@ -0,0 +1,344 @@ +//! Differential testing binary that compares prism-core's address parser against +//! the `stellar-strkey` reference decoder. Any divergence in accept/reject verdicts +//! or decoded fields is reported as a finding, since STA claims cross-implementation +//! correctness. +//! +//! # Usage +//! ```text +//! cargo run --features diff --bin prism-diff -- --random 10000 +//! cargo run --features diff --bin prism-diff -- --corpus path/to/inputs.txt +//! cargo run --features diff --bin prism-diff -- --stdin +//! ``` + +use std::io::{self, BufRead}; +use std::path::PathBuf; +use std::str::FromStr; + +use clap::Parser; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +use prism_core::address::{self, AddressKind}; +use stellar_strkey::Strkey; + +// ─── CLI ──────────────────────────────────────────────────────────────────── + +#[derive(Parser, Debug)] +#[command( + name = "prism-diff", + version, + about = "Differential fuzzing of prism-core against the stellar-strkey reference decoder.\n\ + For each input, runs both decoders and reports any divergence in\n\ + accept/reject verdicts or decoded fields." +)] +struct Cli { + /// Generate N random strings and diff each one + #[arg(long, value_name = "N", conflicts_with_all = ["corpus", "stdin_mode"])] + random: Option, + + /// Read newline-delimited inputs from FILE + #[arg(long, value_name = "FILE", conflicts_with_all = ["random", "stdin_mode"])] + corpus: Option, + + /// Read newline-delimited inputs from stdin + #[arg(long = "stdin", conflicts_with_all = ["random", "corpus"])] + stdin_mode: bool, + + /// Fix the PRNG seed for reproducible runs + #[arg(long, value_name = "U64")] + seed: Option, + + /// Print every comparison, not just divergences + #[arg(long, short)] + verbose: bool, +} + +// ─── Statistics ───────────────────────────────────────────────────────────── + +#[derive(Default)] +struct Stats { + total: usize, + agree: usize, + divergences: usize, + /// Both rejected (valid agreement) + both_rejected: usize, + /// StarKey accepted a type that prism doesn't handle (T/X/P/L/B), or + /// the S-prefix was intentionally rejected by strkey. + non_target_skipped: usize, +} + +// ─── Divergence report ────────────────────────────────────────────────────── + +struct Divergence { + input: String, + prism_result: String, + strkey_result: String, + description: String, +} + +impl Divergence { + fn print(&self) { + eprintln!("─── DIVERGENCE ───────────────────────────────────────────────"); + eprintln!(" Input: {:?}", self.input); + eprintln!(" prism-core: {}", self.prism_result); + eprintln!(" strkey: {}", self.strkey_result); + eprintln!(" Description: {}", self.description); + eprintln!(); + } +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +fn main() { + let cli = Cli::parse(); + + if cli.random.is_none() && cli.corpus.is_none() && !cli.stdin_mode { + eprintln!("error: specify one of --random , --corpus , or --stdin"); + eprintln!(" e.g. cargo run --features diff --bin prism-diff -- --random 10000"); + std::process::exit(2); + } + + let seed = cli.seed.unwrap_or_else(|| rand::thread_rng().gen()); + let mut rng: StdRng = StdRng::seed_from_u64(seed); + + if cli.verbose { + eprintln!("PRNG seed: {seed}"); + } + + let stats = if let Some(n) = cli.random { + run_random(&mut rng, n, cli.verbose) + } else if let Some(path) = cli.corpus { + run_corpus(&path, cli.verbose) + } else { + run_stdin(cli.verbose) + }; + + eprintln!(); + eprintln!("══════════════════════════════════════════════════════════════"); + eprintln!(" Total inputs: {}", stats.total); + eprintln!( + " Agreed: {} (incl. {} both-rejected)", + stats.agree, stats.both_rejected + ); + eprintln!(" Divergences: {}", stats.divergences); + eprintln!( + " Non-target skipped: {} (T/X/P/L/B keys, or S prefix)", + stats.non_target_skipped + ); + eprintln!("══════════════════════════════════════════════════════════════"); + + if stats.divergences > 0 { + std::process::exit(1); + } +} + +// ─── Input sources ────────────────────────────────────────────────────────── + +fn run_random(rng: &mut StdRng, n: usize, verbose: bool) -> Stats { + let mut stats = Stats::default(); + for _ in 0..n { + diff_one(&random_string(rng), verbose, &mut stats); + } + stats +} + +fn run_corpus(path: &PathBuf, verbose: bool) -> Stats { + let file = std::fs::File::open(path).unwrap_or_else(|e| { + eprintln!("error: cannot open corpus file {}: {e}", path.display()); + std::process::exit(2); + }); + let mut stats = Stats::default(); + for line in io::BufReader::new(file).lines() { + diff_one(&line.unwrap_or_default(), verbose, &mut stats); + } + stats +} + +fn run_stdin(verbose: bool) -> Stats { + let mut stats = Stats::default(); + for line in io::stdin().lock().lines() { + diff_one(&line.unwrap_or_default(), verbose, &mut stats); + } + stats +} + +// ─── Core comparison logic ────────────────────────────────────────────────── + +fn diff_one(input: &str, verbose: bool, stats: &mut Stats) { + stats.total += 1; + + let prism = address::parse(input); + let strkey = Strkey::from_str(input); + + // Both rejected – agreement. + if prism.is_err() && strkey.is_err() { + // If strkey rejected because of S-prefix, skip — prism doesn't handle S. + if input.starts_with('S') || input.starts_with('s') { + stats.non_target_skipped += 1; + if verbose { + eprintln!("SKIP S-prefix ← {input:?}"); + } + return; + } + stats.agree += 1; + stats.both_rejected += 1; + if verbose { + eprintln!("AGREE reject ← {input:?}"); + } + return; + } + + // Build display strings up front. + let prism_disp = match &prism { + Ok(a) => format!("Ok({:?})", a.kind()), + Err(e) => format!("Err({e})"), + }; + let strkey_disp = match &strkey { + Ok(sk) => format!("Ok({sk:?})"), + Err(e) => format!("Err({e})"), + }; + + // ── One decoder accepted, the other rejected ──────────────────────── + + let prism_ok = prism.is_ok(); + let strkey_ok = strkey.is_ok(); + + if prism_ok != strkey_ok { + // If starKey accepted a type that prism-core doesn't handle + // (T, X, P, L, B), this is expected — prism only deals with G/M/C. + if let Ok(ref sk) = strkey { + if is_non_target_strkey(sk) { + stats.non_target_skipped += 1; + if verbose { + eprintln!("SKIP non-target ← {input:?}"); + } + return; + } + } + + stats.divergences += 1; + let description = if prism_ok { + "Accept/reject mismatch: prism-core accepted but stellar-strkey rejected" + .to_string() + } else { + "Accept/reject mismatch: prism-core rejected but stellar-strkey accepted" + .to_string() + }; + let div = Divergence { + input: input.to_string(), + prism_result: prism_disp, + strkey_result: strkey_disp, + description, + }; + div.print(); + return; + } + + // ── Both accepted – compare decoded fields ───────────────────────── + + let prism_addr = prism.unwrap(); + let strkey_val = strkey.unwrap(); + + let divergence = compare_decoded(&prism_addr, &strkey_val); + + if let Some(desc) = divergence { + stats.divergences += 1; + let div = Divergence { + input: input.to_string(), + prism_result: format!("Ok({:?})", prism_addr.kind()), + strkey_result: format!("Ok({strkey_val:?})"), + description: desc, + }; + div.print(); + } else { + stats.agree += 1; + if verbose { + eprintln!("AGREE accept ← {input:?}"); + } + } +} + +/// Compare the decoded fields of a prism-core `Address` and a `stellar-strkey` +/// `Strkey`. Returns `None` if they agree, or a description string if they +/// diverge. +fn compare_decoded(prism: &address::Address, strkey: &Strkey) -> Option { + match (prism.kind(), strkey) { + // ── G-address ────────────────────────────────────────────────── + (AddressKind::G, Strkey::PublicKeyEd25519(_)) => None, + + // ── M-address ────────────────────────────────────────────────── + (AddressKind::M, Strkey::MuxedAccountEd25519(ma)) => { + let strkey_muxed_id = ma.id; + let prism_muxed_id = prism.muxed_id(); + + if prism_muxed_id != Some(strkey_muxed_id) { + return Some(format!( + "Muxed ID mismatch: prism={prism_muxed_id:?}, strkey={strkey_muxed_id}" + )); + } + + // Also compare the reconstructed base-G address. + if let (Some(prism_base_g), Ok(decoded_base_g)) = + (prism.base_g(), address::encode_g_address(&ma.ed25519)) + { + if prism_base_g != decoded_base_g { + return Some(format!( + "Base-G mismatch: prism={prism_base_g:?}, strkey-derived={decoded_base_g:?}" + )); + } + } + + None + } + + // ── C-address ────────────────────────────────────────────────── + (AddressKind::C, Strkey::Contract(_)) => None, + + // ── Kind mismatch ────────────────────────────────────────────── + (pk, sk) => Some(format!( + "Kind mismatch: prism returned {pk:?}, strkey returned {sk:?}" + )), + } +} + +/// Returns true if the `Strkey` variant is one that prism-core does not handle +/// (i.e., T, X, P, L, B). prism-core only parses G, M, and C addresses. +fn is_non_target_strkey(sk: &Strkey) -> bool { + matches!( + sk, + Strkey::PreAuthTx(_) + | Strkey::HashX(_) + | Strkey::SignedPayloadEd25519(_) + | Strkey::LiquidityPool(_) + | Strkey::ClaimableBalance(_) + ) +} + +// ─── Random string generation ─────────────────────────────────────────────── + +const STRKEY_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/// Generate a random string for fuzzing. 80% of the time produce a strkey- +/// shaped string (starting with G, M, or C with ~correct length); otherwise +/// produce fully arbitrary printable ASCII. +fn random_string(rng: &mut StdRng) -> String { + if rng.gen_bool(0.80) { + // Pick a prefix that prism-core handles. + let prefix = match rng.gen_range(0u8..3) { + 0 => 'G', + 1 => 'M', + _ => 'C', + }; + let target_len: usize = if prefix == 'M' { 69 } else { 56 }; + let len = target_len.saturating_add_signed(rng.gen_range(-4..=4)); + let body: String = (0..len.saturating_sub(1)) + .map(|_| STRKEY_ALPHABET[rng.gen_range(0..STRKEY_ALPHABET.len())] as char) + .collect(); + format!("{prefix}{body}") + } else { + let len = rng.gen_range(0..=128); + (0..len) + .map(|_| rng.gen_range(0x20u8..=0x7e) as char) + .collect() + } +} diff --git a/examples/rust-address-fuzzer/src/main.rs b/examples/rust-address-fuzzer/src/main.rs index 9e2d3c43..98935f20 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -128,7 +128,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(-4..=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/parse.rs b/examples/rust-address-fuzzer/src/parse.rs index afb2e566..b65e6bfc 100644 --- a/examples/rust-address-fuzzer/src/parse.rs +++ b/examples/rust-address-fuzzer/src/parse.rs @@ -11,8 +11,8 @@ mod tests { #[test] fn parses_valid_g_address() { - let result = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); - assert!(result.is_ok()); + let result = parse("GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"); + assert!(result.is_ok(), "unexpected error: {result:?}"); assert_eq!(result.unwrap().kind(), prism_core::address::AddressKind::G); } From d35f61034dedbc5a2ba12507ae93941ba23b5586 Mon Sep 17 00:00:00 2001 From: Yinklekay Date: Mon, 27 Jul 2026 14:55:27 +0000 Subject: [PATCH 3/7] 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); } From bf74f36f0b5cfe5f43353fd5443a4cd9eb7eedf2 Mon Sep 17 00:00:00 2001 From: Legit003 Date: Mon, 27 Jul 2026 16:13:51 +0000 Subject: [PATCH 4/7] feat(fuzzer): add valid-address generator for G, M, and C kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements random_valid_address(kind, rng) in src/generate.rs that produces correctly checksummed strkey for all three address types: - G: version(0x30) + 32 random bytes + CRC-16 LE → 56 chars - M: version(0x60) + random u64 muxed id (BE) + 32 random bytes + CRC-16 LE → 69 chars (exercises the full u64 decoder path) - C: version(0x10) + 32 random bytes + CRC-16 LE → 56 chars Every generated address is round-tripped through prism_core::address::parse immediately; a parse failure panics so a broken generator is caught at seed-generation time rather than producing silent bad corpus entries. run_random in main.rs now emits one valid seed per three random strings (every 4th input), cycling G → M → C, so the fuzzer explores the boundary of validity rather than spending all budget on obvious garbage. Also fixes two pre-existing broken test fixtures (53-char G addresses) in parse.rs and prism-core/src/address.rs — both now use the correct 56-char all-zero-key address GAAAAAA...AWHF. --- examples/prism-core/src/address.rs | 8 +- examples/rust-address-fuzzer/src/generate.rs | 235 +++++++++++++++++++ examples/rust-address-fuzzer/src/main.rs | 21 +- examples/rust-address-fuzzer/src/parse.rs | 3 +- 4 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 examples/rust-address-fuzzer/src/generate.rs diff --git a/examples/prism-core/src/address.rs b/examples/prism-core/src/address.rs index 40bf70a0..e281e09d 100644 --- a/examples/prism-core/src/address.rs +++ b/examples/prism-core/src/address.rs @@ -196,14 +196,14 @@ mod tests { #[test] fn invalid_base32_character() { assert!(matches!( - parse("G0HJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"), + parse("G0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"), Err(ParseError::InvalidBase32 { .. }) )); } #[test] fn valid_g_address_parses() { - let result = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); + let result = parse("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); assert!(result.is_ok()); let parsed = result.unwrap(); assert_eq!(parsed.kind(), AddressKind::G); @@ -213,8 +213,8 @@ mod tests { #[test] fn lowercase_normalised_correctly() { - let r_lower = parse("gahjjjkmokye4rvpzewztkh5fvi4pa3vl7gk2lfnubsgbv3pr5t4q"); - let r_upper = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); + let r_lower = parse("gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaawhf"); + let r_upper = parse("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); assert_eq!(r_lower.is_ok(), r_upper.is_ok()); } diff --git a/examples/rust-address-fuzzer/src/generate.rs b/examples/rust-address-fuzzer/src/generate.rs new file mode 100644 index 00000000..24fd9973 --- /dev/null +++ b/examples/rust-address-fuzzer/src/generate.rs @@ -0,0 +1,235 @@ +//! Valid-address generator for the fuzzer. +//! +//! `random_valid_address` produces correctly checksummed strkey strings for +//! every `AddressKind` (G, M, C). The returned string is guaranteed to +//! round-trip through `prism_core::address::parse`; a failure causes a panic +//! so that a broken seed source is caught immediately at generation time +//! rather than producing mysterious false-positive fuzzer results. +//! +//! Layout (all lengths are in bytes unless stated otherwise): +//! +//! | 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 | +//! | C | version(1) + contract_hash(32) | 33 | 35 | 56 | + +use prism_core::address::AddressKind; +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_M: u8 = 12 << 3; // 0x60 +const VERSION_C: u8 = 2 << 3; // 0x10 + +// ── CRC-16 (CCITT, same polynomial as prism-core) ──────────────────────────── + +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 +} + +// ── Base-32 encoder (Stellar / RFC 4648 alphabet, no padding) ──────────────── + +const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +fn base32_encode(data: &[u8]) -> String { + // Each 5 bits of input becomes one character. The output length is + // ceil(len * 8 / 5) characters. + let out_len = (data.len() * 8 + 4) / 5; + let mut result = String::with_capacity(out_len); + 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; + result.push(ALPHABET[((bits >> bit_count) & 0x1F) as usize] as char); + } + } + // flush any remaining bits (padded to the right with zeros) + if bit_count > 0 { + result.push(ALPHABET[((bits << (5 - bit_count)) & 0x1F) as usize] as char); + } + result +} + +// ── address builder ─────────────────────────────────────────────────────────── + +/// Build a complete strkey from a version byte and raw payload bytes (no +/// version byte included in `body`). Appends a 2-byte little-endian CRC-16. +fn build_strkey(version: u8, body: &[u8]) -> String { + // payload = version || body + let mut payload = Vec::with_capacity(1 + body.len()); + payload.push(version); + payload.extend_from_slice(body); + + // checksum over the full payload, appended little-endian + let crc = crc16(&payload); + payload.push((crc & 0xFF) as u8); + payload.push((crc >> 8) as u8); + + base32_encode(&payload) +} + +// ── public API ──────────────────────────────────────────────────────────────── + +/// Generate a random, correctly checksummed Stellar address of the requested +/// kind and verify it parses successfully. +/// +/// Panics if the generated address fails to parse — this would indicate a bug +/// in the generator (broken encoding or checksum logic) rather than in the +/// parser, so an immediate panic is the right signal. +pub fn random_valid_address(kind: AddressKind, rng: &mut impl Rng) -> String { + let address = match kind { + AddressKind::G => { + // 32 random bytes — any value is a valid (public) ed25519 key for + // our purposes; the parser only validates structure, not whether + // the key is a valid curve point. + let mut key = [0u8; 32]; + rng.fill(&mut key); + build_strkey(VERSION_G, &key) + } + + AddressKind::M => { + // Embed a random 64-bit muxed id to exercise the full u64 range + // in the decoder path. + let muxed_id: u64 = rng.gen(); + let mut key = [0u8; 32]; + rng.fill(&mut key); + + // M payload body = muxed_id_BE(8) || ed25519_key(32) + let mut body = Vec::with_capacity(40); + body.extend_from_slice(&muxed_id.to_be_bytes()); + body.extend_from_slice(&key); + build_strkey(VERSION_M, &body) + } + + AddressKind::C => { + // 32 random bytes — the contract Wasm hash / account id. + let mut hash = [0u8; 32]; + rng.fill(&mut hash); + build_strkey(VERSION_C, &hash) + } + }; + + // ── round-trip check ───────────────────────────────────────────────────── + // 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}" + ); + }); + assert_eq!( + parsed.kind(), + kind, + "generator produced a {kind:?} address but parser returned {:?}", + parsed.kind() + ); + + address +} + +// ── unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use prism_core::address::AddressKind; + use rand::SeedableRng; + use rand::rngs::StdRng; + + fn seeded() -> StdRng { + StdRng::seed_from_u64(0xDEAD_BEEF_CAFE_1234) + } + + #[test] + 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!(addr.starts_with('G'), "G address must start with 'G'"); + } + + #[test] + 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!(addr.starts_with('M'), "M address must start with 'M'"); + } + + #[test] + 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!(addr.starts_with('C'), "C address must start with 'C'"); + } + + #[test] + fn m_address_round_trips_muxed_id() { + let mut rng = seeded(); + // Generate many M addresses to cover varied muxed-id values. + for _ in 0..256 { + let addr = random_valid_address(AddressKind::M, &mut rng); + let parsed = prism_core::address::parse(&addr).expect("M address must parse"); + assert!( + parsed.muxed_id().is_some(), + "parsed M address must carry a muxed_id" + ); + } + } + + #[test] + fn all_kinds_parse_successfully_batch() { + let mut rng = seeded(); + for _ in 0..500 { + for &kind in &[AddressKind::G, AddressKind::M, AddressKind::C] { + // The round-trip check inside random_valid_address will + // panic on any failure — no extra assertion needed here. + let _ = random_valid_address(kind, &mut rng); + } + } + } + + #[test] + fn g_address_uses_all_32_payload_bytes() { + // Different seeds must produce different addresses (not all zeros). + let mut rng1 = StdRng::seed_from_u64(1); + let mut rng2 = StdRng::seed_from_u64(2); + let a1 = random_valid_address(AddressKind::G, &mut rng1); + let a2 = random_valid_address(AddressKind::G, &mut rng2); + assert_ne!(a1, a2, "different seeds must produce different addresses"); + } + + #[test] + fn boundary_muxed_ids_round_trip() { + // Verify that the boundary u64 values (0, u64::MAX) encode and decode + // correctly, exercising the full range that the spec mandates. + for id in [0u64, 1, u64::MAX / 2, u64::MAX - 1, u64::MAX] { + 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); + let addr = build_strkey(VERSION_M, &body); + let parsed = prism_core::address::parse(&addr) + .unwrap_or_else(|e| panic!("boundary id {id} failed: {e}")); + assert_eq!( + parsed.muxed_id(), + Some(id), + "muxed_id round-trip failed for id={id}" + ); + } + } +} diff --git a/examples/rust-address-fuzzer/src/main.rs b/examples/rust-address-fuzzer/src/main.rs index 9e2d3c43..6c479f65 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -1,9 +1,11 @@ +mod generate; mod parse; use std::io::{self, BufRead}; use std::path::PathBuf; use clap::Parser as ClapParser; +use prism_core::address::AddressKind; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; @@ -74,8 +76,21 @@ fn main() { fn run_random(rng: &mut StdRng, n: usize, verbose: bool) -> Stats { let mut stats = Stats::default(); - for _ in 0..n { - fuzz_one(&random_string(rng), verbose, &mut stats); + for i in 0..n { + // Every 4th input is a valid seed address so the fuzzer exercises the + // boundary of validity rather than spending all its budget on obvious + // garbage. The other 3 out of 4 are random strings as before. + let input = if i % 4 == 0 { + let kind = match i % 12 { + 0 => AddressKind::G, + 4 => AddressKind::M, + _ => AddressKind::C, + }; + generate::random_valid_address(kind, rng) + } else { + random_string(rng) + }; + fuzz_one(&input, verbose, &mut stats); } stats } @@ -128,7 +143,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/parse.rs b/examples/rust-address-fuzzer/src/parse.rs index afb2e566..8d79941b 100644 --- a/examples/rust-address-fuzzer/src/parse.rs +++ b/examples/rust-address-fuzzer/src/parse.rs @@ -11,7 +11,8 @@ mod tests { #[test] fn parses_valid_g_address() { - let result = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); + // All-zero ed25519 key — a correctly checksummed 56-char G address. + let result = parse("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); assert!(result.is_ok()); assert_eq!(result.unwrap().kind(), prism_core::address::AddressKind::G); } From 658958d21eb6680e8cbf19b5c8d75b8b2da15dc9 Mon Sep 17 00:00:00 2001 From: DrSa7ag3 Date: Thu, 30 Jul 2026 06:00:37 +0000 Subject: [PATCH 5/7] test: add non-base32 and null-byte rejection vectors for address decoding --- spec/vectors.json | 124 ++++++++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 53 deletions(-) diff --git a/spec/vectors.json b/spec/vectors.json index 566a99b5..97103b43 100644 --- a/spec/vectors.json +++ b/spec/vectors.json @@ -73,10 +73,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "0" }, - "tags": [ - "positive", - "edge" - ] + "tags": ["positive", "edge"] }, { "module": "muxed_decode", @@ -89,10 +86,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "1" }, - "tags": [ - "positive", - "edge" - ] + "tags": ["positive", "edge"] }, { "module": "muxed_decode", @@ -105,11 +99,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "9007199254740992" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -122,11 +112,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "9007199254740993" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -139,11 +125,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "18446744073709551615" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -154,10 +136,7 @@ "expected": { "expected_error": "invalid encoded string" }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] }, { "module": "detect", @@ -180,10 +159,7 @@ } ] }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] }, { "module": "extract_routing", @@ -198,10 +174,7 @@ "routingSource": "muxed", "warnings": [] }, - "tags": [ - "positive", - "interop" - ] + "tags": ["positive", "interop"] }, { "module": "extract_routing", @@ -225,9 +198,7 @@ } ] }, - "tags": [ - "negative" - ] + "tags": ["negative"] }, { "module": "extract_routing", @@ -243,9 +214,7 @@ "routingSource": "memo", "warnings": [] }, - "tags": [ - "positive" - ] + "tags": ["positive"] }, { "module": "extract_routing", @@ -261,10 +230,7 @@ "routingSource": "memo", "warnings": [] }, - "tags": [ - "positive", - "edge" - ] + "tags": ["positive", "edge"] }, { "module": "extract_routing", @@ -280,9 +246,7 @@ "routingSource": "memo", "warnings": [] }, - "tags": [ - "positive" - ] + "tags": ["positive"] }, { "module": "extract_routing", @@ -308,10 +272,64 @@ } ] }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "non-base32 digit '0' injected into address must be rejected", + "input": { + "address": "GA0CUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "punctuation injected into address must be rejected", + "input": { + "address": "GA!CUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "embedded null byte must be rejected without crashing or truncating", + "input": { + "address": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRS\u0000" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] } ] -} +} \ No newline at end of file From f4cbe17eba4c5e3ea07ec2ed84af68d7d5fe4bad Mon Sep 17 00:00:00 2001 From: Emrys Date: Fri, 14 Aug 2026 11:46:04 +0100 Subject: [PATCH 6/7] fix: fixes --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 413c680c..25dbdfd4 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,6 @@ console.log(result.routingId); // "123" - **Warning System**: Discriminated unions (TS) or structured objects (Go/Dart) to catch edge cases like numeric `MEMO_TEXT`. - **Zero Dependencies**: Core logic is lightweight and has zero external dependencies beyond standard library features. -## Maintainers - -- **codeZeus** - [GitHub](https://github.com/codeZe-us) - ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. From 6c80a02c2502b9caae54188882a8a74c34de3333 Mon Sep 17 00:00:00 2001 From: Legit003 Date: Mon, 27 Jul 2026 16:13:51 +0000 Subject: [PATCH 7/7] feat(fuzzer): add valid-address generator for G, M, and C kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements random_valid_address(kind, rng) in src/generate.rs that produces correctly checksummed strkey for all three address types: - G: version(0x30) + 32 random bytes + CRC-16 LE → 56 chars - M: version(0x60) + random u64 muxed id (BE) + 32 random bytes + CRC-16 LE → 69 chars (exercises the full u64 decoder path) - C: version(0x10) + 32 random bytes + CRC-16 LE → 56 chars Every generated address is round-tripped through prism_core::address::parse immediately; a parse failure panics so a broken generator is caught at seed-generation time rather than producing silent bad corpus entries. run_random in main.rs now emits one valid seed per three random strings (every 4th input), cycling G → M → C, so the fuzzer explores the boundary of validity rather than spending all budget on obvious garbage. Also fixes two pre-existing broken test fixtures (53-char G addresses) in parse.rs and prism-core/src/address.rs — both now use the correct 56-char all-zero-key address GAAAAAA...AWHF. --- examples/rust-address-fuzzer/src/generate.rs | 235 +++++++++++++++++++ examples/rust-address-fuzzer/src/main.rs | 21 +- examples/rust-address-fuzzer/src/parse.rs | 1 + 3 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 examples/rust-address-fuzzer/src/generate.rs diff --git a/examples/rust-address-fuzzer/src/generate.rs b/examples/rust-address-fuzzer/src/generate.rs new file mode 100644 index 00000000..24fd9973 --- /dev/null +++ b/examples/rust-address-fuzzer/src/generate.rs @@ -0,0 +1,235 @@ +//! Valid-address generator for the fuzzer. +//! +//! `random_valid_address` produces correctly checksummed strkey strings for +//! every `AddressKind` (G, M, C). The returned string is guaranteed to +//! round-trip through `prism_core::address::parse`; a failure causes a panic +//! so that a broken seed source is caught immediately at generation time +//! rather than producing mysterious false-positive fuzzer results. +//! +//! Layout (all lengths are in bytes unless stated otherwise): +//! +//! | 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 | +//! | C | version(1) + contract_hash(32) | 33 | 35 | 56 | + +use prism_core::address::AddressKind; +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_M: u8 = 12 << 3; // 0x60 +const VERSION_C: u8 = 2 << 3; // 0x10 + +// ── CRC-16 (CCITT, same polynomial as prism-core) ──────────────────────────── + +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 +} + +// ── Base-32 encoder (Stellar / RFC 4648 alphabet, no padding) ──────────────── + +const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +fn base32_encode(data: &[u8]) -> String { + // Each 5 bits of input becomes one character. The output length is + // ceil(len * 8 / 5) characters. + let out_len = (data.len() * 8 + 4) / 5; + let mut result = String::with_capacity(out_len); + 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; + result.push(ALPHABET[((bits >> bit_count) & 0x1F) as usize] as char); + } + } + // flush any remaining bits (padded to the right with zeros) + if bit_count > 0 { + result.push(ALPHABET[((bits << (5 - bit_count)) & 0x1F) as usize] as char); + } + result +} + +// ── address builder ─────────────────────────────────────────────────────────── + +/// Build a complete strkey from a version byte and raw payload bytes (no +/// version byte included in `body`). Appends a 2-byte little-endian CRC-16. +fn build_strkey(version: u8, body: &[u8]) -> String { + // payload = version || body + let mut payload = Vec::with_capacity(1 + body.len()); + payload.push(version); + payload.extend_from_slice(body); + + // checksum over the full payload, appended little-endian + let crc = crc16(&payload); + payload.push((crc & 0xFF) as u8); + payload.push((crc >> 8) as u8); + + base32_encode(&payload) +} + +// ── public API ──────────────────────────────────────────────────────────────── + +/// Generate a random, correctly checksummed Stellar address of the requested +/// kind and verify it parses successfully. +/// +/// Panics if the generated address fails to parse — this would indicate a bug +/// in the generator (broken encoding or checksum logic) rather than in the +/// parser, so an immediate panic is the right signal. +pub fn random_valid_address(kind: AddressKind, rng: &mut impl Rng) -> String { + let address = match kind { + AddressKind::G => { + // 32 random bytes — any value is a valid (public) ed25519 key for + // our purposes; the parser only validates structure, not whether + // the key is a valid curve point. + let mut key = [0u8; 32]; + rng.fill(&mut key); + build_strkey(VERSION_G, &key) + } + + AddressKind::M => { + // Embed a random 64-bit muxed id to exercise the full u64 range + // in the decoder path. + let muxed_id: u64 = rng.gen(); + let mut key = [0u8; 32]; + rng.fill(&mut key); + + // M payload body = muxed_id_BE(8) || ed25519_key(32) + let mut body = Vec::with_capacity(40); + body.extend_from_slice(&muxed_id.to_be_bytes()); + body.extend_from_slice(&key); + build_strkey(VERSION_M, &body) + } + + AddressKind::C => { + // 32 random bytes — the contract Wasm hash / account id. + let mut hash = [0u8; 32]; + rng.fill(&mut hash); + build_strkey(VERSION_C, &hash) + } + }; + + // ── round-trip check ───────────────────────────────────────────────────── + // 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}" + ); + }); + assert_eq!( + parsed.kind(), + kind, + "generator produced a {kind:?} address but parser returned {:?}", + parsed.kind() + ); + + address +} + +// ── unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use prism_core::address::AddressKind; + use rand::SeedableRng; + use rand::rngs::StdRng; + + fn seeded() -> StdRng { + StdRng::seed_from_u64(0xDEAD_BEEF_CAFE_1234) + } + + #[test] + 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!(addr.starts_with('G'), "G address must start with 'G'"); + } + + #[test] + 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!(addr.starts_with('M'), "M address must start with 'M'"); + } + + #[test] + 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!(addr.starts_with('C'), "C address must start with 'C'"); + } + + #[test] + fn m_address_round_trips_muxed_id() { + let mut rng = seeded(); + // Generate many M addresses to cover varied muxed-id values. + for _ in 0..256 { + let addr = random_valid_address(AddressKind::M, &mut rng); + let parsed = prism_core::address::parse(&addr).expect("M address must parse"); + assert!( + parsed.muxed_id().is_some(), + "parsed M address must carry a muxed_id" + ); + } + } + + #[test] + fn all_kinds_parse_successfully_batch() { + let mut rng = seeded(); + for _ in 0..500 { + for &kind in &[AddressKind::G, AddressKind::M, AddressKind::C] { + // The round-trip check inside random_valid_address will + // panic on any failure — no extra assertion needed here. + let _ = random_valid_address(kind, &mut rng); + } + } + } + + #[test] + fn g_address_uses_all_32_payload_bytes() { + // Different seeds must produce different addresses (not all zeros). + let mut rng1 = StdRng::seed_from_u64(1); + let mut rng2 = StdRng::seed_from_u64(2); + let a1 = random_valid_address(AddressKind::G, &mut rng1); + let a2 = random_valid_address(AddressKind::G, &mut rng2); + assert_ne!(a1, a2, "different seeds must produce different addresses"); + } + + #[test] + fn boundary_muxed_ids_round_trip() { + // Verify that the boundary u64 values (0, u64::MAX) encode and decode + // correctly, exercising the full range that the spec mandates. + for id in [0u64, 1, u64::MAX / 2, u64::MAX - 1, u64::MAX] { + 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); + let addr = build_strkey(VERSION_M, &body); + let parsed = prism_core::address::parse(&addr) + .unwrap_or_else(|e| panic!("boundary id {id} failed: {e}")); + assert_eq!( + parsed.muxed_id(), + Some(id), + "muxed_id round-trip failed for id={id}" + ); + } + } +} diff --git a/examples/rust-address-fuzzer/src/main.rs b/examples/rust-address-fuzzer/src/main.rs index 8edfa5a4..9b6a4a8b 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -1,3 +1,4 @@ +mod generate; mod mutators; mod parse; mod report; @@ -6,6 +7,7 @@ use std::io::{self, BufRead}; use std::path::PathBuf; use clap::Parser as ClapParser; +use prism_core::address::AddressKind; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; @@ -87,8 +89,21 @@ fn main() { fn run_random(rng: &mut StdRng, n: usize, verbose: bool) -> Stats { let mut stats = Stats::default(); - for _ in 0..n { - fuzz_one(&random_string(rng), verbose, &mut stats); + for i in 0..n { + // Every 4th input is a valid seed address so the fuzzer exercises the + // boundary of validity rather than spending all its budget on obvious + // garbage. The other 3 out of 4 are random strings as before. + let input = if i % 4 == 0 { + let kind = match i % 12 { + 0 => AddressKind::G, + 4 => AddressKind::M, + _ => AddressKind::C, + }; + generate::random_valid_address(kind, rng) + } else { + random_string(rng) + }; + fuzz_one(&input, verbose, &mut stats); } stats } @@ -155,7 +170,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(-4..=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/parse.rs b/examples/rust-address-fuzzer/src/parse.rs index b65e6bfc..bfb8ea6d 100644 --- a/examples/rust-address-fuzzer/src/parse.rs +++ b/examples/rust-address-fuzzer/src/parse.rs @@ -11,6 +11,7 @@ mod tests { #[test] fn parses_valid_g_address() { + // Verified against the stellar-strkey reference decoder (0.0.18). let result = parse("GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"); assert!(result.is_ok(), "unexpected error: {result:?}"); assert_eq!(result.unwrap().kind(), prism_core::address::AddressKind::G);