Files
craft-libs/crates/video/tests/mpeg2/fuzz_hostile.rs
Dylan M. Taylor 5e892217ed craft-video: MPEG-2 and MPEG-1 video decoder from FilmCraft (mpeg2) (#864)
* craft-video: MPEG-2 and MPEG-1 video decoder from FilmCraft (`mpeg2`)

FilmCraft's crates/mpeg2v at 4bec22bd moves to craft_video::mpeg2 behind the
decoder-only feature `mpeg2`: sources, README and tests (unit, synthetic
streams checked by ffmpeg, the ffmpeg oracle fixtures, hostile sweeps).

Changes in the move: craft-media-testkit instead of filmcraft-testkit, one
test target (tests/mpeg2), crate paths, rustfmt at this repo's width.

New: a memory budget (Decoder::set_memory_budget, craft_video::budget) checked
when a sequence becomes active, and a mutation test over the synthetic
field-picture, 16x8 and dual-prime streams, which ffmpeg's encoder cannot
produce and the hostile sweep therefore never reached.

* craft-video mpeg2: write the restart check without a negated conjunction (clippy nonminimal_bool)
2026-11-11 17:42:02 -05:00

200 lines
7.4 KiB
Rust

//! Hostile-input robustness: truncation at every length, byte/bit mutation and header-field
//! sweeps over a small ffmpeg-generated stream must never panic or run away (errors are fine).
//! Skips when ffmpeg is missing.
use crate::common;
use std::panic::{AssertUnwindSafe, catch_unwind};
use craft_video::mpeg2::{Decoder, access_units, probe, scan_access_unit};
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
const SPECS: [common::Spec; 3] = [
common::Spec {
name: "m2v_fuzz_seed",
lavfi: "testsrc2=size=64x48:rate=25",
frames: 8,
enc: &["-c:v", "mpeg2video", "-bf", "2", "-g", "6", "-b:v", "500k"],
pix_fmt: "yuv420p",
},
common::Spec {
name: "m2v_fuzz_seed_interlaced",
lavfi: "testsrc2=size=96x64:rate=25,tinterlace=mode=interleave_top,setfield=tff",
frames: 6,
enc: &["-c:v", "mpeg2video", "-flags", "+ilme+ildct", "-bf", "1", "-g", "6", "-b:v", "800k"],
pix_fmt: "yuv420p",
},
common::Spec {
name: "m2v_fuzz_seed_422",
lavfi: "testsrc2=size=64x48:rate=25",
frames: 4,
enc: &["-c:v", "mpeg2video", "-pix_fmt", "yuv422p", "-profile:v", "0", "-b:v", "1M"],
pix_fmt: "yuv422p",
},
];
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
/// Decoder worker threads catch panics and turn them into errors; count them so they still fail the test.
fn count_panics() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::panic::set_hook(Box::new(|info| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(d: &[u8], label: &str) {
craft_media_testkit::set_progress(label);
count_panics();
let before = PANICS.load(std::sync::atomic::Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
let _ = probe(d);
let _ = scan_access_unit(d);
for threads in [false, true] {
let mut dec = Decoder::new();
dec.set_threads(threads);
for (i, r) in access_units(d).into_iter().enumerate() {
let _ = dec.decode(&d[r], i as i64);
}
// also the whole buffer as one unit
let _ = dec.decode(d, 99);
let _ = dec.flush();
}
}));
assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: CRAFT_TEST_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
/// CI runs a rotating share of every part of the sweep; `FILMCRAFT_FUZZ_ITERS=2500` runs all of it (see [`Depth`]).
#[test]
fn hostile_streams_never_panic() {
craft_media_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = craft_media_testkit::ffmpeg_or_skip("mpeg2v fuzz") else { return };
let depth = Depth::from_env();
for spec in &SPECS {
if !craft_media_testkit::ffmpeg_has_encoder(&ff, "mpeg2video") {
craft_media_testkit::skip_test("mpeg2v fuzz", &format!("this ffmpeg has no mpeg2video encoder (seed {})", spec.name));
continue;
}
let Some(path) = common::make(&ff, spec) else {
panic!("ffmpeg has mpeg2video but could not encode the {} seed", spec.name);
};
fuzz(&depth, spec.name, &std::fs::read(path).unwrap());
}
}
fn fuzz(depth: &Depth, name: &str, seed: &[u8]) {
// every truncation length (stride keeps it quick on larger seeds)
for n in depth.truncations(seed.len()) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// header region: every byte to 0 / 0xff / 0x80 / each single bit
for p in 0..seed.len().min(96) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
let mut d = seed.to_vec();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.to_vec();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
// random multi-mutations
let mut rng = Rng(0x9e3779b97f4a7c15);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.to_vec();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
}
}
const VALUES: [u8; 4] = [0, 0xff, 0x80, 0x01];
/// How much of the sweep runs. `FILMCRAFT_FUZZ_ITERS=N` runs all of it (about 600 truncation
/// points spread over each seed, every header byte, overwrite value and bit flip) plus N random
/// rounds per seed: 2500 is the full depth, more hunts longer. Unset, CI runs a third of the
/// truncation points and of the full depth's random rounds and, for each header byte, one value
/// and two bits. A rotation offset shifts which share runs (`FILMCRAFT_FUZZ_SEED`, else the UTC
/// day number), so every case of the full sweep runs within a few days, and a random round's
/// mutations match the same round of a full run.
struct Depth {
full: bool,
truncations: usize,
rounds: usize,
rotation: usize,
}
impl Depth {
fn from_env() -> Self {
match std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()) {
Some(rounds) => Depth { full: true, truncations: 600, rounds, rotation: 0 },
None => {
let rotation = rotation();
eprintln!("fuzz subset rotation {rotation} (FILMCRAFT_FUZZ_SEED={rotation} replays it; FILMCRAFT_FUZZ_ITERS=2500 runs everything)");
Depth { full: false, truncations: 600, rounds: 2500, rotation }
}
}
}
/// Truncation lengths for a `len`-byte input: every point of the full sweep, or every third one.
fn truncations(&self, len: usize) -> std::iter::StepBy<std::ops::Range<usize>> {
let stride = (len / self.truncations).max(1);
let (start, step) = if self.full { (0, stride) } else { ((self.rotation % 3) * stride, 3 * stride) };
(start..len).step_by(step)
}
fn value(&self, p: usize, i: usize) -> bool {
self.full || i == (p + self.rotation % VALUES.len()) % VALUES.len()
}
fn bit(&self, p: usize, b: usize) -> bool {
self.full || (b + p + self.rotation % 4).is_multiple_of(4)
}
fn round(&self, i: usize) -> bool {
self.full || i % 3 == self.rotation % 3
}
}
/// The default subset's rotation offset: `FILMCRAFT_FUZZ_SEED`, else days since the Unix epoch (UTC).
fn rotation() -> usize {
std::env::var("FILMCRAFT_FUZZ_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or_else(|| {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
usize::try_from(secs / 86_400).unwrap_or(0)
})
}