Integration Testing

Fuzz Testing - External Tooling

Coverage-guided fuzz testing (or fuzzing) feeds millions of mutated byte sequences into a target function while monitoring compiler-inserted code-coverage instrumentation. Whenever a mutated input reaches a previously unexplored branch (if, match, or loop edge), the fuzzer saves that input to its corpus and mutates it further to uncover panics, integer overflows, out-of-memory allocations, infinite loops, and unsafe memory errors.

Coverage-Guided Fuzzing Loop (`cargo-fuzz` / LLVM libFuzzer):

  +---> [ Seed Corpus ]
  |            |
  |            v
  |     [ Mutate Bytes ] (bit flips, magic ints, dictionary tokens)
  |            |
  |            v
  |     [ fuzz_target!(|data: &[u8]| { ... }) ]
  |            |
  |     +------+-------------------------------+
  |     |                                      |
  |  Panics / OOM / Sanitizer Fault?     New Branch Coverage Reached?
  |     |                                      |
  |     v                                      +-- YES --> Add to Corpus --+
  |  Save crash artifact to                                                |
  |  `fuzz/artifacts/<target>/crash-*` <-----------------------------------+

Why Fuzzing Matters Even in Safe Rust

Although safe Rust eliminates buffer overflows and use-after-free bugs, untrusted network payloads or malformed files can still trigger severe denial-of-service vulnerabilities in safe code:

  1. Unchecked slice indexing (&data[0..len]) panicking when len > data.len().
  2. UTF-8 char boundary panics (&text[0..4]) when slicing multi-byte UTF-8 characters.
  3. Arithmetic overflow panics (or logic bugs from wrapping in release mode).
  4. Allocation bombs (OOM): Reading an untrusted 4-byte length header (0x7FFF_FFFF) and immediately calling Vec::with_capacity(len) before verifying the remaining input size.
  5. Stack overflow or quadratic CPU hangs on deeply nested brackets ([[[[...).

Setting Up cargo-fuzz (libfuzzer-sys)

The standard Rust fuzzing workflow uses cargo-fuzz, which wraps LLVM's libFuzzer engine and AddressSanitizer (ASAN):

# 1. Install cargo-fuzz
cargo install cargo-fuzz

# 2. Initialize the `fuzz/` directory in your crate
cargo fuzz init

# 3. Add a named fuzz target
cargo fuzz add packet_parser

# 4. Run the fuzzer (requires nightly toolchain for sanitizer instrumentation)
cargo +nightly fuzz run packet_parser

A fuzz target in fuzz/fuzz_targets/packet_parser.rs receives a raw &[u8] slice (or a structured type via the arbitrary crate's #[derive(Arbitrary)]) and must never panic on any input:

// Inside `fuzz/fuzz_targets/packet_parser.rs`:
// #![no_main]
// use libfuzzer_sys::fuzz_target;
//
// fuzz_target!(|data: &[u8]| {
//     let _ = my_crate::parse_packet(data);
// });

Hardening a Parser Against Fuzz-Discovered Bugs

Consider a binary packet parser. A naive parser might index slices directly or allocate based on an untrusted header; a fuzz-hardened parser uses .get(...), checked_add, and a strict MAX_PAYLOAD cap, accompanied by a unit test that replays historical crash inputs:

#[derive(Debug, PartialEq, Eq)]
pub struct Packet<'a> {
    pub version: u8,
    pub payload: &'a [u8],
}

#[derive(Debug, PartialEq, Eq)]
pub enum PacketError {
    TruncatedHeader,
    UnsupportedVersion(u8),
    PayloadTooLarge(usize),
    TruncatedPayload { expected: usize, available: usize },
}

const MAX_PAYLOAD_LEN: usize = 4096;

pub fn parse_packet(data: &[u8]) -> Result<Packet<'_>, PacketError> {
    // Never index `data[0..3]` directly; use `.get()` so empty slices cannot panic
    let header = data.get(0..3).ok_or(PacketError::TruncatedHeader)?;
    let version = header[0];
    if version != 1 {
        return Err(PacketError::UnsupportedVersion(version));
    }

    let len = u16::from_be_bytes([header[1], header[2]]) as usize;
    if len > MAX_PAYLOAD_LEN {
        return Err(PacketError::PayloadTooLarge(len));
    }

    let end = 3usize.checked_add(len).ok_or(PacketError::PayloadTooLarge(len))?;
    let payload = data.get(3..end).ok_or(PacketError::TruncatedPayload {
        expected: len,
        available: data.len() - 3,
    })?;

    Ok(Packet { version, payload })
}

fn main() {
    // Replay edge-case inputs that commonly crash unhardened parsers
    let corpus: &[&[u8]] = &[
        b"",                                // Empty input
        b"\x01\xFF\xFF",                    // 65,535-byte length claim with 0 payload bytes
        b"\x01\x00\x05hello",               // Valid 5-byte packet
        b"\x01\x00\x05hel",                 // Truncated payload
        b"\x99\x00\x00",                    // Invalid version
    ];

    for sample in corpus {
        // Must never panic on any byte sequence
        let _ = parse_packet(sample);
    }

    assert_eq!(
        parse_packet(b"\x01\x00\x05hello"),
        Ok(Packet {
            version: 1,
            payload: b"hello",
        })
    );
    println!("Fuzz-hardened parser handled all {} corpus inputs without panicking", corpus.len());
}
Fuzz-hardened parser handled all 5 corpus inputs without panicking

Whenever cargo-fuzz discovers a crash artifact in fuzz/artifacts/<target>/crash-<hash>, convert those bytes into a permanent regression unit test (#[test] fn regression_crash_<hash>()) so standard cargo test runs verify the fix on every commit.