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
28 changes: 28 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Fuzz

on:
push:
branches: ["main"]
pull_request:
Comment on lines +3 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Run the fuzzer on every push.

The branch filter limits push-triggered fuzzing to main; direct pushes to other branches are skipped, contrary to the PR objective.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/fuzz.yml around lines 3 - 6, Update the workflow’s on.push
trigger to remove the branches filter, so fuzzing runs on every push while
preserving the existing pull_request trigger.


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
54 changes: 40 additions & 14 deletions examples/rust-address-fuzzer/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod parse;
mod report;

use std::io::{self, BufRead};
use std::path::PathBuf;
Expand Down Expand Up @@ -26,6 +27,10 @@ struct Cli {
#[arg(long, value_name = "U64")]
seed: Option<u64>,

/// Stop fuzzing after this many iterations
#[arg(long, value_name = "N")]
max_iterations: Option<usize>,

/// Print every result, not just failures
#[arg(long, short)]
verbose: bool,
Expand All @@ -37,6 +42,7 @@ struct Stats {
ok: usize,
err: usize,
panics: usize,
report: report::Report,
}

fn main() {
Expand All @@ -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);
}
}
Expand All @@ -80,41 +92,55 @@ 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<usize>) -> 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not fuzz an empty string on input read failure. unwrap_or_default() hides corpus/stdin I/O or UTF-8 errors, inflates inputs_run, and can let CI pass after processing corrupted input.

  • examples/rust-address-fuzzer/src/main.rs#L105-L105: report the line-read error and terminate with an input-error status.
  • examples/rust-address-fuzzer/src/main.rs#L116-L116: apply the same error handling for stdin.
📍 Affects 1 file
  • examples/rust-address-fuzzer/src/main.rs#L105-L105 (this comment)
  • examples/rust-address-fuzzer/src/main.rs#L116-L116
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/rust-address-fuzzer/src/main.rs` at line 105, Replace
unwrap_or_default() in the corpus line-reading path at
examples/rust-address-fuzzer/src/main.rs:105-105 with explicit error handling
that reports the read or UTF-8 error and terminates with an input-error status
instead of calling fuzz_one on an empty string. Apply the same handling to the
stdin path at examples/rust-address-fuzzer/src/main.rs:116-116.

}
stats
}

fn run_stdin(verbose: bool) -> Stats {
fn run_stdin(verbose: bool, max_iters: Option<usize>) -> 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
}

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);
Comment on lines +139 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve each distinct panic reproducer. Writing every finding to reproducer.txt overwrites prior failures, and CI uploads only that final file.

  • examples/rust-address-fuzzer/src/main.rs#L139-L142: write each panic to a unique file, such as reproducers/panic-<count>.txt, and surface write failures.
  • .github/workflows/fuzz.yml#L23-L28: upload examples/rust-address-fuzzer/reproducers/** so all captured inputs are retained.
📍 Affects 2 files
  • examples/rust-address-fuzzer/src/main.rs#L139-L142 (this comment)
  • .github/workflows/fuzz.yml#L23-L28
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/rust-address-fuzzer/src/main.rs` around lines 139 - 142, Update the
panic handling in examples/rust-address-fuzzer/src/main.rs:139-142 around the
Err(_) branch to write each reproducer to a distinct file under reproducers,
using the panic count for uniqueness, and surface any write failure instead of
discarding it. Update .github/workflows/fuzz.yml:23-28 to upload
examples/rust-address-fuzzer/reproducers/** so all captured panic inputs are
retained.

}
}
}

Expand Down
26 changes: 26 additions & 0 deletions examples/rust-address-fuzzer/src/report.rs
Original file line number Diff line number Diff line change
@@ -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,
}
}
Comment on lines +1 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline examples/rust-address-fuzzer/src/main.rs --items all --type struct,impl
rg -n -A10 -B3 'struct Stats|impl Default for Stats|derive\(.*Default' examples/rust-address-fuzzer/src/main.rs

Repository: Boxkit-Labs/stellar-address-kit

Length of output: 452


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== report.rs =="
cat -n examples/rust-address-fuzzer/src/report.rs

echo
echo "== main.rs stats section =="
sed -n '1,90p' examples/rust-address-fuzzer/src/main.rs

Repository: Boxkit-Labs/stellar-address-kit

Length of output: 3517


Implement Default for Report. Stats derives Default, so Stats::default() requires report::Report: Default; add that impl here, or replace Stats with a manual Default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/rust-address-fuzzer/src/report.rs` around lines 1 - 14, Implement
the Default trait for Report so Stats::default() can construct its report field,
preserving the zero-value initialization currently provided by Report::new.
Prefer deriving Default on Report and retain Report::new as appropriate for
existing callers.


pub fn print_json(&self) {
println!(
r#"{{
"inputs_run": {},
"findings_count": {},
"divergences": {}
}}"#,
self.inputs_run, self.findings_count, self.divergences
);
}
}
Loading