audit: guard 0xFD video routing, carry frame duration, add cap tests

Round-5 findings from the 10-phase release audit:

- collect_es routed EVERY extended-stream-id (0xFD) PES into the video ES
  buffer, so a 0xFD HD-audio sub-stream (MLP/TrueHD) could pollute the
  video sample and — if it preceded the video PES — stamp the video track
  with the audio PID, losing the video. Only the VC-1 extension (0x55) is
  now treated as video; routing 0xFD audio to its own track is deferred to
  the HD-DVD program-chain follow-up.
- The sparse-PTS reorder now carries its calibrated per-frame duration onto
  each frame, so the muxer emits a BlockDuration and the back-patched
  Segment Duration covers the final frame instead of understating it.
- Add regression tests for the MAX_MARKS and MAX_VTI_HITS caps (promote
  MAX_VTI_HITS to module scope); make the differential-test factory array a
  named type; drop an identity-op in a reorder test.
This commit is contained in:
Matthew Jackson
2026-07-09 17:59:04 -07:00
parent c81a6e05cd
commit 9066433c29
3 changed files with 74 additions and 11 deletions
+44 -7
View File
@@ -61,6 +61,11 @@ const HDDVD_VTI_MAGIC: &[u8] = b"ADVANCED-VTS";
/// shares one residue modulo this stride — the signal used to isolate the table.
const VTI_CLIP_ENTRY_STRIDE: usize = 0x140;
/// Cap on clip-name hits collected from a VTI. A real clip table holds a few
/// dozen entries; this bounds the scan so a crafted VTI packed with millions of
/// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory.
const MAX_VTI_HITS: usize = 8192;
/// Parse the clip-name table from an `ADVANCED-VTS` VTI, returning clip
/// filenames in authored (table) order.
///
@@ -73,10 +78,6 @@ fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
if !vti.starts_with(HDDVD_VTI_MAGIC) {
return Vec::new();
}
// A real VTI clip table holds a few dozen entries; cap the collected hits so
// a crafted VTI packed with millions of `.EVO` tokens (up to the 64 MiB UDF
// read cap) can't burn CPU or memory during a routine scan.
const MAX_VTI_HITS: usize = 8192;
let is_name_byte = |b: u8| b.is_ascii_graphic();
// Bucket hits by residue-mod-stride in a SINGLE pass — the clip table shares
// one residue, so the largest bucket is it (avoids an O(stride*hits) rescan).
@@ -269,11 +270,28 @@ fn collect_es(
) {
use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX};
const EXTENDED_STREAM_ID: u8 = 0xFD;
/// VC-1 video rides extended-stream-id `0xFD` with `stream_id_extension`
/// `0x55`. HD audio (MLP/TrueHD) can also use `0xFD` with other extensions —
/// routing those to their own audio tracks is deferred (see the HD-DVD
/// program-chain follow-up); until then only the VC-1 extension is treated as
/// video, so an audio `0xFD` sub-stream can never mis-stamp the video PID.
const VC1_STREAM_ID_EXT: u8 = 0x55;
// Whether this packet is the VC-1 video sub-stream of the 0xFD extended id.
let is_vc1_ext =
pkt.stream_id == EXTENDED_STREAM_ID && pkt.sub_stream_id == Some(VC1_STREAM_ID_EXT);
match pkt.stream_id {
// Plain MPEG video (0xE0-0xEF), or the HD-DVD extended-stream-id (0xFD)
// that carries VC-1 video. Both feed the single video ES sample; the
// Plain MPEG video (0xE0-0xEF), or the VC-1 sub-stream of the HD-DVD
// extended-stream-id (0xFD). Both feed the single video ES sample; the
// routing PID comes from `PsPacket::dvd_pid` so it matches the demuxer.
VIDEO..=VIDEO_MAX | EXTENDED_STREAM_ID => {
VIDEO..=VIDEO_MAX => {
if video_pid.is_none() {
*video_pid = pkt.dvd_pid();
}
if video.len() < EVO_ES_SAMPLE_CAP {
video.extend_from_slice(&pkt.data);
}
}
EXTENDED_STREAM_ID if is_vc1_ext => {
if video_pid.is_none() {
*video_pid = pkt.dvd_pid();
}
@@ -546,6 +564,25 @@ mod tests {
assert!(parse_vti_clip_order(b"not a vti").is_empty());
}
#[test]
fn parse_vti_clip_order_caps_hits_on_a_crafted_vti() {
// A crafted VTI packed with far more than MAX_VTI_HITS `.EVO` tokens must
// not scan/collect them all (a CPU/memory amplification on a routine
// scan). The result is capped, and parsing stays fast.
let mut vti = Vec::with_capacity(1_000_000);
vti.extend_from_slice(HDDVD_VTI_MAGIC);
// ~160k tokens of the form "X.EVO\0" — well over the 8192 cap.
for _ in 0..(MAX_VTI_HITS * 20) {
vti.extend_from_slice(b"X.EVO\0");
}
let out = parse_vti_clip_order(&vti);
assert!(
out.len() <= MAX_VTI_HITS,
"collected hits capped at MAX_VTI_HITS, got {}",
out.len()
);
}
#[test]
fn parse_vti_clip_order_is_deterministic_on_a_bucket_size_tie() {
// Two residue buckets of EQUAL size must resolve to the SAME winner every
+25 -3
View File
@@ -466,7 +466,7 @@ mod tests {
fn au(payload: u8, len: usize) -> Vec<u8> {
let mut v = AUD.to_vec();
v.extend(std::iter::repeat(payload).take(len));
v.extend(std::iter::repeat_n(payload, len));
v
}
@@ -598,7 +598,7 @@ mod tests {
fn bdu(ty: u8, payload: u8, len: usize) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01, ty];
v.extend(std::iter::repeat(payload).take(len));
v.extend(std::iter::repeat_n(payload, len));
v
}
@@ -762,7 +762,8 @@ mod tests {
};
// (label, stream, assembler factory). MPEG-2 uses the dedicated mpeg2()
// assembler (Mode::Mpeg2); the AUD/VC-1 codecs use for_codec().
let cases: [(&str, &[u8], fn() -> AuAssembler); 3] = [
type MakeAsm = fn() -> AuAssembler;
let cases: [(&str, &[u8], MakeAsm); 3] = [
("h264", &h264, || AuAssembler::for_codec(Codec::H264)),
("vc1", &vc1, || AuAssembler::for_codec(Codec::Vc1)),
("mpeg2", &mpeg2, AuAssembler::mpeg2),
@@ -780,6 +781,27 @@ mod tests {
}
}
#[test]
fn marks_deques_stay_bounded_on_zero_length_timed_fragments() {
// A run of zero-length fragments that each carry a PTS (or a
// discontinuity) grows no buffer bytes, so the buf-size cap never prunes
// the mark deques. The MAX_MARKS backstop must bound them regardless.
let mut a = AuAssembler::for_codec(Codec::H264);
for i in 0..(MAX_MARKS * 2) {
a.push(&[], Some(i as i64), None, None, true);
}
assert!(
a.marks.len() <= MAX_MARKS,
"marks bounded at MAX_MARKS, got {}",
a.marks.len()
);
assert!(
a.disc_marks.len() <= MAX_MARKS,
"disc_marks bounded at MAX_MARKS, got {}",
a.disc_marks.len()
);
}
#[test]
fn over_cap_without_boundary_force_flushes() {
let mut a = AuAssembler::for_codec(Codec::H264);
+5 -1
View File
@@ -190,6 +190,10 @@ impl SparsePtsReorder {
let mut out = Vec::with_capacity(pend.len());
for (mut p, didx) in pend.into_iter().zip(dispidx) {
p.frame.pts_ns = origin + didx * dur;
// Carry the calibrated per-frame duration so the muxer emits a
// BlockDuration and the back-patched Segment Duration covers the
// final frame (the source gives no duration on this path).
p.frame.duration_ns = Some(dur as u64);
out.push(p.frame);
}
// Next GOP with no anchor continues after this one's last display slot.
@@ -295,7 +299,7 @@ mod tests {
// GOP 1 decode order I P B P B -> display indices 0 2 1 4 3 -> PTS:
assert_eq!(
&got[0..5],
&[0, 2 * dur, 1 * dur, 4 * dur, 3 * dur],
&[0, 2 * dur, dur, 4 * dur, 3 * dur],
"GOP1 display PTS in decode order"
);
// GOP 2 re-locks origin to 5*dur.