mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
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:
Generated
+2
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,3 +11,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,4 @@ thiserror = { workspace = true }
|
||||
rayon = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user