//! 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 { (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(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; }