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:
+80
-12
@@ -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();
|
||||
|
||||
+1
-1
@@ -1036,7 +1036,7 @@ mod tests {
|
||||
/// Recording `SectorSource`: logs every `(lba, count)` request and
|
||||
/// returns `Err` whenever the requested range covers `bad_sector`.
|
||||
/// Successful reads return zeroed sectors (which are NOT
|
||||
/// `is_aacs_scrambled`, so `DecryptingSectorSource` passes them through
|
||||
/// `ts_sync_destroyed`, so `DecryptingSectorSource` passes them through
|
||||
/// even with synthetic AACS keys — no real decrypt is attempted).
|
||||
struct RecordingReader {
|
||||
capacity: u32,
|
||||
|
||||
+47
-1
@@ -245,7 +245,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
}
|
||||
|
||||
/// Options for opening an input stream.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct InputOptions {
|
||||
/// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc
|
||||
/// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the
|
||||
@@ -257,6 +257,26 @@ pub struct InputOptions {
|
||||
pub title_index: Option<usize>,
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub raw: bool,
|
||||
/// Optional fresh-key-on-failure closure (a shared [`crate::sector::KeyFetch`]).
|
||||
/// `None` (default) keeps the prior behaviour: a unit no held key decrypts is
|
||||
/// counted as decrypt loss. When set, the mux installs it (cloned `Arc`) so a
|
||||
/// still-scrambled unit is re-tried via the application's key source.
|
||||
/// Application seam only; the library makes no network call.
|
||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
||||
}
|
||||
|
||||
// `KeyFetchFactory` holds a trait object that is not `Debug`; hand-roll the
|
||||
// impl (the prior derive is preserved for every other field) so `InputOptions`
|
||||
// stays printable without dumping key material.
|
||||
impl std::fmt::Debug for InputOptions {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("InputOptions")
|
||||
.field("unit_keys", &self.unit_keys.len())
|
||||
.field("title_index", &self.title_index)
|
||||
.field("raw", &self.raw)
|
||||
.field("key_fetch", &self.key_fetch.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a PES input stream (produces PES frames).
|
||||
@@ -381,6 +401,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
} else {
|
||||
keys
|
||||
};
|
||||
// Install the shared fetch closure (if the app supplied one) so a
|
||||
// unit no held key decrypts is re-tried via the app's key source.
|
||||
// Suppressed in --raw (no decrypt step to recover).
|
||||
let fetch = if opts.raw {
|
||||
None
|
||||
} else {
|
||||
opts.key_fetch.clone()
|
||||
};
|
||||
let stream = build_iso_pipeline(
|
||||
reader,
|
||||
title,
|
||||
@@ -389,6 +417,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
format,
|
||||
None,
|
||||
None,
|
||||
fetch,
|
||||
)?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
@@ -573,6 +602,13 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||
/// - `fetch`: optional fresh-key-on-failure callback (see
|
||||
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
|
||||
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
|
||||
/// returns. `None` keeps the prior behaviour (the unit is counted as loss).
|
||||
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
||||
// point; grouping them into a struct would only move the same fields around.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
reader: S,
|
||||
title: DiscTitle,
|
||||
@@ -581,6 +617,7 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
format: ContentFormat,
|
||||
halt: Option<crate::halt::Halt>,
|
||||
event_fn: Option<crate::sector::prefetched::EventFn>,
|
||||
fetch: Option<crate::sector::KeyFetch>,
|
||||
) -> io::Result<PipelinedPesStream> {
|
||||
let extents = title.extents.clone();
|
||||
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
|
||||
@@ -594,6 +631,12 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
};
|
||||
let mut decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||
// Install the fresh-key-on-failure callback (if any) so a unit no held key
|
||||
// decrypts is re-tried via the application's key source before being counted
|
||||
// as loss.
|
||||
if let Some(cb) = fetch {
|
||||
decrypting = decrypting.with_key_fetch(cb);
|
||||
}
|
||||
// Grab the decrypt-loss counter before the decorator is moved into the
|
||||
// producer thread. It tracks bytes of scrambled AACS units no key could
|
||||
// decrypt — silent loss the demux drops; the consuming stream surfaces it
|
||||
@@ -1116,6 +1159,7 @@ mod tests {
|
||||
ContentFormat::BdTs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("pipeline builds");
|
||||
let first = stream.read().expect("read must not error on clean EOF");
|
||||
@@ -1156,6 +1200,7 @@ mod tests {
|
||||
ContentFormat::BdTs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("pipeline builds");
|
||||
|
||||
@@ -1207,6 +1252,7 @@ mod tests {
|
||||
ContentFormat::BdTs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(res.is_err(), "zero batch_sectors must be rejected");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user