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
+57 -7
View File
@@ -485,9 +485,9 @@ pub fn key_fetch(
///
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `is_clean` TS-sync
/// heuristic: a unit lacking clean TS syncs does not imply encryption (an FMTS
/// variant frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
/// unit sent to a key server yields nothing to validate against — the "0
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
@@ -818,6 +818,55 @@ mod tests {
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
}
/// `key_fetch` memoizes each operation by the fingerprint of the sample batch:
/// identical samples reuse the cached keys (no rebuild), different samples miss,
/// the two operations keep independent caches, and even an empty reply is cached.
#[test]
fn key_fetch_memoizes_per_op_by_sample_fingerprint() {
let builds = Arc::new(Mutex::new(0usize));
let builds_c = Arc::clone(&builds);
let key = [0x11u8; 16];
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
*builds_c.lock().unwrap() += 1;
vec![Box::new(HasKey(key)) as Box<dyn KeySource>]
});
let cb = key_fetch(empty_inputs(), make);
let a = vec![vec![0xAAu8; 8]];
let b = vec![vec![0xBBu8; 8]];
// First resolve for `a` builds sources; the identical repeat is cached.
assert_eq!(cb.unit_keys(&a), vec![key]);
assert_eq!(cb.unit_keys(&a), vec![key]);
assert_eq!(
*builds.lock().unwrap(),
1,
"identical samples reuse the cache"
);
// A different sample batch is a cache miss → one more build.
assert_eq!(cb.unit_keys(&b), vec![key]);
assert_eq!(
*builds.lock().unwrap(),
2,
"different samples miss the cache"
);
// The forensic op has its OWN cache (HasKey has no forensic keys → empty),
// so `a` builds once more here; its empty reply is then cached too.
assert!(cb.fmts_indexes(&a).is_empty());
assert_eq!(
*builds.lock().unwrap(),
3,
"unit/fmts caches are independent"
);
assert!(cb.fmts_indexes(&a).is_empty());
assert_eq!(
*builds.lock().unwrap(),
3,
"an empty reply is cached, not re-asked"
);
}
/// The two `KeyFetch` operations route to the two DISTINCT trait methods:
/// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
/// `get_fmts_indexes`. A source that returns different keys per method proves
@@ -982,12 +1031,12 @@ mod tests {
}
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
/// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but
/// TS-sync clarity heuristic. Half the units lack TS syncs but are
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
/// lack TS syncs; the old sampler collected these and the key server rejected
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
/// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would
/// collect the CPI-clear units too and fail the `& 0xC0` assertion.
/// return ONLY CPI-flagged units. A regression to selecting by TS-sync clarity
/// would collect the CPI-clear units too and fail the `& 0xC0` assertion.
#[test]
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
@@ -996,7 +1045,8 @@ mod tests {
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
// `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd.
// Neither has clean TS syncs, so `is_clean` is FALSE for BOTH;
// `aacs_unit_encrypted` flags only the odd units.
struct MixSource {
ext_start: u32,
total_units: u32,