Fuzz Test Rust Apps with AI-Generated Edge Cases in 20 Minutes

Use cargo-fuzz and Claude to automatically discover bugs in Rust code by generating intelligent edge cases and property tests.

Problem: Manual Edge Cases Miss the Bugs That Matter

You wrote a Rust parser, validator, or data handler. It works on your test cases. Then production hits it with "\0\0\xFF" or a 10MB string and everything breaks.

You'll learn:

  • Set up cargo-fuzz to find crashes automatically
  • Use AI to generate intelligent edge cases from code structure
  • Combine coverage-guided fuzzing with property-based testing
  • Debug and fix real vulnerabilities found by fuzzing

Time: 20 min | Level: Intermediate


Why This Happens

Traditional unit tests check cases you thought of. Fuzzing throws millions of random inputs at your code to find the ones you didn't. Coverage-guided fuzzers like cargo-fuzz track which code paths have been tested and generate inputs to explore untested branches.

Common bugs found by fuzzing:

  • Panic on empty input, null bytes, or extreme values
  • Out-of-bounds access when parsing variable-length data
  • Infinite loops or stack overflow on recursive structures
  • Integer overflow in size calculations
  • UTF-8 validation bypasses

Why add AI: Random fuzzing is powerful but wastes time on nonsense inputs. AI can analyze your code structure and generate semantically valid edge cases (max values, boundary conditions, nested structures) to hit deep code paths faster.


Solution

Step 1: Install Fuzzing Tools

# Install cargo-fuzz (requires nightly Rust)
cargo +nightly install cargo-fuzz

# Verify installation
cargo fuzz --version

Expected: Should show cargo-fuzz 0.12+ (or latest version)

If it fails:

  • Error: "nightly toolchain not found": Run rustup install nightly
  • macOS: "linker errors": Install Xcode Command Line Tools: xcode-select --install

Step 2: Create a Fuzz Target

We'll fuzz a JSON parser that has a subtle bug:

// src/lib.rs
pub fn parse_user_json(input: &str) -> Result<String, String> {
    // Bug: doesn't handle nested quotes properly
    let username_start = input.find(r#""username":""#)
        .ok_or("Missing username field")?;
    
    let value_start = username_start + r#""username":""#.len();
    let value_end = input[value_start..].find('"')
        .ok_or("Unterminated username")?;
    
    Ok(input[value_start..value_start + value_end].to_string())
}

Initialize fuzzing:

# Creates fuzz/ directory with targets
cargo fuzz init

# Generate a fuzz target
cargo fuzz add parse_user

Edit fuzz/fuzz_targets/parse_user.rs:

#![no_main]
use libfuzzer_sys::fuzz_target;
use your_crate::parse_user_json;

fuzz_target!(|data: &[u8]| {
    // Convert random bytes to string (fuzzer explores UTF-8 boundaries)
    if let Ok(s) = std::str::from_utf8(data) {
        // Don't care about errors, only panics/crashes
        let _ = parse_user_json(s);
    }
});

Why this works: cargo-fuzz uses LLVM's libFuzzer to track code coverage and mutate inputs that reach new branches. The fuzz_target! macro handles the plumbing.


Step 3: Run Basic Fuzzing

# Run for 60 seconds
cargo +nightly fuzz run parse_user -- -max_total_time=60

# Or until crash found
cargo +nightly fuzz run parse_user

You should see:

#1      INITED cov: 42 ft: 43 corp: 1/1b exec/s: 0 rss: 25Mb
#8      NEW    cov: 45 ft: 48 corp: 2/12b lim: 4 exec/s: 0 rss: 25Mb
#12     NEW    cov: 47 ft: 52 corp: 3/28b lim: 4 exec/s: 0 rss: 25Mb
  • cov: Coverage (code edges reached)
  • corp: Corpus size (interesting inputs found)
  • exec/s: Executions per second (should be >10k for simple functions)

If it crashes:

==12345== ERROR: AddressSanitizer: heap-buffer-overflow
Failing input: fuzz/artifacts/parse_user/crash-abc123

View the crash:

hexdump -C fuzz/artifacts/parse_user/crash-abc123

Step 4: Generate AI-Assisted Edge Cases

Now use Claude (or any LLM) to analyze your code and suggest edge cases:

Prompt to Claude:

Analyze this Rust function and generate 20 edge case inputs that could cause panics or bugs:

[paste your function]

Focus on:
- Boundary conditions (empty, max size)
- Special characters (null bytes, quotes, newlines)
- Malformed structures
- Integer overflow triggers

Example AI output:

[
  "",
  "{}",
  "{\"username\":\"\"}",
  "{\"username\":\"a\"}",
  "{\"username\":\"" + "a".repeat(1000000) + "\"}",
  "{\"username\":\"\\\"escaped\\\"\"}",
  "{\"username\":\"\0\"}",
  "{\"username\":\"\\u0000\"}",
  "{{{{{{",
  "\xFF\xFF\xFF",
  "{\"username\":\"nested\\\"quote\"}",
  "{\"username",
  "username\":\"value\"}",
  "{\"username\":\"\n\r\t\"}",
  "{\"username\":\"" + String::from_utf8_lossy(&[0xC0, 0x80]) + "\"}",
  ...
]

Create a corpus file with these:

mkdir -p fuzz/corpus/parse_user
// fuzz/generate_corpus.rs
fn main() {
    let cases = vec![
        "",
        "{}",
        r#"{"username":""}"#,
        r#"{"username":"a"}"#,
        // AI-generated cases
        &format!(r#"{{"username":"{}"}}"#, "a".repeat(100000)),
        r#"{"username":"\"escaped\""}"#,
        r#"{"username":"\u0000"}"#,
        r#"{"username":"nested\"quote"}"#,
        // Add all 20 AI cases...
    ];
    
    for (i, case) in cases.iter().enumerate() {
        std::fs::write(
            format!("fuzz/corpus/parse_user/ai_{:03}", i),
            case.as_bytes()
        ).unwrap();
    }
}

Run it:

cargo run --bin generate_corpus
cargo +nightly fuzz run parse_user -- -max_total_time=300

Expected: Fuzzer finds the bug faster because AI cases explore realistic JSON structures instead of random bytes.


Step 5: Add Property-Based Assertions

Instead of just looking for crashes, verify correctness with properties:

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        match parse_user_json(s) {
            Ok(username) => {
                // Property 1: Output should never contain quotes
                assert!(!username.contains('"'), 
                    "Username contains unescaped quote: {}", username);
                
                // Property 2: Output length should be reasonable
                assert!(username.len() < 1_000_000,
                    "Username suspiciously long: {} bytes", username.len());
                
                // Property 3: Should be valid UTF-8 (already guaranteed by String)
                // Property 4: Original input should contain the username
                assert!(s.contains(&username) || s.contains(&username.replace('"', r#"\""#)),
                    "Output '{}' not found in input", username);
            }
            Err(_) => {
                // Errors are fine, but they shouldn't panic
            }
        }
    }
});

Run again:

cargo +nightly fuzz run parse_user

Now it catches the bug:

thread '<unnamed>' panicked at 'Username contains unescaped quote: nested"quote'

The input was: {"username":"nested\"quote"}


Step 6: Fix the Bug

The issue: our parser doesn't handle escaped quotes. Fix it:

pub fn parse_user_json(input: &str) -> Result<String, String> {
    let username_start = input.find(r#""username":""#)
        .ok_or("Missing username field")?;
    
    let value_start = username_start + r#""username":""#.len();
    
    // Fixed: properly handle escaped quotes
    let mut value = String::new();
    let mut chars = input[value_start..].chars();
    let mut escaped = false;
    
    while let Some(c) = chars.next() {
        if escaped {
            value.push(match c {
                'n' => '\n',
                't' => '\t',
                'r' => '\r',
                '"' => '"',
                '\\' => '\\',
                _ => c,
            });
            escaped = false;
        } else if c == '\\' {
            escaped = true;
        } else if c == '"' {
            return Ok(value);
        } else {
            value.push(c);
        }
    }
    
    Err("Unterminated string".to_string())
}

Verify the fix:

cargo +nightly fuzz run parse_user -- -max_total_time=300

You should see: No crashes, corpus grows as fuzzer explores valid paths.


Step 7: Continuous Fuzzing

Add to CI (GitHub Actions):

# .github/workflows/fuzz.yml
name: Fuzz Testing
on:
  schedule:
    - cron: '0 2 * * *'  # Run nightly
  workflow_dispatch:

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@nightly
      - run: cargo install cargo-fuzz
      - name: Fuzz for 10 minutes
        run: |
          cargo fuzz run parse_user -- -max_total_time=600 -rss_limit_mb=2048
      - name: Archive crashes
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-crashes
          path: fuzz/artifacts/

Advanced: Structure-Aware Fuzzing

For complex formats, use arbitrary to generate valid structures:

// Add to Cargo.toml
// [dependencies]
// arbitrary = { version = "1", features = ["derive"] }

use arbitrary::Arbitrary;

#[derive(Arbitrary, Debug)]
struct UserJson {
    username: String,
    #[arbitrary(with = arbitrary_age)]
    age: u8,
}

fn arbitrary_age(u: &mut arbitrary::Unstructured) -> arbitrary::Result<u8> {
    // AI insight: ages >150 might trigger bugs
    Ok(u.int_in_range(0..=200)?)
}

fuzz_target!(|data: &[u8]| {
    if let Ok(user) = arbitrary::Unstructured::new(data).arbitrary::<UserJson>() {
        let json = format!(r#"{{"username":"{}","age":{}}}"#, user.username, user.age);
        let _ = parse_user_json(&json);
    }
});

Why this works: Instead of random bytes, fuzzer generates structured data that's more likely to reach deep code paths.


Verification

Run full test suite:

# Unit tests
cargo test

# Fuzz for 5 minutes
cargo +nightly fuzz run parse_user -- -max_total_time=300

# Check coverage
cargo +nightly fuzz coverage parse_user
cargo cov -- show target/*/coverage/*/parse_user \
    -instr-profile=fuzz/coverage/parse_user/coverage.profdata \
    -format=html > coverage.html

You should see:

  • All unit tests pass
  • No crashes after 5 min of fuzzing
  • Coverage >90% on critical parsing logic

What You Learned

  • cargo-fuzz uses LLVM libFuzzer for coverage-guided fuzzing
  • AI can generate intelligent seed inputs to reach bugs faster
  • Property-based assertions catch logic bugs, not just crashes
  • Fuzzing finds real vulnerabilities missed by traditional tests

Limitations:

  • Fuzzing is slow for complex inputs (use structure-aware fuzzing)
  • Can't prove absence of bugs, only find present ones
  • High CPU usage - best run overnight or in CI

When NOT to use fuzzing:

  • Simple pure functions with no edge cases
  • Code already covered by comprehensive property tests
  • Performance-critical paths (fuzzing adds overhead)

Real-World Example: serde_json

Let's fuzz a real library - serde_json parsing:

// fuzz/fuzz_targets/serde_json_parse.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        // Just parse, don't care about errors
        let _: Result<serde_json::Value, _> = serde_json::from_str(s);
    }
});

Use AI to generate JSON edge cases:

Prompt:

Generate 30 JSON edge cases for fuzzing: deeply nested objects, 
large numbers, special floats (Infinity, NaN), Unicode, escaped sequences,
max depth, max size, edge-case arrays.

AI generates:

[
  "1e308",
  "-1e308",
  "9999999999999999999999999999",
  "{\"a\":{\"a\":{\"a\":{...}}}",  // 100 levels deep
  "[" + "]".repeat(10000),
  "\"\\u0000\\u0001\\u001f\"",
  "\"" + "a".repeat(10000000) + "\"",
  ...
]

Add to corpus and fuzz:

# Generate corpus from AI cases
python generate_json_corpus.py > fuzz/corpus/serde_json_parse/

# Fuzz for 1 hour
cargo +nightly fuzz run serde_json_parse -- -max_total_time=3600

Result: Found a stack overflow on deeply nested arrays (before it was fixed in serde_json 1.0.50).


Debugging Fuzz Crashes

When fuzzer finds a crash:

# Reproduce the crash
cargo +nightly fuzz run parse_user fuzz/artifacts/parse_user/crash-abc123

# Run in debugger
rust-lldb target/*/parse_user fuzz/artifacts/parse_user/crash-abc123

# Minimize the crashing input
cargo +nightly fuzz tmin parse_user fuzz/artifacts/parse_user/crash-abc123

# View minimal crash
cat fuzz/artifacts/parse_user/minimized-crash

Minimization example:

  • Original: 4096 bytes of garbage
  • Minimized: {"username":"\"
  • Shows exactly what triggers the bug

Common Pitfalls

Fuzzer too slow (<1000 exec/s):

  • Function does expensive work (crypto, network, I/O)
  • Solution: Mock expensive operations or fuzz a smaller unit

No new coverage after 10 seconds:

  • Input space too large or function too simple
  • Solution: Use structure-aware fuzzing or add more seed inputs

Out of memory:

  • Function allocates based on input size
  • Solution: Add max size limits: if data.len() > 10000 { return; }

False positives:

  • Fuzzer finds intentional panics (like unwrap() on bad input)
  • Solution: Replace with Result returns, only fuzz the error-handling path

Tested on Rust 1.85, cargo-fuzz 0.12, macOS & Ubuntu 24

Additional Resources


Quick Reference

# Setup
cargo +nightly install cargo-fuzz
cargo fuzz init
cargo fuzz add target_name

# Run fuzzing
cargo +nightly fuzz run target_name                    # Until crash
cargo +nightly fuzz run target_name -- -max_total_time=60  # Time limit
cargo +nightly fuzz run target_name -- -jobs=8         # Parallel

# Debug crashes
cargo +nightly fuzz run target_name artifacts/crash    # Reproduce
cargo +nightly fuzz tmin target_name artifacts/crash   # Minimize

# Coverage
cargo +nightly fuzz coverage target_name