Merge pull request #1795 from g1obalworlds/fix/hostile-input-video-codecs

Hostile-input hardening for video decoders (AV1 panic + oversize allocation, MPEG-2 allocation) and fuzz tests
This commit is contained in:
skylar taylor-barrick
2026-10-21 17:09:37 -06:00
committed by GitHub
22 changed files with 1604 additions and 3 deletions
Generated
+2
View File
@@ -2364,6 +2364,7 @@ dependencies = [
name = "filmcraft-vp8"
version = "0.10.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
]
@@ -2372,6 +2373,7 @@ name = "filmcraft-vp9"
version = "0.10.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
+108
View File
@@ -0,0 +1,108 @@
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
//! truncation at (nearly) every length must never panic or run away (errors are fine).
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_apv::*;
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
}
}
fn seeds() -> Vec<Vec<u8>> {
let mut v = Vec::new();
for (p, w, h) in [(Profile::P422_10, 64u32, 48u32), (Profile::P444_10, 48, 32), (Profile::P4444_10, 32, 32), (Profile::P400_10, 32, 32)] {
let chroma = p.default_chroma();
let depth = p.default_bit_depth();
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
for (i, s) in f.y.iter_mut().enumerate() {
*s = (64 + (i * 37) % 800) as u16;
}
let mut cfg = EncoderConfig::new(p, w, h);
cfg.qp = Some(16);
let Ok(mut enc) = Encoder::with_config(cfg) else { continue };
if let Ok(d) = enc.encode(&f) {
v.push(d);
}
if let Ok(d) = enc.encode_raw_au(&f) {
v.push(d);
}
}
v
}
fn run(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
let res = catch_unwind(AssertUnwindSafe(|| {
for depth in [None, Some(8), Some(16)] {
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() });
}
let _ = probe(d);
let _ = split_raw_bitstream(d);
}));
assert!(res.is_ok(), "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_frames_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let seeds = seeds();
assert!(!seeds.is_empty());
for (si, seed) in seeds.iter().enumerate() {
let stride = (seed.len() / 400).max(1);
for n in (0..seed.len()).step_by(stride) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
for p in 0..seed.len().min(256) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p}.{b}"));
}
}
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
for p in 0..seed.len().min(128) {
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(p..p + 4) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
for i in 0..iters {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run(&d, &format!("seed {si} random {i}"));
}
}
}
+7
View File
@@ -125,6 +125,13 @@ motion vectors nor CDFs reach another frame — skip deblocking, CDEF and loop r
(super-resolution still runs, it sets the output size) and are flagged `Picture::draft`. In
SVT-AV1's hierarchical GOPs that is the top layer, half of the frames.
## Limits
Frames beyond the AV1 level 6.x limits (Annex A: at most 16384 wide, 8704 high and 35 651 584 luma samples
in total, so 16384 x 2176 or 8192 x 4352 at most) are refused with `Error::Invalid("frame size")`. The sequence
header can code up to 65536 x 65536, which would ask for gigabytes of buffers from a few bytes of hostile input;
no real stream comes close to level 6.3, the highest defined level.
## API
```rust
+35 -1
View File
@@ -23,6 +23,17 @@ use crate::stats::{DecodeStats, Stage, Timer};
use crate::tile::TileDecoder;
use crate::{Error, Picture, Result};
/// Largest accepted frame: the AV1 level 6.x limits (Annex A: MaxHSize 16384, MaxVSize 8704, MaxPicSize
/// 35 651 584 luma samples; level 6.3 is the highest defined). The sequence header allows 65536 x 65536
/// (gigabytes of buffers), so nothing larger than a level 6.3 frame is decoded.
const MAX_FRAME_WIDTH: u32 = 16384;
const MAX_FRAME_HEIGHT: u32 = 8704;
const MAX_FRAME_AREA: u64 = 35_651_584;
/// Whether a frame of `width` x `height` luma samples is within the decoder's resource limits.
fn frame_size_allowed(width: u32, height: u32) -> bool {
width <= MAX_FRAME_WIDTH && height <= MAX_FRAME_HEIGHT && u64::from(width) * u64::from(height) <= MAX_FRAME_AREA
}
const OBU_SEQUENCE_HEADER_T: u32 = 1;
const OBU_TEMPORAL_DELIMITER_T: u32 = 2;
const OBU_FRAME_HEADER_T: u32 = 3;
@@ -505,7 +516,7 @@ impl Decoder {
}
// Encoders exist that code frames slightly larger than the sequence maximum (e.g. a
// 270-line stream coded as 272 lines); like other decoders we accept them.
if fh.upscaled_width > 65536 || fh.frame_height > 65536 {
if !frame_size_allowed(fh.upscaled_width, fh.frame_height) {
return Err(Error::Invalid("frame size"));
}
if !fh.frame_is_intra {
@@ -947,3 +958,26 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool {
}
true
}
#[cfg(test)]
mod tests {
use super::frame_size_allowed;
#[test]
fn frames_up_to_the_level_6_3_limits_are_allowed() {
assert!(frame_size_allowed(16384, 2176)); // exactly MaxPicSize
assert!(frame_size_allowed(4096, 8704)); // exactly MaxVSize
assert!(frame_size_allowed(8192, 4352)); // exactly MaxPicSize
assert!(frame_size_allowed(7680, 4320)); // 8K UHD
assert!(frame_size_allowed(1, 1));
}
#[test]
fn frames_beyond_the_limits_are_refused() {
assert!(!frame_size_allowed(16384, 2177)); // one row over MaxPicSize
assert!(!frame_size_allowed(16384, 8704)); // within both sides, four times MaxPicSize
assert!(!frame_size_allowed(4096, 8705)); // one row over MaxVSize
assert!(!frame_size_allowed(16385, 1)); // one column over MaxHSize
assert!(!frame_size_allowed(65536, 65536));
}
}
+4
View File
@@ -394,6 +394,10 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
fn decode_block(&mut self, r: usize, c: usize, sub_size: usize) -> Result<()> {
let (ssx, ssy) = (self.fs.ssx, self.fs.ssy);
let num_planes = self.fs.num_planes;
// Conformance requirement (4:2:2 has no chroma size for 4x16 and similar blocks); indexing the size tables with BLOCK_INVALID would panic.
if num_planes > 1 && self.fs.plane_residual_size(sub_size, 1) == BLOCK_INVALID {
return Err(crate::Error::Invalid("block size has no chroma size at this subsampling"));
}
let mut b = Block { mi_row: r, mi_col: c, mi_size: sub_size, ..Default::default() };
b.bw4 = NUM_4X4_BLOCKS_WIDE[sub_size] as usize;
b.bh4 = NUM_4X4_BLOCKS_HIGH[sub_size] as usize;
+156
View File
@@ -0,0 +1,156 @@
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
//! ffmpeg-generated AV1 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use filmcraft_av1::Decoder;
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
}
}
/// (name, encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[("av1_fz_default", "-c:v libsvtav1 -g 4", "yuv420p"), ("av1_fz_10bit", "-c:v libsvtav1 -g 6", "yuv420p10le")];
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let Some(h) = data.get(6..8) else { return out };
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
while let Some(hd) = data.get(pos..pos + 12) {
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
pos += 12;
let end = pos.saturating_add(size).min(data.len());
out.push(data[pos..end].to_vec());
pos = end;
}
out
}
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
let out = filmcraft_testkit::fixtures_dir("av1").join(format!("{name}.ivf"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
.args(a)
.args(["-f", "ivf"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
Some(ivf_frames(&std::fs::read(p).ok()?))
}
static PANICS: AtomicUsize = AtomicUsize::new(0);
/// Decoder worker threads may 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, Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(frames: &[Vec<u8>], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for (i, f) in frames.iter().enumerate() {
let _ = dec.decode_pts(f, i as i64);
}
let _ = dec.flush_result();
}
}));
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("AV1 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libsvtav1") {
filmcraft_testkit::skip("AV1 fuzz", &format!("this ffmpeg has no libsvtav1 encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libsvtav1 but could not encode the {name} seed");
};
if seed.is_empty() {
filmcraft_testkit::skip("AV1 fuzz", &format!("the {name} seed has no frames"));
continue;
}
// truncate each of the first frames at every length
for fi in 0..seed.len().min(3) {
let len = seed[fi].len();
for n in (0..len).step_by((len / 200).max(1)) {
let mut d = seed.clone();
d[fi].truncate(n);
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
}
}
// header region of every frame: every byte to a few values, every bit
for fi in 0..seed.len() {
for p in 0..seed[fi].len().min(96) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[fi][p] = v;
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[fi][p] ^= 1 << b;
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
}
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let fi = rng.below(d.len());
if d[fi].is_empty() {
continue;
}
let p = rng.below(d[fi].len());
if rng.below(2) == 0 {
d[fi][p] ^= 1 << rng.below(8);
} else {
d[fi][p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
+161
View File
@@ -0,0 +1,161 @@
//! A few bytes must not make the decoder allocate gigabytes: oversized sequences are refused.
//! Hand-built (no ffmpeg): a reduced still-picture sequence header plus a lossless key frame whose
//! tiles are one zero byte each.
use filmcraft_av1::{Decoder, Error};
#[derive(Default)]
struct Bits {
bytes: Vec<u8>,
n: usize,
}
impl Bits {
fn put(&mut self, value: u32, bits: usize) {
for i in (0..bits).rev() {
if self.n.is_multiple_of(8) {
self.bytes.push(0);
}
if (value >> i) & 1 == 1
&& let Some(b) = self.bytes.last_mut()
{
*b |= 0x80 >> (self.n % 8);
}
self.n += 1;
}
}
}
fn obu(kind: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![kind << 3 | 0b10];
let mut n = payload.len();
loop {
let b = (n & 0x7f) as u8;
n >>= 7;
v.push(if n > 0 { b | 0x80 } else { b });
if n == 0 {
break;
}
}
v.extend_from_slice(payload);
v
}
fn tile_log2(blk: u32, target: u32) -> u32 {
let mut k = 0;
while (blk << k) < target {
k += 1;
}
k
}
/// Temporal delimiter, reduced still-picture sequence header of `w` x `h` (64x64 superblocks) and a
/// lossless key frame whose uniformly spaced tiles are one zero byte each.
fn stream(w: u32, h: u32) -> Vec<u8> {
let mut s = Bits::default();
s.put(0, 3); // seq_profile
s.put(1, 1); // still_picture
s.put(1, 1); // reduced_still_picture_header
s.put(0, 5); // seq_level_idx
s.put(15, 4); // frame_width_bits_minus_1
s.put(15, 4); // frame_height_bits_minus_1
s.put(w - 1, 16);
s.put(h - 1, 16);
s.put(0, 3 + 3 + 7 + 1); // sb size, filter intra, edge; superres, cdef, restoration; color config; film grain
s.put(1, 1); // trailing bit
let (sb_cols, sb_rows) = (w.div_ceil(64), h.div_ceil(64));
let cols_log2 = tile_log2(64, sb_cols); // MAX_TILE_WIDTH / 64 superblocks
let min_log2_tiles = cols_log2.max(tile_log2(2304, sb_rows * sb_cols));
let rows_log2 = min_log2_tiles.saturating_sub(cols_log2);
let tiles = (sb_cols.div_ceil(sb_cols.div_ceil(1 << cols_log2))) * (sb_rows.div_ceil(sb_rows.div_ceil(1 << rows_log2)));
let mut f = Bits::default();
f.put(0b0001, 4); // disable_cdf_update, allow_screen_content_tools, render_and_frame_size_different, uniform_tile_spacing_flag
f.put(0, 2); // no increment of the tile column / row counts
if tiles > 1 {
f.put(0, (cols_log2 + rows_log2) as usize + 2); // context_update_tile_id, tile_size_bytes_minus_1
}
f.put(0, 8 + 4 + 1 + 1); // base_q_idx 0 (lossless), delta flags, using_qmatrix, segmentation_enabled, reduced_tx_set
let mut frame = f.bytes;
frame.push(0); // tile_start_and_end_present_flag
for _ in 1..tiles {
frame.extend([0, 0]); // tile_size_minus_1 = 0, one data byte
}
frame.extend([0, 0]);
let mut out = obu(2, &[]);
out.extend(obu(1, &s.bytes));
out.extend(obu(6, &frame));
out
}
fn refused(w: u32, h: u32) {
let mut dec = Decoder::new();
let r = dec.decode(&stream(w, h)).and_then(|_| dec.flush_result());
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("frame size")), "{w}x{h} must be refused as too large, got {:?}", r.as_ref().map(|p| p.len()));
}
#[test]
fn very_wide_frame_is_refused() {
refused(65536, 16);
}
#[test]
fn very_tall_frame_is_refused() {
refused(16, 65536);
}
#[test]
fn huge_frames_are_refused_without_allocating() {
refused(65536, 65536);
refused(65535, 65535);
refused(30000, 30000);
}
/// Frames past the level 6.x limits (width 16384, height 8704, area 35 651 584) are refused. The accepted side of
/// each boundary is unit-tested on `frame_size_allowed` in `decoder.rs`: decoding a maximum-size frame end to end
/// needs gigabytes.
#[test]
fn frames_past_the_level_6_limits_are_refused() {
refused(16384, 2177);
refused(16384, 8704);
refused(4096, 8705);
}
/// A 64x48 4:2:0 key frame whose sequence profile bit was flipped to 2 (4:2:2): blocks such as 4x16 then have no
/// chroma size (BLOCK_INVALID) and used to index the size tables out of bounds.
const PROFILE_FLIPPED: &[u8] = &[
0x12, 0x00, 0x0a, 0x0b, 0x42, 0x00, 0x00, 0x05, 0x55, 0x7f, 0xbc, 0x6a, 0xf9, 0x00, 0x40, 0x32, 0x81, 0x05, 0x14, 0x00, 0xa5, 0xa0, 0x40, 0xd9, 0x52, 0xb9,
0xe7, 0x3c, 0xa6, 0xc1, 0xe4, 0xe9, 0xff, 0x6d, 0x12, 0xc3, 0xce, 0xac, 0x00, 0x92, 0x6f, 0x29, 0x6a, 0xee, 0x7e, 0x1a, 0x31, 0xfe, 0xe6, 0xb8, 0x2e, 0xe2,
0xcf, 0x3d, 0xce, 0x24, 0xe6, 0x30, 0x03, 0xcd, 0x89, 0x49, 0x1f, 0xa7, 0x8a, 0x75, 0xb3, 0x93, 0x27, 0x7b, 0x2f, 0x39, 0xc7, 0x60, 0xb7, 0xf8, 0x69, 0xbc,
0x53, 0x71, 0x30, 0x64, 0x20, 0x7e, 0xc1, 0x10, 0x17, 0xe4, 0xca, 0x34, 0xe9, 0xae, 0x47, 0x39, 0x68, 0x68, 0xef, 0x33, 0x9f, 0x26, 0xe2, 0x3f, 0xf1, 0xef,
0x02, 0xb7, 0xfd, 0xb1, 0x78, 0x4c, 0x35, 0x31, 0x17, 0x27, 0x11, 0x2f, 0xd7, 0x25, 0x01, 0x30, 0xa5, 0x7a, 0xaf, 0x9b, 0x5b, 0x3b, 0x8d, 0x9e, 0xd5, 0x23,
0x06, 0x22, 0x3a, 0xec, 0x57, 0xae, 0xdb, 0x4d, 0x00, 0x36, 0xff, 0xbd, 0x3b, 0xef, 0x0a, 0xf0, 0x39, 0x2a, 0xa9, 0x2a, 0x56, 0x9d, 0x7e, 0xc2, 0x05, 0x46,
0x21, 0x8b, 0xd9, 0x45, 0x33, 0x59, 0x3c, 0x40, 0xe0, 0x2b, 0xec, 0x51, 0x0a, 0xf2, 0xf0, 0xe5, 0x61, 0x99, 0xfd, 0x2e, 0xe5, 0x8c, 0x69, 0x72, 0x44, 0xb8,
0x92, 0x9e, 0x2e, 0x51, 0x0b, 0x8c, 0x16, 0x0f, 0xd9, 0xf2, 0x47, 0xc4, 0x36, 0x9d, 0x54, 0xc6, 0x9f, 0x26, 0x68, 0xf7, 0xfa, 0x80, 0xab, 0x68, 0xd6, 0x61,
0xd2, 0x59, 0x4b, 0x07, 0xc4, 0x7a, 0xde, 0x37, 0xfb, 0x02, 0xd5, 0x3d, 0xb0, 0xeb, 0x71, 0x1b, 0x07, 0x45, 0xdc, 0x03, 0x3b, 0x4f, 0xdf, 0x44, 0xa6, 0x28,
0xd4, 0xb0, 0xbb, 0x26, 0xa4, 0x4f, 0xee, 0xbb, 0xda, 0x59, 0x69, 0xfc, 0xf2, 0x4f, 0xf6, 0x6c, 0x0c, 0xd9, 0xe2, 0xc0, 0xa7, 0x64, 0x96, 0xd5, 0x7b, 0x1a,
0x71, 0xe0, 0xcc, 0x9e, 0xd7, 0xaf, 0xb9, 0x9a, 0x99, 0xcc, 0xe9, 0xa9, 0x79, 0xa3, 0xa8, 0xbc, 0xfe, 0xdb, 0xed, 0xa3, 0xd6, 0x7d, 0x31, 0xb0, 0x18, 0x3c,
0x9e, 0x55, 0x0f, 0x1a, 0xdf, 0x5d, 0x8c, 0x33, 0x5f, 0x2d, 0x00, 0xfc, 0x9d, 0x55, 0xc2, 0xc8, 0x10, 0x25, 0xc8, 0x7f, 0xa6, 0x47, 0xbf, 0xab, 0xf3, 0x97,
0xfb, 0xda, 0xd6, 0x6a, 0x14, 0x87, 0x9d, 0xef, 0x85, 0x12, 0x43, 0x31, 0xc4, 0x4c, 0x4c, 0x7a, 0x50, 0xd1, 0x87, 0xb8, 0x65, 0x2d, 0x3f, 0xf4, 0xa3, 0x54,
0xea, 0x19, 0xa6, 0xfe, 0x70, 0x30, 0x52, 0x7e, 0xbb, 0x9c, 0x3f, 0x2d, 0x10, 0x4b, 0x43, 0x04, 0x6c, 0x03, 0xc7, 0x15, 0xa8, 0x8e, 0x20, 0xde, 0x46, 0x9a,
0x12, 0xdf, 0xe5, 0xce, 0x08, 0x91, 0x3e, 0x74, 0xa4, 0x20, 0x78, 0x52, 0xfd, 0x4d, 0x9d, 0x5a, 0xca, 0xa2, 0xdc, 0xc8, 0x95, 0xa6, 0x69, 0x3a, 0x1a, 0x18,
0x48, 0x76, 0x8c, 0xfa, 0x58, 0xa0, 0x0d, 0xf4, 0xbd, 0xe1, 0x05, 0x37, 0xe6, 0xbb, 0x60, 0xa1, 0x83, 0x40, 0x08, 0xeb, 0x02, 0xa6, 0x6a, 0x7a, 0x16, 0x1f,
0xb3, 0xbb, 0x58, 0x66, 0x51, 0x79, 0xc7, 0x7a, 0xb2, 0xc2, 0x84, 0x69, 0x17, 0x4d, 0x27, 0xa7, 0xa4, 0xd9, 0xc2, 0xb4, 0x03, 0x57, 0x53, 0x44, 0xb9, 0x8f,
0x48, 0xd2, 0x1c, 0x74, 0xd9, 0xdd, 0x9b, 0x88, 0xdc, 0xda, 0xef, 0x70, 0xee, 0xf1, 0x04, 0xdf, 0x35, 0x39, 0x22, 0x49, 0xfe, 0x22, 0x9a, 0x0a, 0x33, 0x60,
0x49, 0xbe, 0x28, 0xfa, 0xdf, 0x71, 0xf6, 0x53, 0x87, 0x49, 0x01, 0x95, 0xfa, 0x22, 0x94, 0x7c, 0x0e, 0xf6, 0x7d, 0x7e, 0x1d, 0x41, 0x8d, 0xc9, 0x1b, 0xf9,
0x53, 0xc9, 0xea, 0x46, 0xee, 0xb2, 0x80, 0x75, 0xe1, 0xca, 0xa4, 0x0a, 0x9c, 0x2b, 0x7b, 0x18, 0x70, 0x56, 0x22, 0x76, 0xaf, 0x4d, 0x84, 0xdd, 0xad, 0x77,
0x0e, 0xea, 0x2e, 0xa9, 0xbf, 0x42, 0x95, 0xdf, 0x17, 0x61, 0xae, 0x46, 0x20, 0xb4, 0x57, 0xca, 0xe0, 0x78, 0x86, 0xd0, 0x51, 0xae, 0xfe, 0x51, 0x53, 0x0f,
0x36, 0x64, 0x5f, 0xb4, 0x5c, 0xac, 0x8b, 0x63, 0xe7, 0x5d, 0x98, 0x8e, 0x65, 0xe9, 0x93, 0xe4, 0x23, 0x10, 0xa3, 0x5b, 0x3b, 0x38, 0x5b, 0x8a, 0x7c, 0xe8,
0x60, 0x01, 0xc1, 0x0e, 0x53, 0x5e, 0x0e, 0x81, 0x33, 0x63, 0x8d, 0x53, 0x82, 0xa6, 0x7b, 0xbd, 0x02, 0x58, 0x1f, 0xe9, 0x50, 0x75, 0x24, 0x64, 0x80, 0x47,
0x02, 0x9e, 0x42, 0x28, 0xb0, 0xa2, 0xab, 0x8e, 0xd0, 0x81, 0xcc, 0xbd, 0x22, 0x87, 0x3f, 0xe4, 0x49, 0x4d, 0x5f, 0x6a, 0x5c, 0x5a, 0x5e, 0xe5, 0x30, 0x9d,
0x7f, 0xc5, 0xaf, 0xce, 0x6c, 0x9c, 0xaf, 0x09, 0x96, 0x1a, 0x62, 0x42, 0x61, 0xa3, 0x1d, 0x1c, 0xe6, 0xcf, 0xa8, 0x90, 0x4d, 0x05, 0x28, 0xef, 0x22, 0x81,
0x12, 0xa7, 0x70, 0x04, 0x0a, 0x0d, 0xe5, 0xa0, 0xd8,
];
#[test]
fn block_without_chroma_size_is_an_error_not_a_panic() {
let mut dec = Decoder::new();
let r = dec.decode(PROFILE_FLIPPED).and_then(|_| dec.flush_result());
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("chroma size")), "got {:?}", r.as_ref().map(|p| p.len()));
}
+110
View File
@@ -0,0 +1,110 @@
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
//! truncation at (nearly) every length and random mutation of encoder-made frames must never
//! panic or run away (errors are fine). No ffmpeg needed.
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with, probe};
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
}
}
fn seeds() -> Vec<Vec<u8>> {
let mut v = Vec::new();
for (profile, depth, w, h) in
[(Profile::Lb, 8, 64u32, 48u32), (Profile::Hq, 8, 48, 32), (Profile::Hqx, 10, 64, 32), (Profile::Hqx, 12, 48, 32), (Profile::R444, 10, 48, 32)]
{
let mut cfg = EncoderConfig::new(profile, w, h);
cfg.bit_depth = depth;
let chroma = cfg.chroma();
let mut f = Frame::new(w, h, chroma, depth, false);
for (i, s) in f.y.iter_mut().enumerate() {
*s = ((16 + (i * 37) % 200) as u16) << (depth - 8);
}
if chroma != ChromaFormat::Yuv444 {
f.cb.fill(128 << (depth - 8));
f.cr.fill(100 << (depth - 8));
}
if let Ok(mut enc) = Encoder::with_config(cfg)
&& let Ok(d) = enc.encode(&f)
{
v.push(d);
}
}
v
}
fn run(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
let res = catch_unwind(AssertUnwindSafe(|| {
let _ = probe(d);
for opts in [DecodeOptions::default(), DecodeOptions { threads: true, spec_dequant: true }] {
let _ = decode_frame_with(d, &opts);
}
}));
assert!(res.is_ok(), "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_frames_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let seeds = seeds();
assert!(!seeds.is_empty());
for (si, seed) in seeds.iter().enumerate() {
let stride = (seed.len() / 400).max(1);
for n in (0..seed.len()).step_by(stride) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
// The header (and the start of the scan-line index): every byte to extreme values, every bit.
for p in 0..seed.len().min(0x300) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p:#x}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p:#x}.{b}"));
}
}
// Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C).
for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(off..off + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u16 {off:#x}={v:#x}"));
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1500);
for i in 0..iters {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run(&d, &format!("seed {si} random {i}"));
}
}
}
+136
View File
@@ -0,0 +1,136 @@
//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and
//! random mutation of small libx264 streams must never panic or run away. Skips without ffmpeg.
mod common;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use filmcraft_h264::Decoder;
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
}
}
/// (name, extra x264 args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[
("h264_fz_cabac_b", "-profile:v high -bf 2 -g 8 -x264-params cabac=1:weightp=2:8x8dct=1", "yuv420p"),
("h264_fz_cavlc", "-profile:v baseline -g 6 -x264-params cabac=0", "yuv420p"),
("h264_fz_interlaced", "-profile:v high -flags +ildct+ilme -bf 1 -g 6 -x264-params tff=1:interlaced=1", "yuv420p"),
("h264_fz_10bit_422", "-profile:v high422 -bf 1 -g 6", "yuv422p10le"),
("h264_fz_multislice", "-profile:v main -g 6 -x264-params slices=3:cabac=1", "yuv420p"),
];
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<u8>> {
let out = filmcraft_testkit::fixtures_dir("h264").join(format!("{name}.h264"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", "libx264"])
.args(a)
.args(["-f", "h264"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
std::fs::read(p).ok()
}
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) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(std::sync::atomic::Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for au in common::split_access_units(d) {
let _ = dec.decode(au, 0);
}
let _ = dec.decode(d, 1);
let _ = dec.flush();
let _ = dec.take_error();
}
}));
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: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("h264 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx264") {
filmcraft_testkit::skip("h264 fuzz", &format!("this ffmpeg has no libx264 encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libx264 but could not encode the {name} seed");
};
let stride = (seed.len() / 300).max(1);
for n in (0..seed.len()).step_by(stride) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// SPS/PPS/first slice header region: every byte to a few values, every bit
for p in 0..seed.len().min(120) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
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;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(400)
}
+137
View File
@@ -0,0 +1,137 @@
//! Hostile-input robustness: header-region sweeps (SPS/PPS/first slice headers), truncation and
//! random mutation of small libx265 streams must never panic or run away. Skips without ffmpeg.
mod common;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use filmcraft_hevc::Decoder;
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
}
}
/// (name, encoder, extra encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str, &str)] = &[
("hevc_fz_main_b", "libx265", "-x265-params log-level=error:bframes=2:keyint=8:min-keyint=8:sao=1:amp=1", "yuv420p"),
("hevc_fz_main10", "libx265", "-x265-params log-level=error:bframes=1:keyint=6:min-keyint=6", "yuv420p10le"),
("hevc_fz_wpp_slices", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6:wpp=1:slices=3", "yuv420p"),
// x265 cannot encode tiles; kvazaar can, at 256 x 64 luma per tile at least (Main profile)
("hevc_fz_tiles_kvazaar", "libkvazaar", "-s 512x128 -kvazaar-params tiles=2x2,gop=0,period=6,qp=40", "yuv420p"),
("hevc_fz_422_10", "libx265", "-x265-params log-level=error:keyint=6:min-keyint=6", "yuv422p10le"),
];
fn make(ff: &std::path::Path, name: &str, enc: &str, args: &str, pix: &str) -> Option<Vec<u8>> {
let out = filmcraft_testkit::fixtures_dir("hevc").join(format!("{name}.hevc"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "7", "-pix_fmt", pix, "-c:v", enc])
.args(a)
.args(["-f", "hevc"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
std::fs::read(p).ok()
}
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) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(std::sync::atomic::Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for au in common::split_access_units(d) {
let _ = dec.decode(au, 0);
}
let _ = dec.decode(d, 1);
let _ = dec.flush();
let _ = dec.take_error();
}
}));
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: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("hevc fuzz") else { return };
for &(name, enc, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, enc) {
filmcraft_testkit::skip("hevc fuzz", &format!("this ffmpeg has no {enc} encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, enc, args, pix) else {
panic!("ffmpeg has {enc} but could not encode the {name} seed");
};
let stride = (seed.len() / 300).max(1);
for n in (0..seed.len()).step_by(stride) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// SPS/PPS/first slice header region: every byte to a few values, every bit
for p in 0..seed.len().min(120) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
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;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(400)
}
+3
View File
@@ -38,6 +38,9 @@ parameters, so a random-access point without its own sequence header still decod
- Main Profile at Low/Main/High-1440/High Level, 4:2:2 Profile (IMX / D-10, XDCAM HD422),
Simple Profile, MPEG-1 (including D pictures). 4:4:4 is refused (no profile in use for it).
- Pictures above 2^25 luma samples (8192 x 4096; 8K UHD at 7680 x 4320 fits, and the largest MPEG-2 level,
High, is 1920 x 1152) are refused as `Unsupported`: the 14-bit header fields allow 16383 x 16383, which
would ask for gigabytes per frame from hostile input.
- Frame pictures with frame and field motion compensation, dual prime, frame and field DCT;
field pictures with field, 16x8 and dual-prime motion compensation, the second field of a
P frame predicted from the first; open and closed GOPs; B pictures without a forward
+6 -1
View File
@@ -163,6 +163,9 @@ struct Seq {
/// MPEG-1 pel_aspect_ratio (height/width of a sample) ×10000, Table 2-D.2.
const MPEG1_PEL_ASPECT: [u32; 15] = [0, 10000, 6735, 7031, 7615, 8055, 8437, 8935, 9157, 9815, 10255, 10695, 10950, 11575, 12015];
/// Largest accepted luma area (8192 x 4096; 8K UHD is 7680 x 4320).
const MAX_PICTURE_AREA: u64 = 1 << 25;
impl Seq {
fn new(hdr: SequenceHeader, ext: Option<SequenceExtension>, display: Option<DisplayExtension>) -> Result<Seq> {
let (w, h) = match &ext {
@@ -175,7 +178,9 @@ impl Seq {
3 => ChromaFormat::Yuv444,
_ => return Err(Error::Invalid("reserved chroma_format".into())),
};
if w > 16383 || h > 16383 {
// The 14-bit sizes allow 16383 x 16383 (gigabytes per frame, several frames and a gray reference
// held); no real stream exceeds 8K, so cap the area there.
if w > 16383 || h > 16383 || u64::from(w) * u64::from(h) > MAX_PICTURE_AREA {
return Err(Error::Unsupported(format!("{w}x{h}")));
}
let progressive_sequence = ext.is_none_or(|e| e.progressive_sequence);
+142
View File
@@ -0,0 +1,142 @@
//! 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.
mod common;
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_mpeg2v::{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) {
filmcraft_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: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpeg2v fuzz") else { return };
for spec in &SPECS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "mpeg2video") {
filmcraft_testkit::skip("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(spec.name, &std::fs::read(path).unwrap());
}
}
fn fuzz(name: &str, seed: &[u8]) {
// every truncation length (stride keeps it quick on larger seeds)
let stride = (seed.len() / 600).max(1);
for n in (0..seed.len()).step_by(stride) {
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 v in [0u8, 0xff, 0x80, 0x01] {
let mut d = seed.to_vec();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
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);
for i in 0..iters() {
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;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(2500)
}
+73
View File
@@ -0,0 +1,73 @@
//! A 14-byte sequence header must not make the decoder size its frame buffers at 16383 x 16383
//! (gigabytes per picture). Hand-built, no ffmpeg.
use filmcraft_mpeg2v::Decoder;
#[derive(Default)]
struct Bits {
bytes: Vec<u8>,
n: usize,
}
impl Bits {
fn put(&mut self, value: u32, bits: usize) {
for i in (0..bits).rev() {
if self.n.is_multiple_of(8) {
self.bytes.push(0);
}
if (value >> i) & 1 == 1
&& let Some(b) = self.bytes.last_mut()
{
*b |= 0x80 >> (self.n % 8);
}
self.n += 1;
}
}
}
/// Sequence header plus sequence extension for a `w` x `h` 4:4:4 stream.
fn sequence(w: u32, h: u32, chroma_format: u32) -> Vec<u8> {
let mut s = Bits::default();
s.put(w & 0xfff, 12);
s.put(h & 0xfff, 12);
s.put(1, 4); // aspect_ratio_information
s.put(4, 4); // frame_rate_code
s.put(1000, 18); // bit_rate_value
s.put(1, 1); // marker
s.put(100, 10); // vbv_buffer_size_value
s.put(0, 3); // constrained_parameters_flag, load_intra, load_non_intra
let mut e = Bits::default();
e.put(1, 4); // sequence_extension
e.put(0x48, 8); // profile_and_level_indication
e.put(1, 1); // progressive_sequence
e.put(chroma_format, 2);
e.put(w >> 12, 2);
e.put(h >> 12, 2);
e.put(0, 12); // bit_rate_extension
e.put(1, 1); // marker
e.put(0, 8 + 1 + 2 + 5); // vbv_buffer_size_extension, low_delay, frame_rate_extension_n/d
let mut out = vec![0, 0, 1, 0xb3];
out.extend(s.bytes);
out.extend([0, 0, 1, 0xb5]);
out.extend(e.bytes);
out
}
fn accepted(w: u32, h: u32, chroma_format: u32) -> bool {
let mut dec = Decoder::new();
let _ = dec.decode(&sequence(w, h, chroma_format), 0);
dec.sequence().is_some()
}
#[test]
fn ordinary_sequences_are_accepted() {
assert!(accepted(1920, 1080, 1));
assert!(accepted(7680, 4320, 3));
}
#[test]
fn gigantic_sequences_are_refused() {
assert!(!accepted(16383, 16383, 3));
assert!(!accepted(16383, 16383, 1));
assert!(!accepted(8192, 8192, 1));
}
+107
View File
@@ -0,0 +1,107 @@
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
//! truncation at (nearly) every length must never panic or run away (errors are fine).
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_prores::*;
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
}
}
fn seeds() -> Vec<Vec<u8>> {
let mut v = Vec::new();
for (p, w, h) in [(Profile::Proxy, 96u32, 48u32), (Profile::Hq, 70, 38), (Profile::P4444, 64, 32), (Profile::P4444Xq, 48, 20)] {
let chroma = p.chroma();
let mut f = Frame::new(w, h, chroma, 10, chroma == ChromaFormat::Yuv444);
for (i, s) in f.y.iter_mut().enumerate() {
*s = (64 + (i * 37) % 800) as u16;
}
if let Some(a) = f.alpha.as_mut() {
for (i, s) in a.iter_mut().enumerate() {
*s = if i % 5 == 0 { 1023 } else { (i % 1024) as u16 };
}
}
let mut cfg = EncoderConfig::new(p, w, h);
cfg.log2_slice_mbs = (w % 4) as u8;
if let Ok(d) = Encoder::with_config(cfg).encode(&f) {
v.push(d);
}
}
v
}
fn run(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
let res = catch_unwind(AssertUnwindSafe(|| {
for depth in [None, Some(8), Some(16)] {
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() });
}
let _ = probe(d);
}));
assert!(res.is_ok(), "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_frames_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let seeds = seeds();
assert!(!seeds.is_empty());
for (si, seed) in seeds.iter().enumerate() {
let stride = (seed.len() / 400).max(1);
for n in (0..seed.len()).step_by(stride) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
for p in 0..seed.len().min(256) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p}.{b}"));
}
}
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
for p in 0..seed.len().min(128) {
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(p..p + 4) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
for i in 0..iters {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run(&d, &format!("seed {si} random {i}"));
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Hang guard for hostile-input sweeps: a test body runs on a worker thread, and a body that does not finish
//! within the budget fails the test (naming the input it was last working on) instead of stalling CI forever.
//! A merely slow body is not a failure, so a loaded runner or slow VM cannot fail a correct decoder.
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Mutex, PoisonError};
use std::time::Duration;
static PROGRESS: Mutex<String> = Mutex::new(String::new());
/// Record the input being worked on, so a hang report can name it. Cheap enough to call per input.
pub fn set_progress(label: &str) {
let mut p = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner);
p.clear();
p.push_str(label);
}
/// Run `f` on a worker thread and wait at most `secs` seconds (`FILMCRAFT_HOSTILE_TIMEOUT_SECS` overrides the
/// budget). A panic in `f` is re-raised here; a timeout panics with the last [`set_progress`] label. A hung
/// worker is abandoned (the failing test process exits anyway).
pub fn with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) {
let secs = std::env::var("FILMCRAFT_HOSTILE_TIMEOUT_SECS").ok().and_then(|v| v.parse::<u64>().ok()).filter(|&n| n > 0).unwrap_or(secs);
run_with_deadline(secs, f);
}
fn run_with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) {
let (tx, rx) = mpsc::channel();
// a failed spawn shows up as "worker gone" below
let _ = std::thread::Builder::new().name("hostile deadline".into()).spawn(move || {
let _ = tx.send(catch_unwind(AssertUnwindSafe(f)));
});
match rx.recv_timeout(Duration::from_secs(secs)) {
Ok(Ok(())) => {}
Ok(Err(payload)) => resume_unwind(payload),
Err(RecvTimeoutError::Timeout) => {
let last = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner).clone();
panic!("hostile-input test did not finish in {secs}s (hang); last input: {last}");
}
Err(RecvTimeoutError::Disconnected) => panic!("hostile-input worker thread could not run"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_slow_body_passes() {
with_deadline(30, || std::thread::sleep(Duration::from_millis(100)));
}
#[test]
#[should_panic(expected = "boom")]
fn a_panic_in_the_body_is_re_raised() {
with_deadline(30, || panic!("boom"));
}
#[test]
#[should_panic(expected = "last input: seed 7 byte 3")]
fn a_hang_fails_and_names_the_input() {
// a body that outlives the 1 s budget; the inner fn, so FILMCRAFT_HOSTILE_TIMEOUT_SECS cannot lengthen it
set_progress("seed 7 byte 3");
run_with_deadline(1, || std::thread::sleep(Duration::from_secs(30)));
}
}
+4 -1
View File
@@ -10,14 +10,17 @@
//! ffmpeg/ffprobe are external test oracles and fixture generators only; they are never linked
//! or shipped (AGENTS.md §2).
pub mod deadline;
pub mod fixtures;
pub mod golden;
pub mod oracle;
pub mod wav;
pub use deadline::{set_progress, with_deadline};
pub use fixtures::{fixtures_dir, temp_path, workspace_root};
pub use oracle::{
ffmpeg, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip,
ffmpeg, ffmpeg_has_encoder, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required,
skip,
};
/// `let ff = require_ffmpeg!();` — the ffmpeg path, or return from the test after printing
+23
View File
@@ -11,6 +11,7 @@
//! `FILMCRAFT_REQUIRE_ORACLES=1` is set (CI), which turns every skip into a failure.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
/// Environment variable that makes missing oracles a hard failure.
@@ -116,6 +117,19 @@ pub fn ffmpeg_or_skip(context: &str) -> Option<PathBuf> {
tool_or_skip("ffmpeg", ffmpeg(), context)
}
/// Whether `ffmpeg -encoders` lists `encoder` (e.g. `libx265`): lets a test tell "this ffmpeg lacks the encoder"
/// (skip) from "the encoder is there but failed" (a real failure).
pub fn ffmpeg_has_encoder(ffmpeg: &Path, encoder: &str) -> bool {
let Ok(out) = Command::new(ffmpeg).args(["-hide_banner", "-encoders"]).stdin(Stdio::null()).output() else { return false };
encoders_output_lists(&String::from_utf8_lossy(&out.stdout), encoder)
}
/// Pure function of the `ffmpeg -encoders` output: whether `encoder` is one of the listed names.
fn encoders_output_lists(output: &str, encoder: &str) -> bool {
// rows look like ` V....D libx264 libx264 H.264 …`; the name is the second whitespace-separated token
output.lines().any(|line| line.split_whitespace().nth(1) == Some(encoder))
}
/// ffprobe, or `None` after reporting a skip (see [`skip`]).
pub fn ffprobe_or_skip(context: &str) -> Option<PathBuf> {
tool_or_skip("ffprobe", ffprobe(), context)
@@ -177,6 +191,15 @@ pub fn ffmpeg_with_format_or_skip(context: &str, format_name: &str, min_version:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoder_lookup_matches_the_name_column_only() {
let out = " V....D libx264 libx264 H.264 / AVC\n V....D libx265 libx265 H.265 / HEVC (libx265)\n A....D aac AAC (libx264 inside)\n";
assert!(encoders_output_lists(out, "libx264"));
assert!(encoders_output_lists(out, "libx265"));
assert!(!encoders_output_lists(out, "libsvtav1"));
assert!(!encoders_output_lists(out, "H.264"));
}
use std::ffi::OsString;
fn tmpdir(tag: &str) -> PathBuf {
+3
View File
@@ -11,3 +11,6 @@ workspace = true
[dependencies]
thiserror = { workspace = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+158
View File
@@ -0,0 +1,158 @@
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
//! ffmpeg-generated VP8 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use filmcraft_vp8::Decoder;
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
}
}
/// (name, encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[
("vp8_fz_default", "-c:v libvpx -g 4 -b:v 300k", "yuv420p"),
("vp8_fz_partitions", "-c:v libvpx -g 6 -b:v 300k -slices 8 -auto-alt-ref 0 -lag-in-frames 0 -error-resilient 1", "yuv420p"),
];
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let Some(h) = data.get(6..8) else { return out };
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
while let Some(hd) = data.get(pos..pos + 12) {
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
pos += 12;
let end = pos.saturating_add(size).min(data.len());
out.push(data[pos..end].to_vec());
pos = end;
}
out
}
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
let out = filmcraft_testkit::fixtures_dir("vp8").join(format!("{name}.ivf"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
.args(a)
.args(["-f", "ivf"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
Some(ivf_frames(&std::fs::read(p).ok()?))
}
static PANICS: AtomicUsize = AtomicUsize::new(0);
/// Decoder worker threads may 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, Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(frames: &[Vec<u8>], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for _ in 0..1 {
let mut dec = Decoder::new();
for (i, f) in frames.iter().enumerate() {
let _ = dec.decode(f, i as i64);
}
}
}));
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP8 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx") {
filmcraft_testkit::skip("VP8 fuzz", &format!("this ffmpeg has no libvpx encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libvpx but could not encode the {name} seed");
};
if seed.is_empty() {
filmcraft_testkit::skip("VP8 fuzz", &format!("the {name} seed has no frames"));
continue;
}
// truncate each of the first frames at every length
for fi in 0..seed.len().min(3) {
let len = seed[fi].len();
for n in (0..len).step_by((len / 200).max(1)) {
let mut d = seed.clone();
d[fi].truncate(n);
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
}
}
// header region of every frame: every byte to a few values, every bit
for fi in 0..seed.len() {
for p in 0..seed[fi].len().min(96) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[fi][p] = v;
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[fi][p] ^= 1 << b;
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
}
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let fi = rng.below(d.len());
if d[fi].is_empty() {
continue;
}
let p = rng.below(d[fi].len());
if rng.below(2) == 0 {
d[fi][p] ^= 1 << rng.below(8);
} else {
d[fi][p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
+1
View File
@@ -19,3 +19,4 @@ thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+162
View File
@@ -0,0 +1,162 @@
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
//! ffmpeg-generated VP9 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use filmcraft_vp9::Decoder;
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
}
}
/// (name, encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[
("vp9_fz_default", "-c:v libvpx-vp9 -g 4 -b:v 300k -lag-in-frames 0", "yuv420p"),
// libvpx needs 256 px per tile column, so two columns need 512 px
("vp9_fz_2tiles_10bit", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -tile-columns 1 -profile:v 2 -s 512x48", "yuv420p10le"),
("vp9_fz_444", "-c:v libvpx-vp9 -g 6 -b:v 300k -lag-in-frames 0 -profile:v 1", "yuv444p"),
("vp9_fz_altref", "-c:v libvpx-vp9 -g 6 -b:v 300k -auto-alt-ref 1 -lag-in-frames 5", "yuv420p"),
];
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let Some(h) = data.get(6..8) else { return out };
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
while let Some(hd) = data.get(pos..pos + 12) {
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
pos += 12;
let end = pos.saturating_add(size).min(data.len());
out.push(data[pos..end].to_vec());
pos = end;
}
out
}
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
let out = filmcraft_testkit::fixtures_dir("vp9").join(format!("{name}.ivf"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
.args(a)
.args(["-f", "ivf"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
Some(ivf_frames(&std::fs::read(p).ok()?))
}
static PANICS: AtomicUsize = AtomicUsize::new(0);
/// Decoder worker threads may 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, Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(frames: &[Vec<u8>], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for (i, f) in frames.iter().enumerate() {
let _ = dec.decode(f, i as i64);
}
let _ = dec.flush();
}
}));
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP9 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx-vp9") {
filmcraft_testkit::skip("VP9 fuzz", &format!("this ffmpeg has no libvpx-vp9 encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libvpx-vp9 but could not encode the {name} seed");
};
if seed.is_empty() {
filmcraft_testkit::skip("VP9 fuzz", &format!("the {name} seed has no frames"));
continue;
}
// truncate each of the first frames at every length
for fi in 0..seed.len().min(3) {
let len = seed[fi].len();
for n in (0..len).step_by((len / 200).max(1)) {
let mut d = seed.clone();
d[fi].truncate(n);
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
}
}
// header region of every frame: every byte to a few values, every bit
for fi in 0..seed.len() {
for p in 0..seed[fi].len().min(96) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[fi][p] = v;
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[fi][p] ^= 1 << b;
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
}
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let fi = rng.below(d.len());
if d[fi].is_empty() {
continue;
}
let p = rng.below(d[fi].len());
if rng.below(2) == 0 {
d[fi][p] ^= 1 << rng.below(8);
} else {
d[fi][p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}