Add a seeded robustness harness for the untrusted-input parsers
Five parsers that take bytes straight off a disc are now swept with generated input asserting one property: they return Ok or Err and never panic. That is this crate's own hard rule, and the class seven rounds of reading is worst at. Written in-crate rather than with cargo-fuzz, which needs a nightly toolchain this project does not use, and without proptest or arbitrary, because one dev-dependency is a deliberate posture and the parsers take plain byte slices. What is given up is coverage-guided mutation, which is the real loss. What is gained is determinism: the same seed replays the same cases anywhere, so a CI failure reproduces locally verbatim. Three generators, and the second is the one that matters. Pure random bytes die at the magic check and exercise the entry guards only; prefixing valid magic is what reaches the parser body; mutating a mostly-zero record is what reaches the offset and count arithmetic a hostile image would lie about. That claim is MEASURED, not asserted. A harness whose cases all bounce off the entry guards is the fuzzing equivalent of a test that cannot fail, so one test counts how many generated cases parse to completion: 15,606 of 60,000, about 26%. If a future change to a guard drops that to zero, the test fails rather than continuing to report a meaningless pass. Two further tests pin that the three generators produce different bytes and that a seed replays identically. 1.2M cases across all five targets found nothing. On this evidence that is a real negative rather than an empty one. The first version of this file was itself broken in the way this audit keeps finding: its two meta-tests set FREEMKV_HARNESS_CASES and raced, because the test harness runs them in parallel and env mutation is unsound there. The budget is a parameter now, and the environment is read once at the call site. Two crate-internal parsers widened from private to pub(crate) so the harness can reach them. No public API change.
This commit is contained in:
@@ -14,3 +14,8 @@ scratch/
|
|||||||
# internal agent context — never publish (path AND dir; leak-guard blocks both)
|
# internal agent context — never publish (path AND dir; leak-guard blocks both)
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude/
|
.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/
|
||||||
|
|||||||
+267
@@ -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=<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<u8> {
|
||||||
|
(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<F: FnMut(&[u8])>(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<F: FnMut(&[u8])>(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<F: FnMut(&[u8])>(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<u8>> = 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");
|
||||||
|
}
|
||||||
@@ -112,6 +112,8 @@ pub mod dvdnav;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod halt;
|
pub mod halt;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod harness;
|
||||||
pub mod hex;
|
pub mod hex;
|
||||||
pub(crate) mod identity;
|
pub(crate) mod identity;
|
||||||
pub(crate) mod ifo;
|
pub(crate) mod ifo;
|
||||||
|
|||||||
@@ -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 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.
|
/// the frame count is bounded by 256 and no allocation here is attacker-scaled.
|
||||||
fn split_lacing(lacing: u8, body: &[u8]) -> Option<Vec<&[u8]>> {
|
pub(crate) fn split_lacing(lacing: u8, body: &[u8]) -> Option<Vec<&[u8]>> {
|
||||||
let (&count_minus_one, rest) = body.split_first()?;
|
let (&count_minus_one, rest) = body.split_first()?;
|
||||||
let n = count_minus_one as usize + 1;
|
let n = count_minus_one as usize + 1;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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:
|
/// UDF uses a compression ID as the first byte:
|
||||||
/// 8 = 8-bit characters (ASCII)
|
/// 8 = 8-bit characters (ASCII)
|
||||||
/// 16 = 16-bit big-endian Unicode (UTF-16BE)
|
/// 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() {
|
if data.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user