Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci-rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
41 changes: 27 additions & 14 deletions examples/rust-address-fuzzer/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@
//! | 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;
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) ────────────────────────────

Expand Down Expand Up @@ -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)
}

Expand All @@ -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(),
Expand All @@ -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)
Expand All @@ -156,23 +154,38 @@ 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'");
}

#[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_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_eq!(
addr.len(),
56,
"C address must be 56 chars, got {}",
addr.len()
);
assert!(addr.starts_with('C'), "C address must start with 'C'");
}

Expand Down Expand Up @@ -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}"));
Expand Down
187 changes: 118 additions & 69 deletions examples/rust-address-fuzzer/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,

Expand Down Expand Up @@ -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<usize>) -> 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<usize>,
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<usize>) -> Stats {
fn run_stdin(verbose: bool, max_iters: Option<usize>, 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::<String>() {
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()
}
}
Loading
Loading