diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index e8b83f2d..e2129813 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) + working-directory: examples/prism-core run: cargo test -p prism-core - name: Test rust-address-fuzzer + working-directory: examples/rust-address-fuzzer run: cargo test -p rust-address-fuzzer - name: Build prism-diff binary + working-directory: examples/prism-core run: cargo build -p prism-core --features diff --bin prism-diff - name: Run prism-diff smoke test (1k random inputs) + working-directory: examples/prism-core run: cargo run -p prism-core --features diff --bin prism-diff -- --random 1000 --seed 42 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index b16d706c..0b4c50a6 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -24,5 +24,5 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: fuzzer-reproducer - path: examples/rust-address-fuzzer/reproducer.txt + name: fuzzer-findings + path: examples/rust-address-fuzzer/findings/ diff --git a/examples/rust-address-fuzzer/src/generate.rs b/examples/rust-address-fuzzer/src/generate.rs index 24fd9973..cfab747d 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) + // M payload 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'"); } @@ -218,10 +231,10 @@ mod tests { // 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 + // fixed key for reproducibility; M payload is key followed by ID. 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..21def4c2 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -3,18 +3,25 @@ mod mutators; mod parse; mod report; +use std::any::Any; use std::io::{self, BufRead}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::Parser as ClapParser; use prism_core::address::AddressKind; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; +const FINDINGS_DIR: &str = "findings"; + #[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 + /// Generate N valid seeds, mutate each one, and parse each mutation #[arg(long, value_name = "N", conflicts_with_all = ["corpus", "stdin_mode"])] random: Option, @@ -57,126 +64,168 @@ fn main() { } let seed = cli.seed.unwrap_or_else(|| rand::thread_rng().gen()); - let mut rng: StdRng = StdRng::seed_from_u64(seed); + let mut rng = StdRng::seed_from_u64(seed); + let findings_dir = Path::new(FINDINGS_DIR); if cli.verbose { eprintln!("PRNG seed: {seed}"); } 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) + let max = cli.max_iterations.unwrap_or(n).min(n); + run_random(&mut rng, max, seed, cli.verbose, findings_dir) } else if let Some(path) = cli.corpus { - run_corpus(&path, cli.verbose, cli.max_iterations) + run_corpus(&path, cli.verbose, cli.max_iterations, seed, findings_dir) } else { - run_stdin(cli.verbose, cli.max_iterations) + run_stdin(cli.verbose, cli.max_iterations, seed, findings_dir) }; 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 + stats.total, + stats.ok, + stats.err, + stats.report.findings.len() ); - stats.report.print_json(); - if stats.report.findings_count > 0 || stats.report.divergences > 0 { + if !stats.report.findings.is_empty() || stats.report.divergences > 0 { std::process::exit(1); } } -fn run_random(rng: &mut StdRng, n: usize, verbose: bool) -> Stats { +fn run_random(rng: &mut StdRng, n: usize, seed: u64, verbose: bool, findings_dir: &Path) -> Stats { let mut stats = Stats::default(); - 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) + for iteration in 0..n { + let kind = match iteration % 3 { + 0 => AddressKind::G, + 1 => AddressKind::M, + _ => AddressKind::C, }; - fuzz_one(&input, verbose, &mut stats); + let base = generate::random_valid_address(kind, rng); + let (mutator, input) = mutate(&base, rng); + fuzz_one( + &input, + mutator, + iteration, + seed, + verbose, + &mut stats, + findings_dir, + ); } 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()); +fn mutate<'a>(base: &str, rng: &mut impl Rng) -> (&'a str, String) { + match rng.gen_range(0..3) { + 0 => ("truncate", mutators::length::truncate(base, rng)), + 1 => ("pad", mutators::length::pad(base, rng)), + _ => match mutators::version::swap_version_byte(base, rng) { + Some(result) => ("swap_version_byte", result.mutated), + None => ("identity", base.to_owned()), + }, + } +} + +fn run_corpus( + path: &Path, + verbose: bool, + max_iters: Option, + seed: u64, + findings_dir: &Path, +) -> Stats { + let file = std::fs::File::open(path).unwrap_or_else(|error| { + eprintln!("error: cannot open corpus file {}: {error}", path.display()); std::process::exit(2); }); 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; } + for (iteration, line) in io::BufReader::new(file).lines().enumerate() { + if max_iters.is_some_and(|max| iteration >= max) { + break; } - fuzz_one(&line.unwrap_or_default(), verbose, &mut stats); + fuzz_one( + &line.unwrap_or_default(), + "corpus", + iteration, + seed, + verbose, + &mut stats, + findings_dir, + ); } stats } -fn run_stdin(verbose: bool, max_iters: Option) -> Stats { +fn run_stdin(verbose: bool, max_iters: Option, seed: u64, findings_dir: &Path) -> Stats { let mut stats = Stats::default(); - for (i, line) in io::stdin().lock().lines().enumerate() { - if let Some(m) = max_iters { - if i >= m { break; } + for (iteration, line) in io::stdin().lock().lines().enumerate() { + if max_iters.is_some_and(|max| iteration >= max) { + break; } - fuzz_one(&line.unwrap_or_default(), verbose, &mut stats); + fuzz_one( + &line.unwrap_or_default(), + "stdin", + iteration, + seed, + verbose, + &mut stats, + findings_dir, + ); } stats } -fn fuzz_one(input: &str, verbose: bool, stats: &mut Stats) { +fn fuzz_one( + input: &str, + mutator: &str, + iteration: usize, + seed: u64, + verbose: bool, + stats: &mut Stats, + findings_dir: &Path, +) { stats.total += 1; - let input_owned = input.to_owned(); - let res = std::panic::catch_unwind(|| parse::parse(&input_owned)); - - match res { - Ok(Ok(addr)) => { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parse::parse(input))); + + match result { + Ok(Ok(address)) => { stats.ok += 1; if verbose { - eprintln!("OK {:?} ← {input:?}", addr.kind()); + eprintln!("OK {:?} ← {input:?}", address.kind()); } } - Ok(Err(e)) => { + Ok(Err(error)) => { stats.err += 1; if verbose { - eprintln!("ERR {e:?} ← {input:?}"); + eprintln!("ERR {error:?} ← {input:?}"); } } - Err(_) => { + Err(payload) => { stats.panics += 1; - eprintln!("PANIC ← {input:?}"); - let _ = std::fs::write("reproducer.txt", input); + let panic_message = panic_message(payload.as_ref()); + eprintln!("PANIC [{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, + ); } } } -const STRKEY_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - -fn random_string(rng: &mut StdRng) -> String { - if rng.gen_bool(0.80) { - 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(-4isize..=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}") +fn panic_message(payload: &(dyn Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::() { + message.clone() + } else if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_owned() } else { - let len = rng.gen_range(0..=128); - (0..len).map(|_| rng.gen_range(0x20u8..=0x7e) as char).collect() + "unknown panic payload".to_owned() } } diff --git a/examples/rust-address-fuzzer/src/report.rs b/examples/rust-address-fuzzer/src/report.rs index af57059c..a02a391d 100644 --- a/examples/rust-address-fuzzer/src/report.rs +++ b/examples/rust-address-fuzzer/src/report.rs @@ -1,27 +1,74 @@ +use std::fs; +use std::path::Path; + +pub struct Finding { + pub input: String, + pub mutator: String, + pub panic_message: String, + pub seed: u64, + pub iteration: usize, +} + #[derive(Default)] pub struct Report { pub inputs_run: usize, - pub findings_count: usize, + pub findings: Vec, pub divergences: usize, } impl Report { - pub fn new() -> Self { - Self { - inputs_run: 0, - findings_count: 0, - divergences: 0, + pub fn record_finding(&mut self, finding: Finding, directory: &Path) { + if let Err(error) = fs::create_dir_all(directory) { + eprintln!( + "error: cannot create findings directory {}: {error}", + directory.display() + ); + } else { + let path = directory.join(format!("finding-{:06}.json", self.findings.len() + 1)); + if let Err(error) = fs::write(&path, finding_json(&finding)) { + eprintln!("error: cannot write finding {}: {error}", path.display()); + } } + self.findings.push(finding); } pub fn print_json(&self) { println!( - r#"{{ - "inputs_run": {}, - "findings_count": {}, - "divergences": {} -}}"#, - self.inputs_run, self.findings_count, self.divergences + "{{\n \"inputs_run\": {},\n \"findings_count\": {},\n \"divergences\": {}\n}}", + self.inputs_run, + self.findings.len(), + self.divergences ); } } + +fn finding_json(finding: &Finding) -> String { + format!( + "{{\n \"input\": {},\n \"mutator\": {},\n \"panic_message\": {},\n \"seed\": {},\n \"iteration\": {}\n}}\n", + json_string(&finding.input), + json_string(&finding.mutator), + json_string(&finding.panic_message), + finding.seed, + finding.iteration + ) +} + +fn json_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + for character in value.chars() { + match character { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() => { + escaped.push_str(&format!("\\u{:04x}", character as u32)); + } + character => escaped.push(character), + } + } + escaped.push('"'); + escaped +}