LeVCS/crates/levcs-core/tests/fuzz.rs

268 lines
9.9 KiB
Rust

//! Robustness/fuzz tests for object deserializers (§8.2).
//!
//! The spec requires: "All deserializers MUST be safe against malformed
//! input and MUST NOT panic." These tests drive thousands of random and
//! adversarially-mutated byte slices through every public parser and
//! assert that the parser either returns a typed error or a valid value
//! — never a panic, never an infinite loop, never an out-of-bounds slice.
//!
//! Reproducibility: input generation is seeded by an LCG with a known
//! constant. If a future change introduces a panic, the printed seed
//! lets the fix verify against the same input.
use std::panic::{catch_unwind, AssertUnwindSafe};
use levcs_core::object::{
ObjectHeader, ObjectType, RawObject, SignatureEntry, SignedObject, FORMAT_VERSION, HEADER_SIZE,
SIGNATURE_ENTRY_SIZE,
};
use levcs_core::{Commit, Release, Tree};
/// Iterations per test target. Tuned so the suite runs in <1 second on
/// a developer laptop while still covering enough random states that
/// a missed bounds check tends to surface within a few hundred iterations.
/// Bump up locally when investigating a specific parser.
const ITERS: u32 = 5_000;
/// Linear-congruential PRNG. A function we can reset deterministically;
/// no need to take a dep on `rand` for this.
fn lcg(state: &mut u64) -> u64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*state
}
fn rand_bytes(seed: &mut u64, n: usize) -> Vec<u8> {
(0..n).map(|_| (lcg(seed) >> 33) as u8).collect()
}
fn rand_size(seed: &mut u64) -> usize {
// Mix of tiny / small / medium sizes — small slices catch bounds
// checks at the start, medium slices catch length-field mismatches.
match lcg(seed) % 5 {
0 => 0,
1 => (lcg(seed) % 16) as usize,
2 => (lcg(seed) % 256) as usize,
3 => 256 + (lcg(seed) % 4096) as usize,
_ => 4096 + (lcg(seed) % 16384) as usize,
}
}
/// Run `parser` on `input`. If it panics, fail the test naming the seed
/// so the failure can be reproduced; otherwise we don't care whether
/// the parser returned Ok or Err — only that it did not panic.
fn assert_no_panic<F, T>(label: &str, seed: u64, input: &[u8], parser: F)
where
F: FnOnce(&[u8]) -> T,
{
let r = catch_unwind(AssertUnwindSafe(|| parser(input)));
if r.is_err() {
panic!(
"{label} panicked on seed {seed:#x}, input len {} ({} bytes shown): {:02x?}",
input.len(),
input.len().min(64),
&input[..input.len().min(64)]
);
}
}
#[test]
fn signed_object_parse_does_not_panic() {
let mut seed = 0xdeadbeef_cafe1234u64;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
assert_no_panic("SignedObject::parse", seed, &bytes, |b| {
let _ = SignedObject::parse(b);
});
assert_no_panic("RawObject::parse", seed, &bytes, |b| {
let _ = RawObject::parse(b);
});
assert_no_panic("ObjectHeader::decode", seed, &bytes, |b| {
let _ = ObjectHeader::decode(b);
});
}
}
/// Mutate a known-valid signed object: flip random bits, truncate, splice
/// in adversarial bytes. The parser must remain panic-free under any of
/// these — they are the realistic shapes of corruption (bit rot, partial
/// writes, hostile peer feeding crafted bytes).
#[test]
fn signed_object_parse_survives_mutation_of_valid_input() {
// Build a valid SignedObject as the mutation seed.
let body = b"hello there, this is a body for fuzzing".to_vec();
let signed = SignedObject::new(ObjectType::Blob, body);
let valid = signed.serialize();
let mut seed = 0xfeedface_5151aaaau64;
for _ in 0..ITERS {
let mut buf = valid.clone();
// Pick a mutation strategy.
match lcg(&mut seed) % 5 {
0 => {
// Flip up to 8 random bits.
let flips = (lcg(&mut seed) % 8 + 1) as usize;
for _ in 0..flips {
if buf.is_empty() {
break;
}
let idx = (lcg(&mut seed) as usize) % buf.len();
let bit = (lcg(&mut seed) % 8) as u8;
buf[idx] ^= 1 << bit;
}
}
1 => {
// Truncate to a random shorter length.
let new_len = (lcg(&mut seed) as usize) % buf.len().max(1);
buf.truncate(new_len);
}
2 => {
// Tamper with the body_len field (bytes 8..16, LE u64).
let off = 8 + (lcg(&mut seed) as usize) % 8;
buf[off] = buf[off].wrapping_add((lcg(&mut seed) & 0xff) as u8);
}
3 => {
// Append random garbage.
let extra = (lcg(&mut seed) % 64) as usize;
buf.extend(rand_bytes(&mut seed, extra));
}
_ => {
// Replace random byte run.
if !buf.is_empty() {
let start = (lcg(&mut seed) as usize) % buf.len();
let len = ((lcg(&mut seed) as usize) % 16).min(buf.len() - start);
for b in &mut buf[start..start + len] {
*b = (lcg(&mut seed) & 0xff) as u8;
}
}
}
}
assert_no_panic("SignedObject::parse(mut)", seed, &buf, |b| {
let _ = SignedObject::parse(b);
});
assert_no_panic("RawObject::parse(mut)", seed, &buf, |b| {
let _ = RawObject::parse(b);
});
}
}
#[test]
fn body_parsers_do_not_panic_on_random_bytes() {
// Tree, Commit, and Release `parse_body` consume just the body — no
// header, no trailer. These are the parsers most exposed to crafted
// bytes from the federation push path.
let mut seed = 0x0123456789abcdefu64;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
assert_no_panic("Tree::parse_body", seed, &bytes, |b| {
let _ = Tree::parse_body(b);
});
assert_no_panic("Commit::parse_body", seed, &bytes, |b| {
let _ = Commit::parse_body(b);
});
assert_no_panic("Release::parse_body", seed, &bytes, |b| {
let _ = Release::parse_body(b);
});
}
}
#[test]
fn header_decode_handles_pathological_lengths() {
// The body_len field is u64 LE — a malicious peer could set it to
// values like u64::MAX. The parser must reject without attempting
// to allocate a buffer that size. This test verifies it returns an
// error and does not panic on the largest plausible adversarial
// values.
let mut seed = 0x9999_5555_aaaa_3333u64;
for _ in 0..512 {
let mut hdr = [0u8; HEADER_SIZE];
hdr[0..4].copy_from_slice(b"LVCS");
hdr[4] = (lcg(&mut seed) & 0xff) as u8;
hdr[5] = FORMAT_VERSION;
hdr[6] = 0;
hdr[7] = 0;
// body_len: extreme values (full u64), random values, all f's.
let body_len = match lcg(&mut seed) % 4 {
0 => u64::MAX,
1 => u64::MAX / 2,
2 => 1u64 << 40,
_ => lcg(&mut seed),
};
hdr[8..16].copy_from_slice(&body_len.to_le_bytes());
assert_no_panic("ObjectHeader::decode(extreme)", seed, &hdr, |b| {
let _ = ObjectHeader::decode(b);
});
assert_no_panic("SignedObject::parse(extreme)", seed, &hdr, |b| {
let _ = SignedObject::parse(b);
});
assert_no_panic("RawObject::parse(extreme)", seed, &hdr, |b| {
let _ = RawObject::parse(b);
});
}
}
#[test]
fn signature_trailer_count_byte_is_safe() {
// The trailer count byte is u8 (max 255 entries). A malicious sender
// could set it to a value that wildly overshoots the byte slice.
// SignedObject::parse must detect and refuse, not panic.
let mut seed = 0x77_77_77_77u64;
for count in [0u8, 1, 5, 255] {
let body = b"x".repeat(64);
let mut bytes = Vec::new();
let header = ObjectHeader {
object_type: ObjectType::Blob,
format_version: FORMAT_VERSION,
body_len: body.len() as u64,
}
.encode();
bytes.extend_from_slice(&header);
bytes.extend_from_slice(&body);
bytes.push(count);
// Intentionally include too few signature bytes for the claimed
// count — parser must return Err.
let want = count as usize * SIGNATURE_ENTRY_SIZE;
let provided = (lcg(&mut seed) as usize) % (want + 1);
bytes.extend(rand_bytes(&mut seed, provided));
assert_no_panic("SignedObject::parse(short trailer)", seed, &bytes, |b| {
let _ = SignedObject::parse(b);
});
}
}
#[test]
fn signature_entry_decode_does_not_panic() {
let mut seed = 0xabc_def_123_456u64;
for _ in 0..1024 {
let n = (lcg(&mut seed) % (SIGNATURE_ENTRY_SIZE as u64 + 32)) as usize;
let bytes = rand_bytes(&mut seed, n);
assert_no_panic("SignatureEntry::decode", seed, &bytes, |b| {
let _ = SignatureEntry::decode(b);
});
}
}
#[test]
fn signed_object_round_trip_when_parse_succeeds() {
// Sanity invariant: when parse succeeds on random input (rare but
// possible), serialize-then-parse must yield identical bytes. This
// protects against a parser that silently drops bytes.
let mut seed = 0xfeed_beef_dead_c0deu64;
let mut hits = 0u32;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
if let Ok(parsed) = SignedObject::parse(&bytes) {
let re = parsed.serialize();
assert_eq!(re, bytes, "round-trip mismatch on seed {seed:#x}");
hits += 1;
}
}
// Random bytes hit a valid signed object only by coincidence — but
// we don't require any hits, just that any incidental hits round-trip.
let _ = hits;
}