diff --git a/.gitignore b/.gitignore index fbd9c29..7b0b4a5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ scratch/ # internal agent context — never publish (path AND dir; leak-guard blocks both) CLAUDE.md .claude/ + +# Nightly harness output. Written into the repo it audits, and it embeds +# absolute paths from the machine that ran it — which must never reach a public +# repo. Ignored rather than relocated so a run from any working copy is safe. +.nightly/ diff --git a/src/harness.rs b/src/harness.rs new file mode 100644 index 0000000..9475ca1 --- /dev/null +++ b/src/harness.rs @@ -0,0 +1,267 @@ +//! Seeded robustness harness for the untrusted-input parsers. +//! +//! Every parser reached from here takes bytes that came off a disc, and this +//! crate's primary boundary is that the disc is untrusted: a malformed, damaged +//! or hostile image must never crash the library. These tests assert exactly +//! that one property — **the parser returns `Ok` or `Err`, and never panics.** +//! +//! # Why this exists rather than `cargo-fuzz` +//! +//! `cargo-fuzz` needs a nightly toolchain (`-Zsanitizer` plus SanitizerCoverage +//! for libFuzzer's coverage feedback) and this project pins stable. So the +//! generator lives here instead. It gives up coverage-guided mutation — the real +//! loss — and keeps everything else: millions of cases, structure-aware input, +//! and a crash corpus. It also gains determinism, which a fuzzer does not have: +//! the same seed replays the same cases on any machine. +//! +//! # Why no `proptest` or `arbitrary` +//! +//! This crate has exactly one dev-dependency. That is a deliberate posture, and +//! a randomness crate is not worth ten transitive dependencies when the parsers +//! take plain `&[u8]` and a good enough generator is forty lines. +//! +//! # Budget +//! +//! `FREEMKV_HARNESS_CASES` sets cases per generator per target (default 256, low +//! enough that the per-commit gate stays under a second). The overnight run sets +//! it to millions. `FREEMKV_HARNESS_SEED` overrides the seed; the default is +//! fixed so a failure in CI reproduces locally verbatim. +//! +//! # On failure +//! +//! The panic message carries the seed, generator and case index. Re-run with +//! `FREEMKV_HARNESS_SEED=` to reproduce, then write the offending bytes +//! into `tests/corpus/` as a permanent regression fixture — discovery happens +//! here, defence happens there. + +#![cfg(test)] + +/// Marsaglia xorshift64. Not cryptographic and does not need to be: the job is +/// a reproducible spread of bytes, and a named algorithm beats an ad-hoc LCG +/// whose period nobody has checked. +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + // A zero seed is a fixed point of xorshift — it would emit zeros forever + // and every generated case would be identical. + Self(if seed == 0 { + 0x2545_F491_4F6C_DD1D + } else { + seed + }) + } + + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn byte(&mut self) -> u8 { + (self.next() >> 24) as u8 + } + + /// Uniform-ish in `0..n`. The modulo bias is irrelevant at these magnitudes. + fn below(&mut self, n: usize) -> usize { + if n == 0 { + 0 + } else { + (self.next() % n as u64) as usize + } + } + + fn fill(&mut self, len: usize) -> Vec { + (0..len).map(|_| self.byte()).collect() + } +} + +/// Budget per generator per target. +fn cases() -> usize { + std::env::var("FREEMKV_HARNESS_CASES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(256) +} + +fn seed() -> u64 { + std::env::var("FREEMKV_HARNESS_SEED") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0x5EED_1234_ABCD_0001) +} + +/// Largest generated input. Big enough to carry a plausible header plus a body, +/// small enough that millions of cases stay quick. +const MAX_LEN: usize = 4096; + +/// Drive `f` over three generators and report which case broke it. +/// +/// A panic inside `f` fails the test on its own — nothing is caught here, +/// because catching would risk reporting a pass on an input that aborted. The +/// wrapper exists to make the failing case *identifiable*: the harness prints +/// the seed, generator and index before each call, so the last line before a +/// panic names the exact case to reproduce. +fn sweep(target: &str, magic: &[u8], f: F) { + sweep_n(target, magic, cases(), f) +} + +/// `sweep` with an explicit budget. The budget is a PARAMETER rather than read +/// from the environment inside the loop: the meta-tests below need a small, +/// fixed count, and `std::env::set_var` is unsound once the test harness runs +/// tests in parallel — two tests setting the same variable race, which is +/// exactly what happened on the first run of this file. +fn sweep_n(target: &str, magic: &[u8], n: usize, mut f: F) { + let s = seed(); + + // 1. Pure random bytes. Cheap, and almost always rejected at the magic + // number — it exercises the entry guards and little else. Kept because + // the entry guards are themselves worth exercising. + let mut rng = Rng::new(s); + for i in 0..n { + let len = rng.below(MAX_LEN); + let buf = rng.fill(len); + run(target, "random", s, i, &buf, &mut f); + } + + // 2. Valid magic, random body. THE generator that matters: pure random + // input dies at the magic check and never reaches the parser body, so + // without this the sweep only ever tests the first few lines. + let mut rng = Rng::new(s ^ 0xA5A5_A5A5_A5A5_A5A5); + for i in 0..n { + let mut buf = magic.to_vec(); + let tail = rng.below(MAX_LEN.saturating_sub(magic.len())); + buf.extend(rng.fill(tail)); + run(target, "magic+noise", s, i, &buf, &mut f); + } + + // 3. Structured mutation of a plausible record: a valid magic, then mostly + // zeroes, with a handful of bytes corrupted and a truncation. Length and + // offset fields live in those early bytes, so this is what reaches the + // arithmetic — the offsets, counts and sizes a hostile image would lie + // about. + let mut rng = Rng::new(s ^ 0x1234_5678_9ABC_DEF0); + for i in 0..n { + let mut buf = vec![0u8; 512]; + buf[..magic.len().min(512)].copy_from_slice(&magic[..magic.len().min(512)]); + for _ in 0..rng.below(24) + 1 { + let at = rng.below(buf.len()); + buf[at] = rng.byte(); + } + buf.truncate(rng.below(buf.len()) + 1); + run(target, "mutate", s, i, &buf, &mut f); + } +} + +fn run(target: &str, generator: &str, seed: u64, i: usize, buf: &[u8], f: &mut F) { + // Printed, not asserted: `cargo test` swallows stdout for passing tests and + // shows it for failing ones, so this line is invisible until it is the last + // thing before a panic — at which point it is exactly what is needed. + println!( + "harness {target}/{generator} seed={seed:#x} case={i} len={} :: \ + FREEMKV_HARNESS_SEED={seed} to reproduce", + buf.len() + ); + f(buf); +} + +#[test] +fn mpls_parse_never_panics() { + sweep("mpls", b"MPLS", |b| { + let _ = crate::mpls::parse(b); + }); +} + +#[test] +fn clpi_parse_never_panics() { + sweep("clpi", b"HDMV", |b| { + let _ = crate::clpi::parse(b); + }); +} + +#[test] +fn udf_name_parse_never_panics() { + // No magic: the compression ID is the first byte and every value is legal + // input to reject, so the "magic" is a byte the sweep will mutate anyway. + sweep("udf_name", &[8], |b| { + let _ = crate::udf::parse_udf_name(b); + }); +} + +#[test] +fn ps_demuxer_feed_never_panics() { + // Stateful, unlike the others: the demuxer carries a buffer across feeds, so + // each case is fed to a FRESH demuxer and then a shared one. The shared pass + // is what exercises cross-feed state — a start code split over a boundary, + // a held PES completed by later bytes, the carry-over cap. + let mut shared = crate::mux::ps::PsDemuxer::new(); + sweep("ps_demux", &[0x00, 0x00, 0x01, 0xBA], |b| { + let mut fresh = crate::mux::ps::PsDemuxer::new(); + let _ = fresh.feed(b); + let _ = shared.feed(b); + }); +} + +#[test] +fn mkv_lacing_split_never_panics() { + // All four lacing modes, including the reserved bit pattern. A degenerate + // fixed lace was a real defect found by audit round 5. + sweep("mkv_lacing", &[0x00], |b| { + for lacing in 0u8..=3 { + let _ = crate::mux::mkvstream::split_lacing(lacing, b); + } + }); +} + +/// The generators must actually differ, or the sweep is one generator run three +/// times and the coverage claim is false. +#[test] +fn the_three_generators_produce_different_inputs() { + let mut seen: Vec> = Vec::new(); + sweep_n("probe", b"MPLS", 1, |b| seen.push(b.to_vec())); + assert_eq!(seen.len(), 3, "one case per generator"); + assert_ne!(seen[0], seen[1], "random and magic+noise must differ"); + assert_ne!(seen[1], seen[2], "magic+noise and mutate must differ"); + assert!( + seen[1].starts_with(b"MPLS"), + "the magic+noise generator must actually carry the magic, or it never \ + reaches the parser body" + ); +} + +/// The same seed must replay the same bytes, or a reported failure cannot be +/// reproduced and the harness is worthless as a regression tool. +#[test] +fn a_seed_replays_identically() { + let mut a = Vec::new(); + let mut b = Vec::new(); + sweep_n("probe", b"MPLS", 4, |x| a.push(x.to_vec())); + sweep_n("probe", b"MPLS", 4, |x| b.push(x.to_vec())); + assert_eq!(a, b, "the same seed must produce the same cases"); +} + +/// The harness is worthless if its cases die at the entry guards, so this +/// MEASURES how deep they actually reach instead of assuming. A generator that +/// never gets past a length or magic check exercises the first ten lines and +/// nothing else — the fuzzing equivalent of a test that cannot fail. +#[test] +fn the_generators_actually_reach_the_parser_bodies() { + // mpls::parse rejects at: len < 40, bad magic, then playlist_start + 10 > + // len. Anything that returns Ok got all the way through the play-item loop. + let mut ok = 0usize; + let mut total = 0usize; + sweep_n("reach", b"MPLS", 20000, |b| { + total += 1; + if crate::mpls::parse(b).is_ok() { + ok += 1; + } + }); + assert!( + ok > 0, + "not one of {total} generated cases parsed successfully — the generators \ + are all being rejected at the entry guards, so this harness is testing \ + the guards and nothing behind them" + ); + println!("mpls reach: {ok}/{total} cases parsed to completion"); +} diff --git a/src/lib.rs b/src/lib.rs index e181f02..fcc1148 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,6 +112,8 @@ pub mod dvdnav; pub mod error; pub mod event; pub mod halt; +#[cfg(test)] +mod harness; pub mod hex; pub(crate) mod identity; pub(crate) mod ifo; diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index d08db83..5819886 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -1401,7 +1401,7 @@ fn lace_svint(d: &[u8]) -> Option<(i64, usize)> { /// /// The Lacing Head is "number of frames in the lace minus 1" on one octet, so /// the frame count is bounded by 256 and no allocation here is attacker-scaled. -fn split_lacing(lacing: u8, body: &[u8]) -> Option> { +pub(crate) fn split_lacing(lacing: u8, body: &[u8]) -> Option> { let (&count_minus_one, rest) = body.split_first()?; let n = count_minus_one as usize + 1; diff --git a/src/udf.rs b/src/udf.rs index 7d3f842..34d3c0c 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1193,7 +1193,7 @@ fn read_file_size(reader: &mut dyn SectorSource, meta_start: u32, meta_lba: u32) /// UDF uses a compression ID as the first byte: /// 8 = 8-bit characters (ASCII) /// 16 = 16-bit big-endian Unicode (UTF-16BE) -fn parse_udf_name(data: &[u8]) -> String { +pub(crate) fn parse_udf_name(data: &[u8]) -> String { if data.is_empty() { return String::new(); }