test: constrain MP4 composition timing, MLP substream directory, and codec-private absence
Mutation testing over src/mux/. No production change — 49 survivors killed, all proven red before green. The MP4 composition-time chain was entirely unconstrained: VideoTiming::ctts, build_ctts and parse_ctts could each return a constant and the suite stayed green. Confirmed on HEAD: build_ctts -> vec![] passes all 1,220 mux tests. A demuxed B-frame title presenting in decode order would have shipped. The cause is a test whose name asserts coverage its body does not deliver — stts_and_ctts_expand builds an stts box and never touches ctts, and write_then_read_round_trip asserts sample sizes and keyframe flags but not one PTS. Same shape as the set_speed forwarding finding, different disguise. mlp_num_substreams / mlp_substr_header_size: every TrueHD fixture in the crate uses one substream and no extraword, so both could return a constant and agree with all of them. These position mlp_parity_ok's window over the AU header, so a constant mis-windows the parity check on exactly the multi-substream AUs that carry 7.1 and Atmos. CodecPrivate absent vs empty: mkv.rs writes Some(bytes) verbatim and omits the element on None (RFC 9559 5.1.4.1.24), so a zero-length Some emits a track header asserting the config IS empty. Four parsers could return Some(vec![]) before any frame. Also: mandatory ISO/IEC 14496-12 boxes (tkhd, vmhd, smhd, dinf, mdhd) could each build empty; HEVC num_extra_slice_header_bits (H.265 7.3.2.3) was never non-zero in any fixture, so the slice-type offset skip was unexercised; chapter names from the disc go straight into <ChapterString> and the & escape must run first; a stray 0x47 in a payload must not latch a TS resync. Documented as equivalent rather than killed: CodecParser::flush and the three parser flush bodies that differ from the mutant only by a tracing call, and DropTally::log_summary.
This commit is contained in:
@@ -1197,6 +1197,398 @@ mod tests {
|
||||
assert_eq!(parse_stts(&stts, MAX_SAMPLE_COUNT), vec![1001, 1001, 1001]);
|
||||
}
|
||||
|
||||
// ── Composition offsets (`ctts`) — ISO/IEC 14496-12 §8.6.1.3.
|
||||
//
|
||||
// `ctts` is the ONLY place a reordered (B-frame) track's presentation time
|
||||
// survives the write→read cycle: `stts` carries decode duration and `stss`
|
||||
// carries sync points, so with the composition table wrong or absent every
|
||||
// frame's PTS collapses onto its DTS and the picture order is destroyed.
|
||||
// The writer's builder and this reader's parser must therefore be exact
|
||||
// inverses, over the SIGNED offsets that make it a version-1 box.
|
||||
|
||||
#[test]
|
||||
fn ctts_build_and_parse_are_exact_inverses_over_signed_offsets() {
|
||||
// Includes a negative offset (version 1 only), a repeated run, and a
|
||||
// value repeated non-adjacently — so a parser that loses the sign, drops
|
||||
// the run expansion, or returns a constant cannot agree.
|
||||
let offsets: Vec<i32> = vec![0, 2, -1, -1, 0, 3003];
|
||||
let boxed = crate::mux::mp4::build_ctts(&offsets);
|
||||
|
||||
assert_eq!(&boxed[4..8], b"ctts", "box type");
|
||||
assert_eq!(
|
||||
boxed[8], 1,
|
||||
"signed composition offsets require ctts version 1 (§8.6.1.3)"
|
||||
);
|
||||
// Run-length coalescing: the two adjacent -1s share one entry, so the
|
||||
// six offsets become five runs. Size = 8 hdr + 4 ver/flags + 4 count + 5×8.
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(boxed[0..4].try_into().unwrap()) as usize,
|
||||
8 + 4 + 4 + 5 * 8,
|
||||
"equal ADJACENT offsets coalesce into a single run"
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(boxed[12..16].try_into().unwrap()),
|
||||
5,
|
||||
"entry_count is the run count, not the sample count"
|
||||
);
|
||||
|
||||
// The box header is not part of the parser's input: it takes the payload
|
||||
// from version/flags onward.
|
||||
assert_eq!(parse_ctts(&boxed[8..], MAX_SAMPLE_COUNT), offsets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_frame_presentation_order_survives_the_mp4_round_trip() {
|
||||
// Four samples in DECODE order carrying a classic I-P-B-B reorder: the
|
||||
// second sample presents last. At 25 fps (timescale 25, one tick per
|
||||
// frame) the composition offsets are [0, +2, -1, -1] — negative, so this
|
||||
// exercises the signed version-1 path end to end.
|
||||
//
|
||||
// Before this test the round trip asserted sample sizes and keyframe
|
||||
// flags only, so the entire composition-time chain (VideoTiming::ctts,
|
||||
// build_ctts, parse_ctts) was unconstrained and a demuxed B-frame title
|
||||
// could have presented in decode order with nothing noticing.
|
||||
use crate::disc::{
|
||||
Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream,
|
||||
};
|
||||
use crate::mux::mp4::Mp4Sink;
|
||||
use crate::pes::{PesFrame, Stream as _};
|
||||
use std::io::Cursor;
|
||||
|
||||
const FRAME_NS: i64 = 40_000_000; // 25 fps, exact
|
||||
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![DiscStreamE::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: crate::disc::ColorSpace::Unknown,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})];
|
||||
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE])];
|
||||
|
||||
// (presentation time, payload byte) in decode order.
|
||||
let plan: [(i64, u8); 4] = [
|
||||
(0, 0x10), // I, presents first
|
||||
(3 * FRAME_NS, 0x20), // P, presents last
|
||||
(FRAME_NS, 0x30), // B
|
||||
(2 * FRAME_NS, 0x40), // B
|
||||
];
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
|
||||
for (i, &(pts, fill)) in plan.iter().enumerate() {
|
||||
sink.write(&PesFrame {
|
||||
track: 0,
|
||||
pts,
|
||||
keyframe: i == 0,
|
||||
data: vec![fill; 64 + i * 8],
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
|
||||
let mut rd = Mp4Reader::from_reader(Cursor::new(buf), "reorder".into()).unwrap();
|
||||
let mut got = Vec::new();
|
||||
while let Some(f) = rd.read().unwrap() {
|
||||
got.push((f.pts, f.data[0]));
|
||||
}
|
||||
assert_eq!(got.len(), 4);
|
||||
|
||||
// Frames come back in decode order (sorted by DTS), each still carrying
|
||||
// the presentation time it was written with — identified by payload, so
|
||||
// a reordering of the samples themselves cannot be mistaken for success.
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(0, 0x10),
|
||||
(3 * FRAME_NS, 0x20),
|
||||
(FRAME_NS, 0x30),
|
||||
(2 * FRAME_NS, 0x40),
|
||||
],
|
||||
"composition times must survive the write→read cycle"
|
||||
);
|
||||
|
||||
// The property that matters to a player: presentation order differs from
|
||||
// decode order, and sorting by PTS recovers the display sequence.
|
||||
let mut by_pts = got.clone();
|
||||
by_pts.sort_by_key(|&(pts, _)| pts);
|
||||
assert_eq!(
|
||||
by_pts.iter().map(|&(_, b)| b).collect::<Vec<_>>(),
|
||||
vec![0x10, 0x30, 0x40, 0x20],
|
||||
"display order is I,B,B,P — not the decode order I,P,B,B"
|
||||
);
|
||||
}
|
||||
|
||||
/// A track's declared duration must be its real length. `mdhd.duration` is in
|
||||
/// the MEDIA timescale and `mvhd.duration` in the movie timescale
|
||||
/// (ISO/IEC 14496-12 §8.2.2, §8.4.2); a player uses them to draw the seek bar
|
||||
/// and to decide when the title ends, so a zeroed or constant duration makes
|
||||
/// a correct file unseekable and apparently empty.
|
||||
#[test]
|
||||
fn declared_track_duration_equals_frame_count_times_frame_duration() {
|
||||
use crate::disc::{
|
||||
Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream,
|
||||
};
|
||||
use crate::mux::mp4::Mp4Sink;
|
||||
use crate::pes::{PesFrame, Stream as _};
|
||||
use std::io::Cursor;
|
||||
|
||||
const FRAME_NS: i64 = 40_000_000; // 25 fps, exact
|
||||
const FRAMES: usize = 5;
|
||||
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![DiscStreamE::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: crate::disc::ColorSpace::Unknown,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})];
|
||||
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE])];
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
|
||||
for i in 0..FRAMES {
|
||||
sink.write(&PesFrame {
|
||||
track: 0,
|
||||
pts: i as i64 * FRAME_NS,
|
||||
keyframe: i == 0,
|
||||
data: vec![0x55u8; 128],
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
|
||||
let moov = find_box(&buf, b"moov").expect("moov");
|
||||
|
||||
// mvhd is a version-1 FullBox here: vflags(4) creation(8) modification(8)
|
||||
// timescale(4) duration(8).
|
||||
let mvhd = find_box(moov, b"mvhd").expect("mvhd");
|
||||
assert_eq!(mvhd[0], 1, "mvhd version 1");
|
||||
let movie_ts = be32(mvhd, 20);
|
||||
let movie_dur = u64::from_be_bytes(mvhd[24..32].try_into().unwrap());
|
||||
assert_eq!(movie_ts, 90_000);
|
||||
assert_eq!(
|
||||
movie_dur, 18_000,
|
||||
"5 frames at 25 fps is 0.2 s = 18000 ticks at 90 kHz"
|
||||
);
|
||||
|
||||
// mdhd, same version-1 layout, but in the media timescale the writer chose.
|
||||
let mdhd = find_box(
|
||||
find_box(find_box(moov, b"trak").expect("trak"), b"mdia").expect("mdia"),
|
||||
b"mdhd",
|
||||
)
|
||||
.expect("mdhd");
|
||||
assert_eq!(mdhd[0], 1, "mdhd version 1");
|
||||
let media_ts = be32(mdhd, 20);
|
||||
let media_dur = u64::from_be_bytes(mdhd[24..32].try_into().unwrap());
|
||||
assert_eq!(media_ts, 25, "25 fps snaps to a 25-tick timescale");
|
||||
assert_eq!(
|
||||
media_dur, FRAMES as u64,
|
||||
"duration is one tick per frame in this timescale"
|
||||
);
|
||||
assert_eq!(
|
||||
media_dur as f64 / media_ts as f64,
|
||||
movie_dur as f64 / movie_ts as f64,
|
||||
"the two declared durations must describe the same wall-clock length"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `moov` tree must carry the boxes ISO/IEC 14496-12 makes mandatory for a
|
||||
/// playable track, with the field values the spec fixes. These live in this
|
||||
/// module rather than the writer's because the box-walking helpers
|
||||
/// ([`find_box`]) are here — asserting through them means the test reads the
|
||||
/// file the way the demuxer does, instead of re-deriving the layout.
|
||||
///
|
||||
/// Nothing in the demux path needs `tkhd`/`vmhd`/`smhd`/`dinf`, so an empty
|
||||
/// one of any of them round-trips through this crate unnoticed while making
|
||||
/// the file unplayable elsewhere.
|
||||
#[test]
|
||||
fn moov_tree_carries_the_mandatory_track_header_and_media_boxes() {
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, DiscTitle, FrameRate, HdrFormat, LabelPurpose,
|
||||
Resolution, SampleRate, Stream as DiscStreamE, VideoStream,
|
||||
};
|
||||
use crate::mux::mp4::Mp4Sink;
|
||||
use crate::pes::{PesFrame, Stream as _};
|
||||
use std::io::Cursor;
|
||||
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![
|
||||
DiscStreamE::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: crate::disc::ColorSpace::Unknown,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
}),
|
||||
DiscStreamE::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::Ac3,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
}),
|
||||
];
|
||||
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE]), None];
|
||||
|
||||
let ac3 = vec![
|
||||
0x0B,
|
||||
0x77,
|
||||
0x00,
|
||||
0x00,
|
||||
0b00_010110,
|
||||
0b01000_000,
|
||||
0b111_00_00_1,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xFF,
|
||||
];
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
|
||||
for i in 0..4i64 {
|
||||
sink.write(&PesFrame {
|
||||
track: 0,
|
||||
pts: i * 40_000_000,
|
||||
keyframe: i == 0,
|
||||
data: vec![0x55u8; 128],
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
})
|
||||
.unwrap();
|
||||
sink.write(&PesFrame {
|
||||
track: 1,
|
||||
pts: i * 32_000_000,
|
||||
keyframe: true,
|
||||
data: ac3.clone(),
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
|
||||
let moov = find_box(&buf, b"moov").expect("moov");
|
||||
let traks = find_boxes_capped(moov, b"trak", usize::MAX);
|
||||
assert_eq!(traks.len(), 2, "one video trak + one audio trak");
|
||||
|
||||
// ── tkhd (§8.3.2). Mandatory in every trak. flags bit 0 = track_enabled;
|
||||
// a track with flags 0 is ignored by a conforming player. Width/height are
|
||||
// 16.16 fixed point.
|
||||
let vid_tkhd = find_box(traks[0], b"tkhd").expect("video tkhd");
|
||||
assert_eq!(vid_tkhd[0], 1, "tkhd version 1");
|
||||
let flags = u32::from_be_bytes([0, vid_tkhd[1], vid_tkhd[2], vid_tkhd[3]]);
|
||||
assert_eq!(flags & 0x1, 0x1, "track_enabled must be set");
|
||||
assert_eq!(be32(vid_tkhd, 20), 1, "first track_id is 1");
|
||||
assert_eq!(
|
||||
be32(vid_tkhd, 88) >> 16,
|
||||
1920,
|
||||
"tkhd width is 16.16 fixed point"
|
||||
);
|
||||
assert_eq!(be32(vid_tkhd, 92) >> 16, 1080, "tkhd height");
|
||||
|
||||
let aud_tkhd = find_box(traks[1], b"tkhd").expect("audio tkhd");
|
||||
assert_eq!(be32(aud_tkhd, 20), 2, "second track_id is 2");
|
||||
assert_eq!(
|
||||
be16(aud_tkhd, 48),
|
||||
0x0100,
|
||||
"an audio track's tkhd volume is 1.0 (8.8 fixed), not muted"
|
||||
);
|
||||
assert_eq!(
|
||||
(be32(aud_tkhd, 88), be32(aud_tkhd, 92)),
|
||||
(0, 0),
|
||||
"a sound track declares zero visual dimensions"
|
||||
);
|
||||
|
||||
// ── minf media headers: vmhd for video, smhd for audio (§12.1.2, §12.2.2).
|
||||
// Exactly one of them, and never the wrong one for the handler.
|
||||
let minf = |trak: &[u8]| -> Vec<u8> {
|
||||
find_box(find_box(trak, b"mdia").expect("mdia"), b"minf")
|
||||
.expect("minf")
|
||||
.to_vec()
|
||||
};
|
||||
let vid_minf = minf(traks[0]);
|
||||
let aud_minf = minf(traks[1]);
|
||||
|
||||
let vmhd = find_box(&vid_minf, b"vmhd").expect("video minf must carry vmhd");
|
||||
assert!(
|
||||
find_box(&vid_minf, b"smhd").is_none(),
|
||||
"a video minf must not carry smhd"
|
||||
);
|
||||
// §12.1.2 fixes vmhd flags to 1.
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([0, vmhd[1], vmhd[2], vmhd[3]]),
|
||||
1,
|
||||
"vmhd flags must be 1"
|
||||
);
|
||||
assert_eq!(
|
||||
(be16(vmhd, 4), &vmhd[6..12]),
|
||||
(0u16, &[0u8; 6][..]),
|
||||
"graphicsmode 0 (copy) with a zero opcolor"
|
||||
);
|
||||
|
||||
let smhd = find_box(&aud_minf, b"smhd").expect("audio minf must carry smhd");
|
||||
assert!(
|
||||
find_box(&aud_minf, b"vmhd").is_none(),
|
||||
"an audio minf must not carry vmhd"
|
||||
);
|
||||
assert_eq!(be16(smhd, 4), 0, "smhd balance is centre");
|
||||
|
||||
// ── dinf > dref > "url " with flags 1 = media is in THIS file (§8.7.2).
|
||||
// Both tracks need it; a missing/empty dref makes the samples unreachable.
|
||||
for (name, m) in [("video", &vid_minf), ("audio", &aud_minf)] {
|
||||
let dinf = find_box(m, b"dinf").unwrap_or_else(|| panic!("{name} dinf"));
|
||||
let dref = find_box(dinf, b"dref").unwrap_or_else(|| panic!("{name} dref"));
|
||||
assert_eq!(be32(dref, 4), 1, "{name} dref entry_count");
|
||||
let url = find_box(&dref[8..], b"url ").unwrap_or_else(|| panic!("{name} url "));
|
||||
// `url ` payload is version(1)+flags(3) only when self-contained.
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([0, url[1], url[2], url[3]]),
|
||||
1,
|
||||
"{name} url flags=1 (self-contained), so no external name follows"
|
||||
);
|
||||
assert_eq!(
|
||||
url.len(),
|
||||
4,
|
||||
"{name} self-contained url carries no location"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Untrusted-input hardening: a crafted MP4 must never panic or over-allocate.
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user