diff --git a/Cargo.lock b/Cargo.lock index 6d2935702..1aef35fe0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -974,6 +974,9 @@ dependencies = [ [[package]] name = "craft-cfb" version = "0.1.0" +dependencies = [ + "craft-media-testkit", +] [[package]] name = "craft-cms" diff --git a/crates/cfb/Cargo.toml b/crates/cfb/Cargo.toml index 04bdac867..e2684da0b 100644 --- a/crates/cfb/Cargo.toml +++ b/crates/cfb/Cargo.toml @@ -11,3 +11,6 @@ repository.workspace = true workspace = true [dependencies] + +[dev-dependencies] +craft-media-testkit = { workspace = true } diff --git a/crates/cfb/src/tests.rs b/crates/cfb/src/tests.rs index 7625afb52..dda76fb24 100644 --- a/crates/cfb/src/tests.rs +++ b/crates/cfb/src/tests.rs @@ -3,6 +3,7 @@ use super::*; +mod hostile; mod ported; fn pattern(n: usize, seed: u8) -> Vec { diff --git a/crates/cfb/src/tests/hostile.rs b/crates/cfb/src/tests/hostile.rs new file mode 100644 index 000000000..c20c132e2 --- /dev/null +++ b/crates/cfb/src/tests/hostile.rs @@ -0,0 +1,30 @@ +//! Hostile-input hammer: truncation, bit flips and extreme field values must never panic or hang. + +use super::*; + +fn exercise(d: &[u8]) { + let Ok(f) = CompoundFile::open(d) else { return }; + let _ = f.root(); + let _ = f.sector_size(); + for id in 0..2000 { + if f.entry(id).is_none() { + break; + } + let _ = f.read(id); + let _ = f.child(id, "s0"); + } + let _ = f.read(usize::MAX); + let _ = f.find("Header-2/Content-3b03/s4"); + let _ = f.read_path("Mobs-19010/properties"); +} + +#[test] +fn hostile_input_never_panics() { + for (i, v) in [Version::V3, Version::V4].into_iter().enumerate() { + let (bytes, _) = sample(v, 5); + craft_media_testkit::hostile::hammer(&format!("cfb seed {i}"), &bytes, 15_000, exercise); + // The header and directory are where the structure lives: hammer a prefix harder. + let head = &bytes[..bytes.len().min(2048)]; + craft_media_testkit::hostile::hammer(&format!("cfb head {i}"), head, 15_000, exercise); + } +} diff --git a/crates/media/src/matroska/synthetic_tests.rs b/crates/media/src/matroska/synthetic_tests.rs index d667a0952..ba96d483c 100644 --- a/crates/media/src/matroska/synthetic_tests.rs +++ b/crates/media/src/matroska/synthetic_tests.rs @@ -10,6 +10,8 @@ use crate::matroska::mux::{el, el_float, el_str, el_uint}; use crate::matroska::track::ContentEncoding; use crate::matroska::{ByteSource, Demuxer, MkvFile, OpenOptions, Packet, open_with}; +mod hostile; + fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec { let mut b = vec![0x80 | track]; b.extend_from_slice(&rel.to_be_bytes()); diff --git a/crates/media/src/matroska/synthetic_tests/hostile.rs b/crates/media/src/matroska/synthetic_tests/hostile.rs new file mode 100644 index 000000000..e8180fd3b --- /dev/null +++ b/crates/media/src/matroska/synthetic_tests/hostile.rs @@ -0,0 +1,75 @@ +//! Hostile-input hammer: truncation, bit flips and extreme field values must never panic or hang. + +use super::*; +use crate::matroska::mux::{LacingMode, MkvWriter, MuxOptions, TrackSpec}; +use crate::matroska::{MkvFile, TrackKind}; +use std::io::Cursor; + +fn muxed(lacing: LacingMode) -> Vec { + let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG"); + v.video_size = Some((16, 16)); + v.default_duration_ns = Some(40_000_000); + let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT"); + a.audio = Some((48000.0, 1, Some(16))); + let mut s = TrackSpec::new(TrackKind::Subtitle, "S_TEXT/UTF8"); + s.language = Some("fre".into()); + let opts = MuxOptions { lacing, cluster_duration_ns: 100_000_000, ..Default::default() }; + let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![v, a, s], opts).unwrap(); + for i in 0..12i64 { + m.write_frame(0, i * 40_000_000, i % 4 == 0, &vec![i as u8; 30 + i as usize], None).unwrap(); + for k in 0..3 { + m.write_frame(1, (i * 40 + k * 10) * 1_000_000, true, &[k as u8; 48], None).unwrap(); + } + if i % 5 == 0 { + m.write_frame(2, i * 40_000_000, true, b"hello", Some(500_000_000)).unwrap(); + } + } + m.finish().unwrap().into_inner() +} + +fn walk(f: &MkvFile, d: &[u8]) { + for t in 0..f.tracks.len() { + let n = f.tracks[t].samples.len(); + for k in [0, n / 2, n.saturating_sub(1), n, usize::MAX] { + let _ = f.read_sample(d, t, k); + let _ = f.read_sample_addition(d, t, k); + } + let _ = f.keyframe_before(t, i64::MIN); + let _ = f.keyframe_before(t, i64::MAX); + } + let _ = f.duration_ns(); +} + +fn exercise(d: &[u8]) { + for opts in + [OpenOptions { index: true, verify_crc: true, ..Default::default() }, OpenOptions { index: false, verify_crc: false, ..Default::default() }] + { + if let Ok(f) = open_with(d, &opts) { + walk(&f, d); + } + if let Ok(mut dm) = Demuxer::with_options(d, opts) { + let mut n = 0; + while let Ok(Some(_)) = dm.next_packet() { + n += 1; + if n > 100_000 { + break; + } + } + for t in 0..dm.tracks().len() { + let _ = dm.seek(t, 20_000_000); + let _ = dm.seek(t, i64::MAX); + let _ = dm.seek(t, i64::MIN); + let _ = dm.keyframe_index(t); + } + let _ = dm.build_index(); + } + } +} + +#[test] +fn hostile_input_never_panics() { + let seeds = [synthetic(), muxed(LacingMode::None), muxed(LacingMode::Xiph), muxed(LacingMode::Ebml), muxed(LacingMode::FixedOrEbml)]; + for (i, s) in seeds.iter().enumerate() { + craft_media_testkit::hostile::hammer(&format!("matroska seed {i}"), s, 20_000, exercise); + } +} diff --git a/crates/media/tests/fuzz.rs b/crates/media/tests/fuzz.rs index 3c302ac6b..29cdbf7c7 100644 --- a/crates/media/tests/fuzz.rs +++ b/crates/media/tests/fuzz.rs @@ -175,6 +175,15 @@ fn mutated_ffmpeg_fixtures_never_panic() { } } +#[test] +fn hostile_hammer_never_panics() { + for (i, f) in synthetic_files().iter().enumerate() { + craft_media_testkit::hostile::hammer(&format!("isobmff seed {i}"), f, 20_000, |d| { + exercise(d); + }); + } +} + #[test] fn truncations_never_panic() { for f in synthetic_files() {