audit: bound DTS marks, align disc-format tree order, doc/test cleanups

Round-7 findings from the 10-phase release audit (no HIGH; convergence):

- Cap DtsParser.pts_marks (MAX_PTS_MARKS): a run of zero-length timed PES
  packets grew no buffer bytes, so the drain_front mark-prune never ran —
  the deque could accumulate without bound on hostile PS input.
- detect_disc_format tested HVDVD_TS before BDMV while the title-scan
  dispatch tests BDMV first, so a disc with both trees would be classified
  HD-DVD but enumerated as Blu-ray. Align both to BDMV → HVDVD_TS →
  VIDEO_TS.
- Document why the DTS new-PES re-base can emit a locally-decreasing PTS
  (the muxer's block_ts applies the strictly-monotonic audio nudge, tested
  in mkv.rs) — this is by design, not a mux defect.
- Fix stale aacs/keys.rs comment references (functions moved to
  aacs/inf.rs / aacs::resolve/derive in the module split).
This commit is contained in:
Matthew Jackson
2026-07-09 19:01:56 -07:00
parent 7d852419b5
commit 92e3b41468
5 changed files with 53 additions and 12 deletions
+3 -3
View File
@@ -1345,7 +1345,7 @@ mod tests {
}
#[test]
fn stride_v10_is_48_v20_is_64_and_picks_distinct_keys() {
// AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (keys.rs:30-35).
// AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (aacs/inf.rs).
// Lay keys at 64-byte stride. Parsing at V20 stride must pick exactly
// those keys; parsing the SAME bytes at V10 (48) stride would read the
// wrong (intermediate) bytes for key 2 onward — proving the stride
@@ -1447,7 +1447,7 @@ mod tests {
}
#[test]
fn parse_unit_key_ro_cps_unit_numbers_are_1_based() {
// The disc's CPS unit numbers are emitted as (i+1) — keys.rs:162.
// The disc's CPS unit numbers are emitted as (i+1) — aacs/inf.rs.
let data = build_unit_key_ro(3, 48);
let p = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert_eq!(
@@ -1936,7 +1936,7 @@ mod tests {
//
// The rc.6 E7017/E7022 split is also exercised end-to-end through the
// `ensure_decryptable` gate in `disc/mod.rs`. These tests pin the
// *classifier* directly at the keys.rs seam and cover the branches the
// *classifier* directly at the aacs::resolve seam and cover the branches the
// gate test does not: VID-present (must never be VidUnavailable), the
// processing-keys-only material path, and the version dispatch / Ok path.
+1 -1
View File
@@ -889,7 +889,7 @@ mod tests {
fn mkb_records_matches_walk_mkb_framing() {
// The lazy `mkb_records` iterator and the owning `walk_mkb` must agree on
// (offset, type, len) for every record — they share the one framing
// walker, and every keys.rs MKB walk now relies on this equivalence.
// walker, and every aacs::resolve/derive MKB walk now relies on this equivalence.
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]);
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xFF]); // terminator + trailing
+2 -2
View File
@@ -572,7 +572,7 @@ mod tests {
}
/// A content certificate: type byte@0 (0x00 = V10, else V20),
/// bus_encryption bit7@1, cc_id@14..20 (aacs/keys.rs parse_content_cert,
/// bus_encryption bit7@1, cc_id@14..20 (aacs/inf.rs parse_content_cert,
/// which requires ≥20 bytes and reads the bus flag from `data[1] >> 7`).
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
let mut v = vec![0u8; 20];
@@ -584,7 +584,7 @@ mod tests {
/// An MKB with one Type-and-Version record (type 0x10) carrying the
/// version as BE u32 at record offset 8, followed by a recorded EOF
/// record then trailing zero padding. mkb_content_len walks records
/// and stops at the first padding (type 0) byte (aacs/keys.rs).
/// and stops at the first padding (type 0) byte (aacs/inf.rs).
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
let mut v = Vec::new();
// Type 0x10 record, length 16 (>= 12 so version is read).
+9 -6
View File
@@ -2129,12 +2129,9 @@ impl Disc {
titles: &[DiscTitle],
) -> DiscFormat {
use crate::aacs::mkb::{AacsVersion, mkb_type};
if udf_fs.find_dir("/HVDVD_TS").is_some() {
return DiscFormat::HdDvd;
}
if udf_fs.find_dir("/VIDEO_TS").is_some() {
return DiscFormat::Dvd;
}
// Tree priority MUST match the title-scan dispatch (BDMV → HVDVD_TS →
// VIDEO_TS): otherwise a disc carrying two trees would be classified as
// one format but enumerated as another (e.g. BD titles tagged HdDvd).
if udf_fs.find_dir("/BDMV").is_some() {
// Only the Type-and-Version record (first record) is needed.
if let Ok(mkb) = udf_fs.read_file_prefix(reader, "/AACS/MKB_RO.inf", 64) {
@@ -2151,6 +2148,12 @@ impl Disc {
other => other,
};
}
if udf_fs.find_dir("/HVDVD_TS").is_some() {
return DiscFormat::HdDvd;
}
if udf_fs.find_dir("/VIDEO_TS").is_some() {
return DiscFormat::Dvd;
}
DiscFormat::Unknown
}
+38
View File
@@ -140,6 +140,12 @@ impl DtsParser {
/// this without a clean boundary we resync rather than stall or balloon.
const MAX_AU_BYTES: usize = 65536;
/// Cap on buffered PTS marks. A real AU spans a few PES; this bounds the deque so
/// a run of zero-length timed PES packets (which grow no buffer bytes, so the
/// `drain_front` prune never fires) cannot accumulate marks without bound on
/// hostile program-stream input.
const MAX_PTS_MARKS: usize = 64 * 1024;
/// Number of leading bytes that must be buffered before the core `fsize` field
/// (bytes 5-7) can be decoded. This is a HEADER-LAYOUT minimum — "enough bytes
/// to read the size field" — and is deliberately distinct from
@@ -231,6 +237,14 @@ impl CodecParser for DtsParser {
// that core's timestamp even when its extensions / the following core
// arrive (with a later PTS) in this same parse() call.
self.pts_marks.push((self.buf.len(), pts_ns));
// Backstop: a run of zero-length (sub-header-only) PES packets that each
// carry a PTS grows no buffer bytes, so `drain_front` (which prunes marks)
// never runs. Bound the deque directly — drop the oldest, which belongs to
// an already-emitted or lost AU — so hostile PS input can't accumulate
// marks without bound.
if self.pts_marks.len() > MAX_PTS_MARKS {
self.pts_marks.remove(0);
}
self.buf.extend_from_slice(&pes.data);
let mut frames = Vec::new();
@@ -915,6 +929,22 @@ mod tests {
assert_eq!(dts_core_sample_rate(&core), 48_000);
}
#[test]
fn pts_marks_stay_bounded_on_zero_length_pes() {
// A run of zero-length (sub-header-only) DTS PES packets that each carry a
// PTS grows no buffer bytes, so drain_front (which prunes marks) never
// runs. The MAX_PTS_MARKS backstop must bound the deque regardless.
let mut parser = DtsParser::new();
for i in 0..(MAX_PTS_MARKS * 2) {
parser.parse(&make_pes(Vec::new(), Some(i as i64)));
}
assert!(
parser.pts_marks.len() <= MAX_PTS_MARKS,
"pts_marks bounded, got {}",
parser.pts_marks.len()
);
}
#[test]
fn new_pes_rebases_to_its_own_pts_no_drift() {
// Regression for the drift bug: a global running clock overshot a
@@ -922,6 +952,14 @@ mod tests {
// PES arrives whose PTS is BEHIND where accumulated frame durations
// would put a running clock, the AU must re-base to that PES's OWN
// timestamp — tracking the container, not drifting ahead of it.
//
// The re-base can make one emitted PTS sit just below the previous AU's
// (a fresh PES whose PTS lands under the within-PES cursor). That is
// CORRECT here and is NOT a muxer defect: the parser reports the true
// container timestamps, and the mkv muxer applies the strictly-monotonic
// per-track nudge to AUDIO at emit time (`mkv::block_ts` / `monotonic_ts`,
// tested in `mkv.rs`), so the written block DTS is always monotonic. The
// alternative — clamping in the parser — is what reintroduced the drift.
let mut parser = DtsParser::new();
// PES A: core1 + core2 (2 frames), pts 90000.
let mut pes_a = make_dts_core(512);