audit: clamp BD format fallback, running GOP byte counter, O(1) DTS marks

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

- detect_disc_format's BDMV fallback passed detect_format's result
  through unchanged, so an SD bonus/menu title could tag a BD-tree disc as
  DVD (mis-sizing the ECC sweep) — violating its own "never below Blu-ray"
  invariant. Clamp anything but UHD up to Blu-ray.
- Track the MPEG-2 GOP byte total incrementally instead of re-summing the
  whole gop_buf on every pushed picture (was O(pictures²) on any MPEG-2
  disc, not just adversarial input).
- Back the DTS pts_marks deque with a VecDeque so the over-cap prune is an
  O(1) pop_front, not an O(n) Vec::remove(0).
- Add a test exercising parse_stream_id_extension's PTS/DTS skip branches
  (the real AU-opening 0xFD video PES path) — previously untested.
This commit is contained in:
Matthew Jackson
2026-07-09 19:27:01 -07:00
parent 92e3b41468
commit 6a0e61d415
4 changed files with 59 additions and 10 deletions
+9 -2
View File
@@ -110,6 +110,10 @@ pub struct Mpeg2Parser {
/// without ever reordering emitted blocks (B-frames keep decode order; only
/// their PTS is lower).
gop_buf: Vec<BufferedPicture>,
/// Running total of `data` bytes buffered in `gop_buf` — the byte-cap counter,
/// incremented on each push and reset when the GOP flushes. Avoids re-summing
/// the whole buffer per picture (which would be O(pictures²)).
gop_bytes: usize,
/// Total field-display periods of all frames already emitted, in display
/// order — the running base for each new frame's display time.
emitted_fields: u64,
@@ -148,6 +152,7 @@ impl Mpeg2Parser {
frame_duration_ns: 0,
progressive_sequence: false,
gop_buf: Vec::new(),
gop_bytes: 0,
emitted_fields: 0,
origin_pts_ns: None,
}
@@ -241,6 +246,7 @@ impl Mpeg2Parser {
if gop_boundary && !self.gop_buf.is_empty() {
self.flush_gop(out);
}
self.gop_bytes += data.len();
self.gop_buf.push(BufferedPicture {
tr,
info,
@@ -262,8 +268,7 @@ impl Mpeg2Parser {
// unbounded. Force-flush a pathologically long run as its own GOP —
// bounded by BOTH the frame count and the total buffered bytes, so a
// crafted stream of few-but-huge pictures cannot over-allocate either.
let gop_bytes: usize = self.gop_buf.iter().map(|p| p.frame.data.len()).sum();
if self.gop_buf.len() >= MAX_PENDING_FRAMES || gop_bytes >= MAX_PENDING_BYTES {
if self.gop_buf.len() >= MAX_PENDING_FRAMES || self.gop_bytes >= MAX_PENDING_BYTES {
self.flush_gop(out);
}
}
@@ -281,6 +286,8 @@ impl Mpeg2Parser {
if n == 0 {
return;
}
// The GOP is fully drained below; reset the running byte counter.
self.gop_bytes = 0;
let field_period = self.frame_duration_ns / 2;
if field_period <= 0 {
// No sequence header / frame rate yet (malformed lead-in): emit in