verify: post-read decrypt-verify gate + libaacs-strict verify + audit fixes

Post-read verify gate (new src/disc/verify.rs): UnitVerifier buffers/aligns the disc-absolute read stream into clip-file 6144-byte units, then makes one decryptability() decision per unit (CPI gate -> held keys -> key_fetch -> strict TS). POST_READ_VERIFY const kill-switch; fail-safe contract (only ever downgrades units it is confident are undecryptable; every doubt skips). Hooked into Disc::sweep (producer observes ciphertext -> WorkItem::MarkBad after the Good, FIFO-ordered) and Disc::patch (post-loop reverify_iso reads recovered units whole from the patched ISO). extract::clip_layouts enumerates AACS clips for the gate.

Standards-correct AACS verify: aacs::unit_is_clean_ts is a strict port of libaacs _verify_ts (all 32 TS syncs, not a majority vote); decrypt_unit accepts a key only on it; the majority verify_ts is removed. Deleted the Disc::verify_clips post-pass bolt-on (its primitive is absorbed by the read-path gate).

libaacs/DVD audit fixes: content-cert bus_encryption flag now read from bit 7 (was bit 0 - defeated the bus-key fail-loud gate); cc_id read from offset 14; title_cps_unit range-validated + 1->0 index-converted per libaacs. Corrected attack_crib ("functionally-equivalent" not "exact" port) and read_disc_key (READ DVD STRUCTURE 0xAD, not REPORT KEY) doc comments.

Also includes accumulated uncommitted work: key-fetch seam and TrueHD/DTS audio fix.
This commit is contained in:
Matthew Jackson
2026-06-28 15:03:52 -07:00
parent f49ef023cf
commit a7bd574c34
23 changed files with 3098 additions and 207 deletions
+80 -12
View File
@@ -13,6 +13,7 @@
//! We skip AC-3 frames and only emit TrueHD access units.
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS;
/// Duration of one TrueHD access unit in nanoseconds for the 48 kHz family
/// (48 / 96 / 192 kHz). `access_unit_size = 40 << (ratebits & 7)` and
@@ -138,18 +139,47 @@ impl CodecParser for TrueHdParser {
// the next PES legitimately begins a new AU and seeds the base.
if self.buf.is_empty() {
if let Some(pts) = pes.pts {
// Resync to the authoritative PES PTS, but NEVER snap backward.
// TrueHD AUs are a fixed sample count (40 @ 48 kHz), so the
// per-AU `+AU_DURATION_NS` cadence is sample-accurate — more so
// than the disc's per-PES PTS, which carries the source muxer's
// own rounding jitter. When the buffer empties exactly on a PES
// boundary and that PES's PTS lands a few ticks *below* the
// running cadence, an unconditional reset would set the next
// AU's timestamp below the AU just emitted, producing the
// non-monotonic block timestamps a muxer rejects. Clamp to the
// running position so output stays strictly monotonic; a
// genuine forward gap/discontinuity is still adopted.
self.next_pts_ns = self.next_pts_ns.max(pts_to_ns(pts));
// Resync to the authoritative PES PTS. TrueHD AUs are a fixed
// sample count (40 @ 48 kHz), so the per-AU `+AU_DURATION_NS`
// cadence is sample-accurate — more so than the disc's per-PES
// PTS, which carries the source muxer's own rounding jitter.
//
// Two distinct backward steps must be handled OPPOSITELY:
//
// 1. Small backward jitter (sub-second PES rounding): when the
// buffer empties exactly on a PES boundary and that PES's PTS
// lands a few ticks *below* the running cadence, an
// unconditional reset would set the next AU's timestamp below
// the AU just emitted, producing non-monotonic block
// timestamps a muxer rejects. CLAMP to the running position so
// output stays strictly monotonic.
//
// 2. Large backward step (> DISCONTINUITY_BACKSTEP_NS): this is a
// clip-boundary PTS reset — the title's clips are read as one
// concatenated stream and a non-seamless boundary resets the
// source PES PTS near zero. This is NOT jitter and must NOT be
// clamped: clamping strands the audio at the previous clip's
// tail cadence, so when `TimelineContinuity` later bumps the
// global offset for the new epoch (driven by the video
// back-jump) the stranded-high audio PTS is flung ~a whole
// clip past the frontier, producing the non-monotonic
// audio-DTS band on multi-clip titles (Dune: Part Two, Top
// Gun). ADOPT the raw reset so the per-track raw PTS that
// reaches `TimelineContinuity` carries the true boundary, and
// the corrector rebases it exactly as it already does for the
// DTS / AC-3 parsers (which never clamp). Same threshold the
// timeline corrector uses to classify a discontinuity.
//
// A genuine forward gap/discontinuity is always adopted by the
// `.max()`.
let new = pts_to_ns(pts);
if new < self.next_pts_ns - DISCONTINUITY_BACKSTEP_NS {
// Clip-boundary reset: take the raw PTS, restart the cadence.
self.next_pts_ns = new;
} else {
// Within-clip jitter (or forward progression): stay monotonic.
self.next_pts_ns = self.next_pts_ns.max(new);
}
}
}
@@ -490,6 +520,44 @@ mod tests {
);
}
#[test]
fn clip_boundary_pts_reset_is_adopted_not_clamped() {
// Regression (Dune: Part Two / Top Gun non-monotonic audio-DTS band):
// a title's clips are read as one concatenated stream, so at a
// non-seamless boundary the source PES PTS resets near zero — a LARGE
// backward step (> DISCONTINUITY_BACKSTEP_NS), NOT muxer jitter. The
// parser must ADOPT that reset (restart the cadence at the raw PTS), the
// same way the DTS / AC-3 parsers pass raw PTS through, so the per-track
// raw PTS reaching TimelineContinuity carries the true boundary and the
// corrector can rebase it. Clamping it forward (the old `.max()`) stranded
// the audio at the previous clip's tail; when the global offset later
// bumped for the new epoch the stranded audio was flung ~a clip past the
// frontier — the non-monotonic band.
let mut parser = TrueHdParser::new();
let au = make_truehd_unit(100);
// Clip 1: an AU at PES PTS = 10s (90000 ticks/s → 900_000 ticks). Buffer
// empties, so the next PES seeds a fresh base.
let clip1_pts = 90_000 * 10; // 10 s in 90 kHz ticks
let f1 = parser.parse(&make_pes(au.clone(), Some(clip1_pts)));
assert_eq!(f1.len(), 1);
let last1 = f1[0].pts_ns;
assert_eq!(last1, pts_to_ns(clip1_pts));
// Clip 2: PES PTS resets to 0 — 10 s backward, far beyond the 3 s
// discontinuity threshold. Must be adopted, not clamped to the cadence.
let f2 = parser.parse(&make_pes(au.clone(), Some(0)));
assert_eq!(f2.len(), 1);
assert_eq!(
f2[0].pts_ns, 0,
"clip-boundary PTS reset must be adopted raw (got {}, expected the \
reset value 0 — clamping to the previous clip's cadence is the bug)",
f2[0].pts_ns
);
assert!(
f2[0].pts_ns < last1,
"the reset frame must land below the previous clip's tail, not above it"
);
}
#[test]
fn skip_interleaved_ac3() {
let mut parser = TrueHdParser::new();