Harden mux + decrypt paths; fail-loud on unresolvable keys

mp4 demuxer (untrusted input): bound every allocation sized from a box
field (stsz/stco/stsc counts, stts/ctts run-lengths, per-sample and moov
sizes, plus an absolute cap so a sparse file can't inflate file_len);
guard the parse_stsd slice and a zero mdhd timescale; cap track count so
the per-track PID can't overflow; rewrite read_moov to handle size==0 /
size<8 / 64-bit largesize; parse esds/AudioSpecificConfig for AAC; write
tkhd duration in the movie timescale.

decrypt: resolve_mux_key_map now fails loud on an extent no key can
classify instead of inheriting the previous extent's key, so a keymap
never silently carries a wrong key; the sweep/patch key-fetch recovery
fails loud when a unit is still unresolved after the retry.

AACS: reject inverted forensic segments in both range builders; compare
the forensic index in u16 space so an out-of-range value can't truncate
onto a valid u8 index. RECOVERED_ERROR no longer latches the damage zone,
preserving the 30s wedge cooldown for a following hard error.

audio: AAC/MP2/MP3/FLAC carry the last PTS across a PES with no timestamp;
the DTS-HD extension-sync search is bounded to after the core; the MP4
16.16 sample-rate field saturates. demux_sink records the video reference
before the kind filter so audio:// / sub:// keep multi-clip PTS continuity
and the DELAY tag.

Remove a dead error variant and the AACS-unsupported-video code; codec
comments cite the primary format specs; assorted doc/naming fixes and
regression tests throughout.
This commit is contained in:
Matthew Jackson
2026-07-23 12:02:43 -07:00
parent e380e3b7c8
commit 1eb6910bdb
37 changed files with 1157 additions and 434 deletions
+5 -5
View File
@@ -1867,7 +1867,7 @@ impl Disc {
capacity: u32,
handshake: Option<HandshakeResult>,
handshake_error: Option<Error>,
_opts: &ScanOptions,
opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
let scan_with_t0 = std::time::Instant::now();
@@ -1931,7 +1931,7 @@ impl Disc {
// forced flags match what the muxer derives during a rip (both use the
// one shared PGS classifier); the rip path leaves it off — the muxer
// detects forced while muxing, without a second read of the clip.
if _opts.probe_forced_subtitles {
if opts.probe_forced_subtitles {
for title in &mut titles {
if title.content_format == ContentFormat::BdTs {
pgs_forced_probe::probe_and_set_forced(reader, title);
@@ -2277,8 +2277,8 @@ impl std::fmt::Debug for Key {
/// next candidate (and ultimately surfaces a key error rather than silently
/// writing ciphertext).
///
/// Reuses the ecosystem's single `ts_sync_destroyed` predicate and the full
/// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
/// Reuses the ecosystem's single `is_clean` content-clarity predicate and the
/// full (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>,
@@ -2367,7 +2367,7 @@ impl Disc {
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
///
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
/// never consults [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) about
/// never consults the TS-sync content check about
/// non-content bytes — filesystem data has no TS sync and would otherwise be
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
///
+26 -1
View File
@@ -492,7 +492,14 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
// so the 30s zone-entry cooldown below keys off the real
// transition rather than re-deriving it from a counter that the
// fast-jump path resets after every jump.
let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting;
//
// A RECOVERED_ERROR (marginal read) is explicitly NOT damage-zone signal
// (see the SkipBlock branch below) — it returns early, so latching
// in_damage_zone here would spuriously consume the zone-entry transition and
// let a genuine hard error that follows skip the 30s wedge cooldown.
let is_recovered =
err.scsi_sense().map(|s| s.sense_key) == Some(scsi::SENSE_KEY_RECOVERED_ERROR);
let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting && !is_recovered;
if is_zone_entry_transition {
ctx.in_damage_zone = true;
ctx.zones_entered += 1;
@@ -874,6 +881,24 @@ mod tests {
assert_eq!(ctx.jumps_taken, 0);
}
#[test]
fn recovered_error_does_not_consume_zone_entry() {
// A recovered (marginal) read must NOT latch in_damage_zone — otherwise a
// genuine hard error that follows would not be seen as the zone entry and
// would skip the 30s wedge cooldown.
let mut ctx = ReadCtx::for_sweep(32);
handle_read_error(&recovered_err(), &mut ctx);
assert!(
!ctx.in_damage_zone,
"recovered read is not damage-zone signal"
);
assert_eq!(ctx.zones_entered, 0);
// The following genuine hard error IS the real zone entry.
handle_read_error(&hardware_err(), &mut ctx);
assert!(ctx.in_damage_zone);
assert_eq!(ctx.zones_entered, 1, "hard error registers the zone entry");
}
#[test]
fn recovered_error_skips_block_pass_n_too() {
// Pass N sees the same: a recovered read is distrusted → SkipBlock (the