From b9568242df77cb022a1e673e37adc994f67706fb Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:57:50 -0700 Subject: [PATCH] libfreemkv: 10-phase release audit fixes (v1.5.2..HEAD) Multi-round audit of the decrypt/AACS/mux-codec refactor. Fixes, in descending severity: - mux/mp4/read.rs: bound untrusted-input allocations. `sample_budget` now also capped by file_len (a fixed-size stsz claiming count=u32::MAX can't inflate the Vec past the file's own size); trak scan capped at MAX_TRACKS matches; find_box() takes only the first match (cap=1) instead of materializing every match. Removes dead find_boxes wrapper. - disc/mod.rs: merge_content_key_ranges now UNIONS same-key overlapping ranges (coverage-preserving) instead of dropping the non-overlapping tail, which silently left encrypted LBAs uncovered -> ciphertext passthrough in the whole-disc sweep/patch map. Different-key overlap (malformed) still dropped to keep the set disjoint. - sector/decrypting.rs: remove dead unit_key_idx field + with_unit_key_idx setter (vestigial from the pre-keymap trial-decrypt design; AACS is map-only now). Fix stale docs. - decrypt.rs / resolve.rs / error.rs / extract.rs: doc/comment drift from the refactor (AacsKeyMap positive-map semantics, resolve_mux_key_map doc reattachment, decrypt_sectors_in_content legacy-alias, E_MP4_INVALID meaning, multi-CPS orphan by-design note). Test coverage (all mutation-verified real): - DTS NeedMore force-flush buffer bound; FLAC/MPEG-audio PTS carry-forward; mp4 mdhd timescale=0 divide-by-zero guard, MAX_TRACKS cap, sample-count file_len bound, MAX_ALLOC_BYTES cap under inflated file_len. - resolve_fmts_key_map: extracted filter_addressable_segments, resolve_tie_phase, fill_base_key_gaps as pure behavior-preserving helpers, each unit-tested (segment filter, phase-tie arms, gap-fill gaplessness over every extent). --- src/decrypt.rs | 30 +-- src/disc/extract.rs | 7 + src/disc/mod.rs | 42 +++- src/error.rs | 5 +- src/mux/codec/dts.rs | 45 +++++ src/mux/codec/flac.rs | 16 ++ src/mux/codec/mpegaudio.rs | 16 ++ src/mux/mp4/read.rs | 387 +++++++++++++++++++++++++++++++++++- src/mux/resolve.rs | 398 +++++++++++++++++++++++++++++++++---- src/sector/decrypting.rs | 48 ++--- 10 files changed, 894 insertions(+), 100 deletions(-) diff --git a/src/decrypt.rs b/src/decrypt.rs index ceab7f8..07319e6 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -201,9 +201,13 @@ pub enum Phase { /// concern, exactly as for a physically-read clear disc. /// /// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` pool, -/// sorted and disjoint. `default_idx` covers any LBA no range claims — the -/// single-CPS case is just an empty range list with `default_idx = 0`, so the -/// common disc pays zero lookup cost and needs no structural walk. +/// sorted and disjoint. The map is a POSITIVE list: an LBA in no range is passed +/// through untouched (no default key). How a single-CPS disc is mapped depends on +/// the caller: the whole-disc EXTRACT path uses one blanket range `(0, u32::MAX, +/// 0)` so every encrypted unit — parsed title or orphan clip — resolves to key 0; +/// the per-title MUX/sweep path (`resolve_mux_key_map` → `content_map`) maps only +/// the title's own extents, so an orphan clip outside them is left as pass-through. +/// Either way, clear nav/filesystem sectors (encrypted-flag off) pass through. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AacsKeyMap { // (start_lba, end_lba, key_idx, phase). An LBA in NO range is passed through @@ -514,17 +518,13 @@ pub fn decrypt_sectors( decrypt_sectors_impl(buf, keys, unit_key_idx, None) } -/// Like [`decrypt_sectors`], but ONLY decrypts/verifies units whose absolute LBA -/// falls inside `content_ranges` — the disc's AACS-encrypted content (the m2ts -/// stream extents). Units OUTSIDE content (UDF filesystem / nav) are left -/// untouched and never counted as decrypt loss: they are clear by definition, so -/// the content-clarity check [`is_clean`](crate::aacs::content::is_clean) must not -/// be consulted about them (a filesystem unit has no TS sync, so it would -/// otherwise be mistaken for ciphertext). `base_lba` is -/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors. -/// -/// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)` -/// tuples (each covering `[start_lba, start_lba + sector_count)`). +/// Legacy alias of [`decrypt_sectors`]. Under the keymap-only model AACS decrypts +/// EXCLUSIVELY through the resolved key map (`decrypt_sectors_mapped`), so there is +/// no per-unit content-extent gate here any more: the AACS arm fails loud and the +/// CSS / `None` arm self-gates on its per-sector scramble flag. `base_lba` and +/// `content_ranges` are therefore inert — retained only so the wrapper signature +/// stays stable for the `DecryptingSectorSource` dispatch. Prefer +/// [`decrypt_sectors`] in new code. pub fn decrypt_sectors_in_content( buf: &mut [u8], keys: &mut DecryptKeys, @@ -607,7 +607,7 @@ mod tests { v } - // ── Content-extent gate (`decrypt_sectors_in_content` / `lba_in_ranges`) ── + // ── `decrypt_sectors_in_content` (now a legacy alias of `decrypt_sectors`) ── /// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes. #[test] diff --git a/src/disc/extract.rs b/src/disc/extract.rs index f981ef4..fbc74de 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -202,6 +202,13 @@ impl Disc { // real sample from it), up front before the decorator takes the reader. A // content unit whose key the pool lacks fails loud at resolve (extract has // no CPS/forensic fetch source), never emits a wrong-key garble. + // + // KNOWN LIMITATION (by design): an orphan encrypted clip on a multi-CPS + // disc — referenced by no playlist, so in no title extent — is in no range + // and passes through as ciphertext. There is no correct key to apply (its + // CPS unit is unknown without a playlist reference), and blind trial-decrypt + // is exactly what this keymap-only model removes. Single-CPS is unaffected + // (the blanket key-0 map above covers orphans). let key_map = match &base_keys { DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => { diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 415dbf7..c570556 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -592,17 +592,30 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut /// Merge per-title AACS key ranges into the sorted, disjoint set the whole-disc map /// needs ([`crate::decrypt::AacsKeyMap::entry_for`] requires disjoint ranges). /// Titles that share a clip resolve the SAME physical span (same LBAs → same CPS -/// unit → same key), so a later range that starts before the previous kept range's -/// end is that duplicate and is dropped. A real disc never produces two DIFFERENT -/// keys for one LBA, so the drop is a dedup, not a conflict resolution. +/// unit → same key). When a later range overlaps a kept one that carries the SAME +/// key index and phase, the two are UNIONED (end extended to the max) — this covers +/// both the exact-duplicate (shared clip) case and any partial overlap without ever +/// dropping coverage, so no encrypted LBA is left in no range (which would pass +/// through as ciphertext). A real disc never produces two DIFFERENT keys for one +/// LBA; if that malformed case ever appeared, the later range is dropped to keep the +/// set disjoint rather than extend one key over another key's LBAs. fn merge_content_key_ranges( mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>, ) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> { ranges.sort_by_key(|&(s, _, _, _)| s); let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); for r in ranges { - if merged.last().is_none_or(|&(_, e, _, _)| r.0 >= e) { - merged.push(r); + match merged.last_mut() { + // Overlaps the previous kept range. + Some(last) if r.0 < last.1 => { + // Same key + phase → union (coverage-preserving); a genuine + // different-key overlap (malformed disc) is dropped to stay disjoint. + if r.2 == last.2 && r.3 == last.3 { + last.1 = last.1.max(r.1); + } + } + // Disjoint or exactly adjacent → keep as its own range. + _ => merged.push(r), } } merged @@ -4364,14 +4377,27 @@ mod tests { assert_eq!(merge_content_key_ranges(v), vec![(100, 300, 0, Phase::All)]); } - /// A later range that merely overlaps a kept one (starts before its end) is - /// dropped — the map stays disjoint rather than admitting an ambiguous LBA. + /// A later range that partially overlaps a kept one carrying the SAME key is + /// UNIONED, not dropped — the tail (400..500) must stay covered, or those + /// encrypted LBAs would fall in no range and pass through as ciphertext. #[test] - fn merge_key_ranges_drops_overlap() { + fn merge_key_ranges_unions_same_key_overlap() { let v = vec![ (100u32, 400u32, 0usize, Phase::All), (200, 500, 0, Phase::All), ]; + assert_eq!(merge_content_key_ranges(v), vec![(100, 500, 0, Phase::All)]); + } + + /// A different-key partial overlap (malformed disc) is dropped rather than + /// unioned, so one unit key is never stretched over another key's LBAs; the set + /// stays disjoint for `entry_for`. + #[test] + fn merge_key_ranges_drops_conflicting_key_overlap() { + let v = vec![ + (100u32, 400u32, 0usize, Phase::All), + (200, 500, 1, Phase::All), + ]; assert_eq!(merge_content_key_ranges(v), vec![(100, 400, 0, Phase::All)]); } diff --git a/src/error.rs b/src/error.rs index c9c38dc..d9d3397 100644 --- a/src/error.rs +++ b/src/error.rs @@ -848,8 +848,9 @@ impl From for std::io::Error { // 9023 MuxEmpty: finish() reached with zero frames — the output // would be a header-only container. Treat as invalid output. E_MUX_EMPTY => std::io::ErrorKind::InvalidData, - // mp4:// track/config mismatches: the requested output can't hold - // this title's video — invalid output request. + // mp4:// demux errors: a malformed/truncated source file + // (E_MP4_INVALID), or a source whose tracks the mux can't use — no + // video track / missing codec-private config. All are invalid data. E_MP4_NO_VIDEO_TRACK | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE => { std::io::ErrorKind::InvalidData } diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index 3a96266..e0570a3 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -1473,6 +1473,51 @@ mod tests { ); } + #[test] + fn needmore_past_cap_force_flushes_to_bound_buffer() { + // A crafted DTS-HD stream whose extension substream declares a size + // larger than what is (ever) buffered keeps `next_core_boundary` in a + // sustained NeedMore state (a candidate boundary that is never fully + // buffered). Once `buf` exceeds MAX_AU_BYTES the NeedMore force-flush + // safety valve must fire — mirroring the None arm — so the buffer can't + // grow without bound. WITHOUT the guard the parser would `break` and + // retain everything, emitting nothing. + let mut parser = DtsParser::new(); + + let core = make_dts_core(512); + // Short-form EXSS header declaring the maximum 16-bit size (65536 bytes); + // we buffer only a truncated prefix of it, so the extension is never + // "fully buffered" and the candidate boundary stays NeedMore. + let full_ext = make_exss(65536, None); + assert_eq!(exss_frame_size(&full_ext), Some(65536)); + + // Land the total buffer in (MAX_AU_BYTES, core_size + declared_ext_size): + // 65600 > 65536 fires the cap; 65600 < 512 + 65536 = 66048 keeps NeedMore. + let total = 65600usize; + let mut data = core.clone(); + data.extend_from_slice(&full_ext[..total - core.len()]); + assert!(data.len() > MAX_AU_BYTES, "buffer must exceed the AU cap"); + assert!( + data.len() < core.len() + 65536, + "extension must not be fully buffered (sustained NeedMore)" + ); + assert!( + matches!(next_core_boundary(&data, core.len()), NextCore::NeedMore), + "the framing decision at this buffer size is NeedMore past the cap" + ); + + let frames = parser.parse(&make_pes(data, Some(90000))); + assert_eq!( + frames.len(), + 1, + "NeedMore past the AU cap must force-emit, not stall and balloon the buffer" + ); + assert!( + parser.buf.is_empty(), + "the forced flush drains the buffer instead of growing it unbounded" + ); + } + #[test] fn codec_private_none() { let parser = DtsParser::new(); diff --git a/src/mux/codec/flac.rs b/src/mux/codec/flac.rs index 9f228c0..9f58ede 100644 --- a/src/mux/codec/flac.rs +++ b/src/mux/codec/flac.rs @@ -177,6 +177,22 @@ mod tests { assert_eq!(p.dropped_frames(), 0); } + #[test] + fn pes_without_pts_carries_last_timestamp_not_zero() { + // A PES with no PTS (legal for audio, e.g. after a discontinuity) must + // carry the last known timestamp forward — resetting to 0 would corrupt + // A/V sync. Mirrors the adts.rs guard test. + let mut p = FlacParser::new(); + p.parse(&make_pes(make_flac_frame(100), Some(90000))); + let f = p.parse(&make_pes(make_flac_frame(100), None)); + assert_eq!(f.len(), 1); + assert_eq!( + f[0].pts_ns, + pts_to_ns(90000), + "carried forward, not reset to 0" + ); + } + #[test] fn corrupt_frame_is_dropped() { let mut p = FlacParser::new(); diff --git a/src/mux/codec/mpegaudio.rs b/src/mux/codec/mpegaudio.rs index 9aa82ee..1c1fb5b 100644 --- a/src/mux/codec/mpegaudio.rs +++ b/src/mux/codec/mpegaudio.rs @@ -167,6 +167,22 @@ mod tests { assert_eq!(p.dropped_frames(), 0); } + #[test] + fn pes_without_pts_carries_last_timestamp_not_zero() { + // A PES with no PTS (legal for audio, e.g. after a discontinuity) must + // carry the last known timestamp forward — resetting to 0 would corrupt + // A/V sync. Mirrors the adts.rs guard test. + let mut p = MpegAudioParser::new(); + p.parse(&make_pes(mp3_frame(400), Some(90000))); + let f = p.parse(&make_pes(mp3_frame(400), None)); + assert_eq!(f.len(), 1); + assert_eq!( + f[0].pts_ns, + pts_to_ns(90000), + "carried forward, not reset to 0" + ); + } + #[test] fn reserved_version_field_is_dropped() { // version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB diff --git a/src/mux/mp4/read.rs b/src/mux/mp4/read.rs index 63b1bc7..6100bc5 100644 --- a/src/mux/mp4/read.rs +++ b/src/mux/mp4/read.rs @@ -93,10 +93,17 @@ impl Mp4Reader { let mut track_idx = 0usize; // Global cap on total decoded samples across ALL tracks — a crafted file // with many `trak` boxes must not sum past this even though each track is - // individually bounded. Real titles stay far under it. - let mut sample_budget = MAX_SAMPLE_COUNT; + // individually bounded. Real titles stay far under it. Also bound by + // `file_len`: a sample occupies at least one byte of the file, so a tiny + // crafted file with a fixed-size `stsz` claiming count=0xFFFFFFFF can't + // inflate the `sizes`/`Vec` allocations past the file's own size + // (a genuine large title has file_len ≫ sample count, so it is unaffected). + let mut sample_budget = MAX_SAMPLE_COUNT.min(file_len.min(usize::MAX as u64) as usize); - for trak in find_boxes(&moov, b"trak") { + // Bound the scan at MAX_TRACKS *matches* so a crafted moov packed with tiny + // (8-byte) trak headers can't force the scan to materialize a Vec far + // larger than the moov payload before the per-track cap below ever runs. + for trak in find_boxes_capped(&moov, b"trak", MAX_TRACKS) { if track_idx >= MAX_TRACKS { break; // bound track count so the per-track PID can't overflow u16 } @@ -348,14 +355,20 @@ fn read_moov(file: &mut R) -> io::Result> { /// The first child box of `payload` with the given type — returns its payload /// (bytes after the 8-byte header). One level. fn find_box<'a>(payload: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> { - find_boxes(payload, want).into_iter().next() + // cap=1: a single lookup only needs the first match, so a crafted payload + // packed with millions of tiny boxes can't force a huge transient match Vec + // before `.next()` throws all but one entry away. + find_boxes_capped(payload, want, 1).into_iter().next() } -/// All child boxes of `payload` with the given type — each as its payload slice. -fn find_boxes<'a>(payload: &'a [u8], want: &[u8; 4]) -> Vec<&'a [u8]> { +/// All child boxes of `payload` with the given type (each as its payload slice), +/// stopping after `cap` matches so a caller that only processes the first `cap` +/// never forces an oversized Vec of slice fat-pointers from a crafted payload +/// packed with minimum-size boxes. Pass `usize::MAX` for "all matches". +fn find_boxes_capped<'a>(payload: &'a [u8], want: &[u8; 4], cap: usize) -> Vec<&'a [u8]> { let mut out = Vec::new(); let mut pos = 0; - while pos + 8 <= payload.len() { + while pos + 8 <= payload.len() && out.len() < cap { let size = u32::from_be_bytes([ payload[pos], payload[pos + 1], @@ -1046,6 +1059,65 @@ mod tests { assert_eq!(parse_esds_asc(&esds[..12]), None); } + #[test] + fn read_moov_over_cap_rejected_despite_inflated_file_len() { + use std::io::{Read, Seek, SeekFrom}; + // A reader that reports an 8 GiB length on `seek(End)` (trivially forged by + // a sparse file, e.g. `truncate -s 8G`) but is backed by a tiny crafted + // header followed by an endless run of zeros. A `moov` whose declared size + // (512 MiB) is UNDER that inflated length passes the plain EOF check AND + // (crucially) the payload `read_exact` would SUCCEED against the zero + // stream — so only the absolute MAX_ALLOC_BYTES (256 MiB) cap can reject + // it. This makes the test flip to Ok (allocation attempted) if a regression + // drops the cap and keeps only the (sparse-file-defeatable) EOF check. + struct InflatedReader { + data: Vec, + pos: u64, + fake_len: u64, + } + impl Read for InflatedReader { + fn read(&mut self, out: &mut [u8]) -> io::Result { + let remaining = self.fake_len.saturating_sub(self.pos); + let n = (out.len() as u64).min(remaining) as usize; + for (i, byte) in out[..n].iter_mut().enumerate() { + let idx = self.pos + i as u64; + *byte = if idx < self.data.len() as u64 { + self.data[idx as usize] + } else { + 0 // endless zero fill past the crafted header + }; + } + self.pos += n as u64; + Ok(n) + } + } + impl Seek for InflatedReader { + fn seek(&mut self, from: SeekFrom) -> io::Result { + self.pos = match from { + SeekFrom::Start(p) => p, + SeekFrom::End(off) => (self.fake_len as i64 + off) as u64, + SeekFrom::Current(off) => (self.pos as i64 + off) as u64, + }; + Ok(self.pos) + } + } + + // Only the 8-byte box header is served; the 512 MiB payload is never read. + let box_size: u32 = (512 << 20) + 8; // 512 MiB > 256 MiB cap, < 8 GiB len + let mut data = Vec::new(); + data.extend_from_slice(&box_size.to_be_bytes()); + data.extend_from_slice(b"moov"); + let mut rd = InflatedReader { + data, + pos: 0, + fake_len: 8 << 30, // 8 GiB + }; + // Sanity: the EOF check would pass (claim < inflated len), so a rejection + // can only come from the MAX_ALLOC_BYTES cap. + assert!((box_size as u64) < rd.fake_len); + assert!(read_moov(&mut rd).is_err()); + } + #[test] fn read_moov_size_zero_spans_to_eof() { use std::io::Cursor; @@ -1086,6 +1158,307 @@ mod tests { assert!(read_moov(&mut Cursor::new(b)).is_err()); } + /// Wrap `payload` in an ISO-BMFF box with the given 4-byte type. + fn mp4_box(typ: &[u8; 4], payload: &[u8]) -> Vec { + let size = (payload.len() + 8) as u32; + let mut v = Vec::with_capacity(payload.len() + 8); + v.extend_from_slice(&size.to_be_bytes()); + v.extend_from_slice(typ); + v.extend_from_slice(payload); + v + } + + /// Build a minimal-but-complete audio `trak` (one AC-3 sample) with the given + /// media timescale — enough boxes that `from_reader` reaches the per-sample + /// `to_ns` timestamp conversion (mdia → mdhd/hdlr/minf → stbl → stsd/stsz/ + /// stco/stsc, one sample). + fn audio_trak(timescale: u32) -> Vec { + let mdhd = { + // v0: version+flags(4) creation(4) modification(4) timescale(4) duration(4). + let mut p = vec![0u8; 24]; + p[12..16].copy_from_slice(×cale.to_be_bytes()); + mp4_box(b"mdhd", &p) + }; + let hdlr = { + // version+flags(4) pre_defined(4) handler_type(4). + let mut p = vec![0u8; 12]; + p[8..12].copy_from_slice(b"soun"); + mp4_box(b"hdlr", &p) + }; + let stsd = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&1u32.to_be_bytes()); // entry_count + p.extend_from_slice(&8u32.to_be_bytes()); // sample entry size (header only) + p.extend_from_slice(b"ac-3"); // fourcc → Codec::Ac3 + mp4_box(b"stsd", &p) + }; + let stsz = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&10u32.to_be_bytes()); // sample_size (fixed) = 10 + p.extend_from_slice(&1u32.to_be_bytes()); // count = 1 + mp4_box(b"stsz", &p) + }; + let stco = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); + p.extend_from_slice(&1u32.to_be_bytes()); // count + p.extend_from_slice(&0u32.to_be_bytes()); // chunk offset 0 + mp4_box(b"stco", &p) + }; + let stsc = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); + p.extend_from_slice(&1u32.to_be_bytes()); // count + p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk + p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk + p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx + mp4_box(b"stsc", &p) + }; + let mut stbl = Vec::new(); + stbl.extend_from_slice(&stsd); + stbl.extend_from_slice(&stsz); + stbl.extend_from_slice(&stco); + stbl.extend_from_slice(&stsc); + let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl)); + let mut mdia = Vec::new(); + mdia.extend_from_slice(&mdhd); + mdia.extend_from_slice(&hdlr); + mdia.extend_from_slice(&minf); + mp4_box(b"trak", &mp4_box(b"mdia", &mdia)) + } + + #[test] + fn mdhd_timescale_zero_does_not_divide_by_zero() { + use std::io::Cursor; + // Without the `.filter(|&t| t != 0)` guard the per-sample `to_ns` closure + // divides by the zero timescale and panics; with it the track falls back + // to the 90 kHz default and is indexed normally. + let moov = mp4_box(b"moov", &audio_trak(0)); + let rd = Mp4Reader::from_reader(Cursor::new(moov), "ts0".into()); + assert!( + rd.is_ok(), + "timescale 0 must be handled via fallback, no divide-by-zero panic" + ); + assert_eq!( + rd.unwrap().info().streams.len(), + 1, + "the timescale-0 track is still indexed" + ); + } + + #[test] + fn trak_loop_stops_at_max_tracks() { + use std::io::Cursor; + // A crafted moov packing more than MAX_TRACKS trak boxes must not index + // past the cap — the per-track PID `0x1011 + idx` overflows u16 past ~61k + // tracks. Without the cap this indexes all MAX_TRACKS + 50 tracks. + let mut traks = Vec::new(); + for _ in 0..(MAX_TRACKS + 50) { + traks.extend_from_slice(&audio_trak(48_000)); + } + let moov = mp4_box(b"moov", &traks); + let rd = Mp4Reader::from_reader(Cursor::new(moov), "many".into()).unwrap(); + assert_eq!( + rd.info().streams.len(), + MAX_TRACKS, + "trak loop must stop at MAX_TRACKS" + ); + } + + /// A crafted file with a *fixed-size* `stsz` (sample_size != 0) claiming + /// count = 0xFFFFFFFF must not inflate the sample index past the file's own + /// byte length. `from_reader` sets `sample_budget = MAX_SAMPLE_COUNT.min(file_len)`, + /// so a few-hundred-byte file bounds the `Vec` to a few hundred — + /// NOT the 16M `MAX_SAMPLE_COUNT` ceiling. Mutation check: revert the budget + /// to a bare `MAX_SAMPLE_COUNT` and this file yields ~16M samples, failing the + /// `<= file_len` (and `< MAX_SAMPLE_COUNT`) assertions below. + #[test] + fn stsz_sample_count_bounded_by_file_len() { + use std::io::Cursor; + // A minimal audio trak, but with a fixed-size stsz lying about its count. + let mdhd = { + let mut p = vec![0u8; 24]; + p[12..16].copy_from_slice(&48_000u32.to_be_bytes()); // timescale + mp4_box(b"mdhd", &p) + }; + let hdlr = { + let mut p = vec![0u8; 12]; + p[8..12].copy_from_slice(b"soun"); + mp4_box(b"hdlr", &p) + }; + let stsd = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&1u32.to_be_bytes()); // entry_count + p.extend_from_slice(&8u32.to_be_bytes()); // sample entry size (header only) + p.extend_from_slice(b"ac-3"); // fourcc → Codec::Ac3 + mp4_box(b"stsd", &p) + }; + let stsz = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&10u32.to_be_bytes()); // sample_size != 0 (fixed) + p.extend_from_slice(&0xFFFF_FFFFu32.to_be_bytes()); // count = u32::MAX (lie) + mp4_box(b"stsz", &p) + }; + let stco = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); + p.extend_from_slice(&1u32.to_be_bytes()); // count + p.extend_from_slice(&0u32.to_be_bytes()); // chunk offset 0 + mp4_box(b"stco", &p) + }; + let stsc = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); + p.extend_from_slice(&1u32.to_be_bytes()); // count + p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk + p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk + p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx + mp4_box(b"stsc", &p) + }; + let mut stbl = Vec::new(); + stbl.extend_from_slice(&stsd); + stbl.extend_from_slice(&stsz); + stbl.extend_from_slice(&stco); + stbl.extend_from_slice(&stsc); + let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl)); + let mut mdia = Vec::new(); + mdia.extend_from_slice(&mdhd); + mdia.extend_from_slice(&hdlr); + mdia.extend_from_slice(&minf); + let trak = mp4_box(b"trak", &mp4_box(b"mdia", &mdia)); + let moov = mp4_box(b"moov", &trak); + + let file_len = moov.len() as u64; + assert!( + file_len < 1024, + "fixture stays a few hundred bytes ({file_len})" + ); + let rd = Mp4Reader::from_reader(Cursor::new(moov), "hostile".into()).unwrap(); + // The index must be bounded by the file's byte length, NOT the 16M ceiling. + assert!( + (rd.samples.len() as u64) <= file_len, + "sample count {} must be bounded by file_len {file_len}, not the count lie", + rd.samples.len() + ); + assert!( + rd.samples.len() < MAX_SAMPLE_COUNT, + "a tiny file must not allocate the 16M MAX_SAMPLE_COUNT ceiling" + ); + } + + /// Build an audio `trak` identical to `audio_trak(48_000)` but with the + /// named stbl child box omitted. Used to reach the untrusted-input guards + /// that drop a track whose `stsz` says samples exist yet whose `stco`/`co64` + /// (chunk offsets) or `stsc` (sample-to-chunk map) is missing — without such + /// a table every sample offset would resolve near file byte 0. + fn audio_trak_missing(omit: &[u8; 4]) -> Vec { + let mdhd = { + let mut p = vec![0u8; 24]; + p[12..16].copy_from_slice(&48_000u32.to_be_bytes()); // timescale + mp4_box(b"mdhd", &p) + }; + let hdlr = { + let mut p = vec![0u8; 12]; + p[8..12].copy_from_slice(b"soun"); + mp4_box(b"hdlr", &p) + }; + let stsd = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&1u32.to_be_bytes()); // entry_count + p.extend_from_slice(&8u32.to_be_bytes()); // sample entry size (header only) + p.extend_from_slice(b"ac-3"); // fourcc → Codec::Ac3 + mp4_box(b"stsd", &p) + }; + let stsz = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&10u32.to_be_bytes()); // sample_size (fixed) = 10 + p.extend_from_slice(&3u32.to_be_bytes()); // count = 3 (samples exist) + mp4_box(b"stsz", &p) + }; + let stco = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); + p.extend_from_slice(&1u32.to_be_bytes()); // count + p.extend_from_slice(&0u32.to_be_bytes()); // chunk offset 0 + mp4_box(b"stco", &p) + }; + let stsc = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); + p.extend_from_slice(&1u32.to_be_bytes()); // count + p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk + p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk + p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx + mp4_box(b"stsc", &p) + }; + let mut stbl = Vec::new(); + stbl.extend_from_slice(&stsd); + stbl.extend_from_slice(&stsz); + if omit != b"stco" { + stbl.extend_from_slice(&stco); + } + if omit != b"stsc" { + stbl.extend_from_slice(&stsc); + } + let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl)); + let mut mdia = Vec::new(); + mdia.extend_from_slice(&mdhd); + mdia.extend_from_slice(&hdlr); + mdia.extend_from_slice(&minf); + mp4_box(b"trak", &mp4_box(b"mdia", &mdia)) + } + + /// A track with samples (`stsz`) but no chunk-offset table (`stco`/`co64`) + /// must be DROPPED, not indexed with offsets that resolve near file byte 0. + /// With it the only track, the whole file fails `Mp4Invalid`. + /// Mutation check: delete the `if chunk_offsets.is_empty() { continue; }` + /// guard and `from_reader` returns `Ok` (garbage samples), flipping this to FAIL. + #[test] + fn missing_stco_drops_track_all_dropped_is_invalid() { + use std::io::Cursor; + let moov = mp4_box(b"moov", &audio_trak_missing(b"stco")); + let rd = Mp4Reader::from_reader(Cursor::new(moov), "no-stco".into()); + assert!( + rd.is_err(), + "a track with stsz but no stco/co64 must be dropped; all-dropped → Mp4Invalid" + ); + } + + /// A track with samples (`stsz`) and chunk offsets (`stco`) but no + /// sample-to-chunk map (`stsc`) must be DROPPED — without `stsc` the samples + /// cannot be placed against the chunk offsets and would pack from byte 0. + /// Mutation check: delete the `if stsc.is_empty() { continue; }` guard and + /// `from_reader` returns `Ok`, flipping this to FAIL. + #[test] + fn missing_stsc_drops_track_all_dropped_is_invalid() { + use std::io::Cursor; + let moov = mp4_box(b"moov", &audio_trak_missing(b"stsc")); + let rd = Mp4Reader::from_reader(Cursor::new(moov), "no-stsc".into()); + assert!( + rd.is_err(), + "a track with stsz + stco but no stsc must be dropped; all-dropped → Mp4Invalid" + ); + } + + /// Sanity companion: the SAME builder WITH both tables present yields a valid, + /// indexed single-track file — proving the two Err results above come from the + /// missing table, not from some unrelated defect in the fixture builder. + #[test] + fn audio_trak_missing_none_is_valid() { + use std::io::Cursor; + // omit a box that isn't in the stbl → nothing omitted, fixture is complete. + let moov = mp4_box(b"moov", &audio_trak_missing(b"____")); + let rd = Mp4Reader::from_reader(Cursor::new(moov), "complete".into()) + .expect("complete stbl (stsz+stco+stsc) must index"); + assert_eq!(rd.info().streams.len(), 1, "the complete track is indexed"); + } + #[test] fn mdhd_language_offsets_per_version() { // "eng" packed = 0x15C7. v0 carries it at byte 20, v1 (64-bit times) at 32. diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 50341f0..cf8b6c4 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -795,10 +795,7 @@ fn resolve_fmts_key_map( // resolves EVERY title for the whole-disc sweep — aborts the entire decrypt on // the first non-forensic title (a menu playlist), and `build_iso_pipeline` // aborts muxing any non-main title. - let segments: Vec = segments - .into_iter() - .filter(|s| clip_byte_to_lba(&title.extents, s.start_spn as u64 * 192).is_some()) - .collect(); + let segments = filter_addressable_segments(segments, &title.extents); if segments.is_empty() { return Ok(None); } @@ -931,24 +928,25 @@ fn resolve_fmts_key_map( } } } - let phase = match even.cmp(&odd) { - std::cmp::Ordering::Greater => crate::decrypt::Phase::Even, - std::cmp::Ordering::Less => crate::decrypt::Phase::Odd, - std::cmp::Ordering::Equal if even == 0 => { + let phase = match resolve_tie_phase(even, odd) { + Ok(p) => { + if even == odd { + // BOTH halves decrypt clean (even == odd > 0): the sampled units + // are source-zero padding (`is_clean_ts` is true for all-zero + // content under ANY key), so the key is valid and the parity is + // immaterial here — default Even (we decrypt one parity; padding + // in the dropped parity is harmless). A padding-heavy sample must + // NOT abort the rip. + tracing::debug!(target: "freemkv::keysource", index = tag, even, odd, "fmts: padding tie — defaulting Even"); + } + p + } + Err(e) => { // NEITHER half decrypts clean under this index's key: the key is // wrong or the sampled units aren't this index's real content. The // map would be wrong — fail loud rather than emit a broken segment. tracing::warn!(target: "freemkv::keysource", index = tag, even, odd, "fmts: no clean phase under index key — refusing broken map"); - return Err(crate::error::Error::FmtsKeyMissing.into()); - } - std::cmp::Ordering::Equal => { - // BOTH halves decrypt clean: the sampled units are source-zero - // padding (`is_clean_ts` is true for all-zero content under ANY - // key), so the key is valid and the parity is immaterial here — - // default Even (we decrypt one parity; padding in the dropped parity - // is harmless). A padding-heavy sample must NOT abort the rip. - tracing::debug!(target: "freemkv::keysource", index = tag, even, odd, "fmts: padding tie — defaulting Even"); - crate::decrypt::Phase::Even + return Err(e); } }; phase_of_index.insert(tag, phase); @@ -1006,12 +1004,80 @@ fn resolve_fmts_key_map( // (added above with their index keys) carve holes out of the title's content // extents; every other content unit uses the base UK. Fill the gaps so the map // is a complete positive list — an LBA in no range is nav and passes through. + let base_gaps = fill_base_key_gaps(&title.extents, &ranges, base_idx); + ranges.extend(base_gaps); + + Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(ranges))) +} + +/// Keep only the forensic segments addressable within THIS title's extents: a +/// segment whose clip-byte start (`start_spn * 192`) maps to an LBA inside the +/// title is forensic content for this title; one that does not belongs to a +/// different clip (a menu/extras playlist) and is dropped. An empty result means +/// the title carries no forensic content, so [`resolve_fmts_key_map`] returns +/// `Ok(None)` and the caller's base Unit-Key path applies. Extracted from +/// `resolve_fmts_key_map` for direct testing of the inclusion/exclusion decision. +fn filter_addressable_segments( + segments: Vec, + extents: &[crate::disc::Extent], +) -> Vec { + segments + .into_iter() + .filter(|s| { + crate::aacs::segment::clip_byte_to_lba(extents, s.start_spn as u64 * 192).is_some() + }) + .collect() +} + +/// Decide a forensic index's decrypt phase from the clean-sample counts of its +/// EVEN vs ODD aligned units under that index's key. Extracted from +/// [`resolve_fmts_key_map`] so the tie logic is unit-testable; the `tracing` +/// diagnostics stay at the call site, which holds the segment-index context. +/// +/// * `even > odd` → [`Phase::Even`](crate::decrypt::Phase::Even); `odd > even` → +/// [`Phase::Odd`](crate::decrypt::Phase::Odd) — the clean half is this index's +/// real content variant. +/// * `even == odd == 0` → [`Error::FmtsKeyMissing`](crate::error::Error::FmtsKeyMissing): +/// NEITHER half decrypts clean, so the key is wrong (or the sample is not this +/// index's content) — fail loud rather than emit a broken segment. +/// * `even == odd > 0` → [`Phase::Even`](crate::decrypt::Phase::Even): BOTH halves +/// are clean, i.e. source-zero padding (clean under any key), so the parity is +/// immaterial — default Even. +fn resolve_tie_phase(even_clean: usize, odd_clean: usize) -> io::Result { + match even_clean.cmp(&odd_clean) { + std::cmp::Ordering::Greater => Ok(crate::decrypt::Phase::Even), + std::cmp::Ordering::Less => Ok(crate::decrypt::Phase::Odd), + std::cmp::Ordering::Equal if even_clean == 0 => { + Err(crate::error::Error::FmtsKeyMissing.into()) + } + std::cmp::Ordering::Equal => Ok(crate::decrypt::Phase::Even), + } +} + +/// Back-fill the LBA gaps NOT covered by the forensic segment ranges with the base +/// Unit Key, so the finished map is a COMPLETE positive list over the title's +/// content extents: every content LBA resolves to either a forensic key (inside a +/// segment) or the base key (`base_idx`). An LBA left in no range would pass +/// ciphertext through as clear — this range arithmetic guarantees there is no such +/// hole inside any extent. Extracted from [`resolve_fmts_key_map`] for exhaustive +/// direct testing (gaplessness over every extent). +/// +/// `forensic_ranges` are the already-built per-segment ranges; only their +/// `[start, end)` spans matter here (they carve the holes — the key idx / phase are +/// irrelevant). The return is the base-key fill ranges ONLY; the caller appends +/// them to `forensic_ranges` to form the full map. +fn fill_base_key_gaps( + extents: &[crate::disc::Extent], + forensic_ranges: &[(u32, u32, usize, crate::decrypt::Phase)], + base_idx: usize, +) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> { let cuts: Vec<(u32, u32)> = { - let mut c: Vec<(u32, u32)> = ranges.iter().map(|&(s, e, _, _)| (s, e)).collect(); + let mut c: Vec<(u32, u32)> = forensic_ranges.iter().map(|&(s, e, _, _)| (s, e)).collect(); c.sort_unstable(); c }; - for ext in &title.extents { + let mut fills = Vec::new(); + for ext in extents { let end = ext.start_lba.saturating_add(ext.sector_count); let mut cur = ext.start_lba; for &(cs, ce) in &cuts { @@ -1019,16 +1085,26 @@ fn resolve_fmts_key_map( continue; // cut outside this extent } if cs > cur { - ranges.push((cur, cs, base_idx, crate::decrypt::Phase::All)); + fills.push((cur, cs, base_idx, crate::decrypt::Phase::All)); } cur = cur.max(ce); } if cur < end { - ranges.push((cur, end, base_idx, crate::decrypt::Phase::All)); + fills.push((cur, end, base_idx, crate::decrypt::Phase::All)); } } + fills +} - Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(ranges))) +/// A single-key content map: every content extent → `idx`; everything else passes +/// through. The positive-map replacement for the old "one key everywhere" default. +fn content_map(title: &DiscTitle, idx: usize) -> crate::decrypt::AacsKeyMap { + let ranges = title + .extents + .iter() + .map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count), idx)) + .collect(); + crate::decrypt::AacsKeyMap::from_ranges(ranges) } /// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title @@ -1050,18 +1126,6 @@ fn resolve_fmts_key_map( /// content extent with one index; multi-CPS keys each extent with the key that /// opens a real sample from it; FMTS layers per-segment index keys on top. Any LBA /// outside the title's content (nav/filesystem) is in no range and passes through. -/// -/// A single-key content map: every content extent → `idx`; everything else passes -/// through. The positive-map replacement for the old "one key everywhere" default. -fn content_map(title: &DiscTitle, idx: usize) -> crate::decrypt::AacsKeyMap { - let ranges = title - .extents - .iter() - .map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count), idx)) - .collect(); - crate::decrypt::AacsKeyMap::from_ranges(ranges) -} - pub fn resolve_mux_key_map( reader: &mut dyn SectorSource, title: &DiscTitle, @@ -1949,4 +2013,268 @@ mod tests { "a scrambled DVD title with no key must hard-fail, not build a scrambled-passthrough pipeline" ); } + + // ── content_map: single-CPS positive range building ──────────────────── + + /// `content_map(title, idx)` keys every single-CPS UHD disc (the common + /// case): each content extent → one `[start_lba, start_lba+sector_count)` + /// range at `idx`, phase `All`. Assert the exact ranges — an off-by-one on + /// the end (or a wrong idx / phase) must flip this test to FAIL. + #[test] + fn content_map_builds_exact_ranges_from_extents() { + use crate::decrypt::Phase; + let mut t = DiscTitle::empty(); + t.extents = vec![ + Extent { + start_lba: 100, + sector_count: 50, + }, + Extent { + start_lba: 1000, + sector_count: 200, + }, + ]; + let map = super::content_map(&t, 3); + // end = start + count (exclusive), idx = 3, phase = All, for each extent. + assert_eq!( + map.ranges(), + &[ + (100u32, 150u32, 3usize, Phase::All), + (1000u32, 1200u32, 3usize, Phase::All), + ], + "each extent maps to [start, start+count) at the given idx" + ); + // Spot-check the derived lookups: inside → idx 3, the exclusive end and + // the inter-extent gap → no key (pass-through). + assert_eq!(map.key_idx_for(100), Some(3), "range start is inclusive"); + assert_eq!(map.key_idx_for(149), Some(3), "last sector of extent 0"); + assert_eq!(map.key_idx_for(150), None, "extent end is exclusive"); + assert_eq!(map.key_idx_for(500), None, "gap between extents → no key"); + assert_eq!(map.key_idx_for(1199), Some(3), "last sector of extent 1"); + } + + /// A single-extent title still produces exactly one range with the correct + /// end (`saturating_add`), and a `sector_count` that would overflow u32 + /// saturates rather than wrapping past `u32::MAX`. + #[test] + fn content_map_single_extent_end_saturates() { + use crate::decrypt::Phase; + let mut t = DiscTitle::empty(); + t.extents = vec![Extent { + start_lba: u32::MAX - 10, + sector_count: 100, // (MAX-10)+100 would overflow → saturate to MAX + }]; + let map = super::content_map(&t, 0); + assert_eq!( + map.ranges(), + &[(u32::MAX - 10, u32::MAX, 0usize, Phase::All)], + "range end saturates at u32::MAX, no wrap" + ); + } + + // ── resolve_fmts_key_map decision helpers (behaviors flagged by audit) ── + + /// BEHAVIOR 1 — segment filter (`resolve_fmts_key_map` line ~800). A segment + /// whose clip-byte start (`start_spn * 192`) maps inside the title's extents is + /// kept; one whose start is past the clip is dropped; all-outside → empty (the + /// resolver then returns `Ok(None)` and the base-UK path applies). + #[test] + fn filter_addressable_segments_keeps_only_in_title_segments() { + use crate::aacs::segment::Segment; + // One extent covering clip bytes [0, 60*2048) = [0, 122880). + let extents = vec![Extent { + start_lba: 500, + sector_count: 60, + }]; + // start_spn 100 → clip byte 19200 < 122880 → maps to an LBA → KEEP. + let inside = Segment { + index: 1, + start_spn: 100, + end_spn: 199, + }; + // start_spn 1000 → clip byte 192000 >= 122880 → clip_byte_to_lba None → DROP. + let outside = Segment { + index: 2, + start_spn: 1000, + end_spn: 1099, + }; + let kept = super::filter_addressable_segments(vec![inside, outside], &extents); + assert_eq!(kept, vec![inside], "only the in-title segment survives"); + // All-outside → empty; `resolve_fmts_key_map` maps this to Ok(None). + assert!( + super::filter_addressable_segments(vec![outside], &extents).is_empty(), + "no addressable segment → empty (→ resolver Ok(None))" + ); + // Boundary: a segment whose start is the LAST clip byte still maps (Some); + // one exactly at the clip end (122880) does not. + let at_last = Segment { + index: 3, + start_spn: (122_879 / 192) as u32, // 639 → byte 122688 < 122880 + end_spn: 700, + }; + let at_end = Segment { + index: 4, + start_spn: (122_880 / 192) as u32, // 640 → byte 122880 == clip end → None + end_spn: 700, + }; + assert_eq!( + super::filter_addressable_segments(vec![at_last, at_end], &extents), + vec![at_last], + "start inside the clip is kept; start at/after the clip end is dropped" + ); + } + + /// BEHAVIOR 2 — phase-tie default (`resolve_fmts_key_map` line ~936). All four + /// arms of the even/odd clean-count decision. + #[test] + fn resolve_tie_phase_covers_all_arms() { + use crate::decrypt::Phase; + // Non-tie: the clean half is the index's real variant. + assert_eq!( + super::resolve_tie_phase(5, 2).unwrap(), + Phase::Even, + "even majority → Even" + ); + assert_eq!( + super::resolve_tie_phase(2, 5).unwrap(), + Phase::Odd, + "odd majority → Odd" + ); + // Padding tie (both halves clean, > 0): parity immaterial → default Even. + assert_eq!( + super::resolve_tie_phase(3, 3).unwrap(), + Phase::Even, + "even == odd > 0 → default Even" + ); + assert_eq!(super::resolve_tie_phase(1, 1).unwrap(), Phase::Even); + // Neither half clean (even == odd == 0): fail loud with FmtsKeyMissing. + let err = super::resolve_tie_phase(0, 0).unwrap_err(); + let expected = std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string(); + assert_eq!( + err.to_string(), + expected, + "even == odd == 0 → FmtsKeyMissing" + ); + } + + /// Assert `forensic` + `fills` together cover every LBA of every extent EXACTLY + /// once — no gap (a hole would pass ciphertext through as clear) and no overlap + /// (two keys over one LBA). This is the load-bearing invariant of the gap-fill. + fn assert_gapless( + extents: &[Extent], + forensic: &[(u32, u32, usize, crate::decrypt::Phase)], + fills: &[(u32, u32, usize, crate::decrypt::Phase)], + ) { + let mut spans: Vec<(u32, u32)> = forensic.iter().map(|&(s, e, _, _)| (s, e)).collect(); + spans.extend(fills.iter().map(|&(s, e, _, _)| (s, e))); + spans.sort_unstable(); + for w in spans.windows(2) { + assert!(w[0].1 <= w[1].0, "spans overlap: {:?} vs {:?}", w[0], w[1]); + } + for ext in extents { + let end = ext.start_lba + ext.sector_count; + for lba in ext.start_lba..end { + let covering = spans.iter().filter(|&&(s, e)| lba >= s && lba < e).count(); + assert_eq!( + covering, 1, + "LBA {lba} covered {covering}× (want exactly 1)" + ); + } + } + } + + /// BEHAVIOR 3 — gap-fill range arithmetic (`resolve_fmts_key_map` line ~1005). + /// Exhaustive: no segments, mid-extent, at-start, at-end, adjacent segments, + /// and multi-extent. Each asserts the EXACT fills AND gaplessness over every + /// extent — an off-by-one that leaves a hole flips this to FAIL. + #[test] + fn fill_base_key_gaps_is_gapless_over_every_extent() { + use crate::decrypt::Phase::{All, Even, Odd}; + let base = 0usize; + + // No segments → the whole extent is base key. + let ext = vec![Extent { + start_lba: 100, + sector_count: 60, + }]; + let forensic: Vec<(u32, u32, usize, crate::decrypt::Phase)> = vec![]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!(fills, vec![(100, 160, base, All)], "no segments → all base"); + assert_gapless(&ext, &forensic, &fills); + + // One segment mid-extent → base | forensic | base, gapless. + let forensic = vec![(120, 130, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!( + fills, + vec![(100, 120, base, All), (130, 160, base, All)], + "mid-extent segment → leading + trailing base" + ); + assert_gapless(&ext, &forensic, &fills); + + // Segment at extent START → only a trailing base fill (no zero-length lead). + let forensic = vec![(100, 130, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!( + fills, + vec![(130, 160, base, All)], + "segment at start → no leading base, one trailing" + ); + assert_gapless(&ext, &forensic, &fills); + + // Segment at extent END → only a leading base fill (no zero-length trail). + let forensic = vec![(130, 160, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!( + fills, + vec![(100, 130, base, All)], + "segment at end → one leading base, no trailing" + ); + assert_gapless(&ext, &forensic, &fills); + + // Whole extent is one segment → no base fill at all, still gapless. + let forensic = vec![(100, 160, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert!( + fills.is_empty(), + "segment spans whole extent → no base fill" + ); + assert_gapless(&ext, &forensic, &fills); + + // Adjacent segments (touching, no gap between) → NO zero-length base range + // between them (guards the `cs > cur` off-by-one). + let forensic = vec![(110, 120, 5, Even), (120, 130, 6, Odd)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, base); + assert_eq!( + fills, + vec![(100, 110, base, All), (130, 160, base, All)], + "adjacent segments → no zero-length fill between them" + ); + assert_gapless(&ext, &forensic, &fills); + + // Multi-extent: a segment mid-first-extent and one at the start of the + // second. Fills are per-extent and the union is gapless across both. + let exts = vec![ + Extent { + start_lba: 100, + sector_count: 60, + }, // [100, 160) + Extent { + start_lba: 1000, + sector_count: 40, + }, // [1000, 1040) + ]; + let forensic = vec![(120, 130, 5, Even), (1000, 1010, 7, Odd)]; + let fills = super::fill_base_key_gaps(&exts, &forensic, base); + assert_eq!( + fills, + vec![ + (100, 120, base, All), + (130, 160, base, All), + (1010, 1040, base, All), + ], + "each extent filled independently" + ); + assert_gapless(&exts, &forensic, &fills); + } } diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index b40be8f..a833e82 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -93,15 +93,12 @@ impl KeyFetch { /// Decorator: read from `inner`, then run the configured /// AACS / CSS decrypt over the bytes that landed in `buf`. /// -/// `unit_key_idx` selects the AACS unit key for the disc (0 for -/// the vast majority of titles; the rare multi-CPS-unit discs pick -/// the index that covers the title being read). For -/// [`DecryptKeys::None`] and [`DecryptKeys::Css`] the index is -/// ignored. +/// AACS decrypts EXCLUSIVELY through the installed [`key_map`](Self::key_map) +/// (one key per CPS unit / segment, resolved up front); CSS self-descrambles on +/// its per-sector scramble flag; [`DecryptKeys::None`] is a pass-through. pub struct DecryptingSectorSource { inner: S, keys: DecryptKeys, - unit_key_idx: usize, /// Base LBA of the encrypted region currently being read — the clip / /// extent `start_lba` that AACS aligned units are anchored at. The unit- /// alignment gate measures `lba` relative to THIS, not absolute disc LBA 0, @@ -130,16 +127,13 @@ pub struct DecryptingSectorSource { } impl DecryptingSectorSource { - /// Wrap `inner` with the given keys. The default unit-key - /// index is 0; use [`with_unit_key_idx`] for the multi-CPS-unit - /// case. - /// - /// [`with_unit_key_idx`]: Self::with_unit_key_idx + /// Wrap `inner` with the given keys. For an AACS source, install a key map + /// via [`with_key_map`](Self::with_key_map) before reading — AACS decrypts + /// only through the map and fails loud without one. pub fn new(inner: S, keys: DecryptKeys) -> Self { Self { inner, keys, - unit_key_idx: 0, unit_base: 0, content_ranges: None, key_map: None, @@ -175,13 +169,6 @@ impl DecryptingSectorSource { self } - /// Override the AACS unit-key index. Only meaningful for - /// [`DecryptKeys::Aacs`]; other variants ignore it. - pub fn with_unit_key_idx(mut self, idx: usize) -> Self { - self.unit_key_idx = idx; - self - } - /// Replace the configured keys without unwrapping the decorator. /// Used by `DiscStream::set_raw()` to flip from encrypted-disc /// decryption to a pass-through after the inner reader is already @@ -216,13 +203,14 @@ impl DecryptingSectorSource { fn decrypt_buf( buf: &mut [u8], keys: &mut DecryptKeys, - unit_key_idx: usize, lba: u32, content: Option<&[(u32, u32)]>, ) -> Result { + // The `unit_key_idx` arg on `decrypt_sectors[_in_content]` is a legacy + // inert param (AACS is map-only; CSS/None ignore it) — pass 0. match content { - Some(ranges) => decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges), - None => decrypt_sectors(buf, keys, unit_key_idx), + Some(ranges) => decrypt_sectors_in_content(buf, keys, 0, lba, ranges), + None => decrypt_sectors(buf, keys, 0), } } } @@ -302,13 +290,7 @@ impl SectorSource for DecryptingSectorSource { // `Err` and propagates; otherwise every unit gets its key applied and the // bytes pass through — a unit that decrypts to broken TS is the consumer's // concern (the muxer drops it), never a read failure. - Self::decrypt_buf( - &mut buf[..n], - &mut self.keys, - self.unit_key_idx, - lba, - content_ref, - )?; + Self::decrypt_buf(&mut buf[..n], &mut self.keys, lba, content_ref)?; Ok(n) } @@ -612,10 +594,10 @@ mod tests { assert_eq!(io.kind(), std::io::ErrorKind::TimedOut); } - /// With AACS keys but an out-of-range `unit_key_idx`, the decrypt - /// step must fail (DecryptFailed) rather than silently returning - /// still-encrypted bytes. Grounding: `decrypt_sectors`' unit-key - /// lookup — `unit_keys.get(idx)` → None → Error::DecryptFailed. + /// An AACS source reaching the decrypt step WITHOUT an installed key map + /// must fail loud (DecryptFailed) rather than silently return still-encrypted + /// bytes. Grounding: the map-only model — `decrypt_sectors`' AACS arm + /// unconditionally returns `Error::DecryptFailed` when no map path handled it. #[test] fn aacs_missing_unit_key_errors() { let src = PatternedSource { capacity: 16 };