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<SampleRef> 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).
This commit is contained in:
Matthew Jackson
2026-07-23 22:09:04 -07:00
parent 8ac18fa631
commit b9568242df
10 changed files with 894 additions and 100 deletions
+15 -15
View File
@@ -201,9 +201,13 @@ pub enum Phase {
/// concern, exactly as for a physically-read clear disc. /// concern, exactly as for a physically-read clear disc.
/// ///
/// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` pool, /// 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 /// sorted and disjoint. The map is a POSITIVE list: an LBA in no range is passed
/// single-CPS case is just an empty range list with `default_idx = 0`, so the /// through untouched (no default key). How a single-CPS disc is mapped depends on
/// common disc pays zero lookup cost and needs no structural walk. /// 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct AacsKeyMap { pub struct AacsKeyMap {
// (start_lba, end_lba, key_idx, phase). An LBA in NO range is passed through // (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) decrypt_sectors_impl(buf, keys, unit_key_idx, None)
} }
/// Like [`decrypt_sectors`], but ONLY decrypts/verifies units whose absolute LBA /// Legacy alias of [`decrypt_sectors`]. Under the keymap-only model AACS decrypts
/// falls inside `content_ranges` — the disc's AACS-encrypted content (the m2ts /// EXCLUSIVELY through the resolved key map (`decrypt_sectors_mapped`), so there is
/// stream extents). Units OUTSIDE content (UDF filesystem / nav) are left /// no per-unit content-extent gate here any more: the AACS arm fails loud and the
/// untouched and never counted as decrypt loss: they are clear by definition, so /// CSS / `None` arm self-gates on its per-sector scramble flag. `base_lba` and
/// the content-clarity check [`is_clean`](crate::aacs::content::is_clean) must not /// `content_ranges` are therefore inert — retained only so the wrapper signature
/// be consulted about them (a filesystem unit has no TS sync, so it would /// stays stable for the `DecryptingSectorSource` dispatch. Prefer
/// otherwise be mistaken for ciphertext). `base_lba` is /// [`decrypt_sectors`] in new code.
/// 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)`).
pub fn decrypt_sectors_in_content( pub fn decrypt_sectors_in_content(
buf: &mut [u8], buf: &mut [u8],
keys: &mut DecryptKeys, keys: &mut DecryptKeys,
@@ -607,7 +607,7 @@ mod tests {
v 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. /// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes.
#[test] #[test]
+7
View File
@@ -202,6 +202,13 @@ impl Disc {
// real sample from it), up front before the decorator takes the reader. A // 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 // content unit whose key the pool lacks fails loud at resolve (extract has
// no CPS/forensic fetch source), never emits a wrong-key garble. // 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 = let key_map =
match &base_keys { match &base_keys {
DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => { DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => {
+34 -8
View File
@@ -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 /// Merge per-title AACS key ranges into the sorted, disjoint set the whole-disc map
/// needs ([`crate::decrypt::AacsKeyMap::entry_for`] requires disjoint ranges). /// needs ([`crate::decrypt::AacsKeyMap::entry_for`] requires disjoint ranges).
/// Titles that share a clip resolve the SAME physical span (same LBAs → same CPS /// 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 /// unit → same key). When a later range overlaps a kept one that carries the SAME
/// end is that duplicate and is dropped. A real disc never produces two DIFFERENT /// key index and phase, the two are UNIONED (end extended to the max) — this covers
/// keys for one LBA, so the drop is a dedup, not a conflict resolution. /// 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( fn merge_content_key_ranges(
mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>, mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>,
) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> { ) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> {
ranges.sort_by_key(|&(s, _, _, _)| s); ranges.sort_by_key(|&(s, _, _, _)| s);
let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
for r in ranges { for r in ranges {
if merged.last().is_none_or(|&(_, e, _, _)| r.0 >= e) { match merged.last_mut() {
merged.push(r); // 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 merged
@@ -4364,14 +4377,27 @@ mod tests {
assert_eq!(merge_content_key_ranges(v), vec![(100, 300, 0, Phase::All)]); 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 /// A later range that partially overlaps a kept one carrying the SAME key is
/// dropped — the map stays disjoint rather than admitting an ambiguous LBA. /// 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] #[test]
fn merge_key_ranges_drops_overlap() { fn merge_key_ranges_unions_same_key_overlap() {
let v = vec![ let v = vec![
(100u32, 400u32, 0usize, Phase::All), (100u32, 400u32, 0usize, Phase::All),
(200, 500, 0, 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)]); assert_eq!(merge_content_key_ranges(v), vec![(100, 400, 0, Phase::All)]);
} }
+3 -2
View File
@@ -848,8 +848,9 @@ impl From<Error> for std::io::Error {
// 9023 MuxEmpty: finish() reached with zero frames — the output // 9023 MuxEmpty: finish() reached with zero frames — the output
// would be a header-only container. Treat as invalid output. // would be a header-only container. Treat as invalid output.
E_MUX_EMPTY => std::io::ErrorKind::InvalidData, E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
// mp4:// track/config mismatches: the requested output can't hold // mp4:// demux errors: a malformed/truncated source file
// this title's video — invalid output request. // (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 => { E_MP4_NO_VIDEO_TRACK | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE => {
std::io::ErrorKind::InvalidData std::io::ErrorKind::InvalidData
} }
+45
View File
@@ -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] #[test]
fn codec_private_none() { fn codec_private_none() {
let parser = DtsParser::new(); let parser = DtsParser::new();
+16
View File
@@ -177,6 +177,22 @@ mod tests {
assert_eq!(p.dropped_frames(), 0); 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] #[test]
fn corrupt_frame_is_dropped() { fn corrupt_frame_is_dropped() {
let mut p = FlacParser::new(); let mut p = FlacParser::new();
+16
View File
@@ -167,6 +167,22 @@ mod tests {
assert_eq!(p.dropped_frames(), 0); 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] #[test]
fn reserved_version_field_is_dropped() { fn reserved_version_field_is_dropped() {
// version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB // version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB
+380 -7
View File
@@ -93,10 +93,17 @@ impl<R: Read + Seek> Mp4Reader<R> {
let mut track_idx = 0usize; let mut track_idx = 0usize;
// Global cap on total decoded samples across ALL tracks — a crafted file // 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 // with many `trak` boxes must not sum past this even though each track is
// individually bounded. Real titles stay far under it. // individually bounded. Real titles stay far under it. Also bound by
let mut sample_budget = MAX_SAMPLE_COUNT; // `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<SampleRef>` 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 { if track_idx >= MAX_TRACKS {
break; // bound track count so the per-track PID can't overflow u16 break; // bound track count so the per-track PID can't overflow u16
} }
@@ -348,14 +355,20 @@ fn read_moov<R: Read + Seek>(file: &mut R) -> io::Result<Vec<u8>> {
/// The first child box of `payload` with the given type — returns its payload /// The first child box of `payload` with the given type — returns its payload
/// (bytes after the 8-byte header). One level. /// (bytes after the 8-byte header). One level.
fn find_box<'a>(payload: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> { 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. /// 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]> { /// 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 out = Vec::new();
let mut pos = 0; let mut pos = 0;
while pos + 8 <= payload.len() { while pos + 8 <= payload.len() && out.len() < cap {
let size = u32::from_be_bytes([ let size = u32::from_be_bytes([
payload[pos], payload[pos],
payload[pos + 1], payload[pos + 1],
@@ -1046,6 +1059,65 @@ mod tests {
assert_eq!(parse_esds_asc(&esds[..12]), None); 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<u8>,
pos: u64,
fake_len: u64,
}
impl Read for InflatedReader {
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
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<u64> {
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] #[test]
fn read_moov_size_zero_spans_to_eof() { fn read_moov_size_zero_spans_to_eof() {
use std::io::Cursor; use std::io::Cursor;
@@ -1086,6 +1158,307 @@ mod tests {
assert!(read_moov(&mut Cursor::new(b)).is_err()); 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<u8> {
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<u8> {
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(&timescale.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<SampleRef>` 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<u8> {
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] #[test]
fn mdhd_language_offsets_per_version() { fn mdhd_language_offsets_per_version() {
// "eng" packed = 0x15C7. v0 carries it at byte 20, v1 (64-bit times) at 32. // "eng" packed = 0x15C7. v0 carries it at byte 20, v1 (64-bit times) at 32.
+363 -35
View File
@@ -795,10 +795,7 @@ fn resolve_fmts_key_map(
// resolves EVERY title for the whole-disc sweep — aborts the entire decrypt on // 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` // the first non-forensic title (a menu playlist), and `build_iso_pipeline`
// aborts muxing any non-main title. // aborts muxing any non-main title.
let segments: Vec<crate::aacs::segment::Segment> = segments let segments = filter_addressable_segments(segments, &title.extents);
.into_iter()
.filter(|s| clip_byte_to_lba(&title.extents, s.start_spn as u64 * 192).is_some())
.collect();
if segments.is_empty() { if segments.is_empty() {
return Ok(None); return Ok(None);
} }
@@ -931,24 +928,25 @@ fn resolve_fmts_key_map(
} }
} }
} }
let phase = match even.cmp(&odd) { let phase = match resolve_tie_phase(even, odd) {
std::cmp::Ordering::Greater => crate::decrypt::Phase::Even, Ok(p) => {
std::cmp::Ordering::Less => crate::decrypt::Phase::Odd, if even == odd {
std::cmp::Ordering::Equal if even == 0 => { // 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 // 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 // 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. // 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"); 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()); return Err(e);
}
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
} }
}; };
phase_of_index.insert(tag, phase); 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 // (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 // 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. // 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<crate::aacs::segment::Segment>,
extents: &[crate::disc::Extent],
) -> Vec<crate::aacs::segment::Segment> {
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<crate::decrypt::Phase> {
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 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.sort_unstable();
c 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 end = ext.start_lba.saturating_add(ext.sector_count);
let mut cur = ext.start_lba; let mut cur = ext.start_lba;
for &(cs, ce) in &cuts { for &(cs, ce) in &cuts {
@@ -1019,16 +1085,26 @@ fn resolve_fmts_key_map(
continue; // cut outside this extent continue; // cut outside this extent
} }
if cs > cur { 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); cur = cur.max(ce);
} }
if cur < end { 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 /// 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 /// 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 /// 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. /// 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( pub fn resolve_mux_key_map(
reader: &mut dyn SectorSource, reader: &mut dyn SectorSource,
title: &DiscTitle, title: &DiscTitle,
@@ -1949,4 +2013,268 @@ mod tests {
"a scrambled DVD title with no key must hard-fail, not build a scrambled-passthrough pipeline" "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);
}
} }
+15 -33
View File
@@ -93,15 +93,12 @@ impl KeyFetch {
/// Decorator: read from `inner`, then run the configured /// Decorator: read from `inner`, then run the configured
/// AACS / CSS decrypt over the bytes that landed in `buf`. /// AACS / CSS decrypt over the bytes that landed in `buf`.
/// ///
/// `unit_key_idx` selects the AACS unit key for the disc (0 for /// AACS decrypts EXCLUSIVELY through the installed [`key_map`](Self::key_map)
/// the vast majority of titles; the rare multi-CPS-unit discs pick /// (one key per CPS unit / segment, resolved up front); CSS self-descrambles on
/// the index that covers the title being read). For /// its per-sector scramble flag; [`DecryptKeys::None`] is a pass-through.
/// [`DecryptKeys::None`] and [`DecryptKeys::Css`] the index is
/// ignored.
pub struct DecryptingSectorSource<S: SectorSource> { pub struct DecryptingSectorSource<S: SectorSource> {
inner: S, inner: S,
keys: DecryptKeys, keys: DecryptKeys,
unit_key_idx: usize,
/// Base LBA of the encrypted region currently being read — the clip / /// Base LBA of the encrypted region currently being read — the clip /
/// extent `start_lba` that AACS aligned units are anchored at. The unit- /// extent `start_lba` that AACS aligned units are anchored at. The unit-
/// alignment gate measures `lba` relative to THIS, not absolute disc LBA 0, /// alignment gate measures `lba` relative to THIS, not absolute disc LBA 0,
@@ -130,16 +127,13 @@ pub struct DecryptingSectorSource<S: SectorSource> {
} }
impl<S: SectorSource> DecryptingSectorSource<S> { impl<S: SectorSource> DecryptingSectorSource<S> {
/// Wrap `inner` with the given keys. The default unit-key /// Wrap `inner` with the given keys. For an AACS source, install a key map
/// index is 0; use [`with_unit_key_idx`] for the multi-CPS-unit /// via [`with_key_map`](Self::with_key_map) before reading — AACS decrypts
/// case. /// only through the map and fails loud without one.
///
/// [`with_unit_key_idx`]: Self::with_unit_key_idx
pub fn new(inner: S, keys: DecryptKeys) -> Self { pub fn new(inner: S, keys: DecryptKeys) -> Self {
Self { Self {
inner, inner,
keys, keys,
unit_key_idx: 0,
unit_base: 0, unit_base: 0,
content_ranges: None, content_ranges: None,
key_map: None, key_map: None,
@@ -175,13 +169,6 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
self 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. /// Replace the configured keys without unwrapping the decorator.
/// Used by `DiscStream::set_raw()` to flip from encrypted-disc /// Used by `DiscStream::set_raw()` to flip from encrypted-disc
/// decryption to a pass-through after the inner reader is already /// decryption to a pass-through after the inner reader is already
@@ -216,13 +203,14 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
fn decrypt_buf( fn decrypt_buf(
buf: &mut [u8], buf: &mut [u8],
keys: &mut DecryptKeys, keys: &mut DecryptKeys,
unit_key_idx: usize,
lba: u32, lba: u32,
content: Option<&[(u32, u32)]>, content: Option<&[(u32, u32)]>,
) -> Result<usize> { ) -> Result<usize> {
// 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 { match content {
Some(ranges) => decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges), Some(ranges) => decrypt_sectors_in_content(buf, keys, 0, lba, ranges),
None => decrypt_sectors(buf, keys, unit_key_idx), None => decrypt_sectors(buf, keys, 0),
} }
} }
} }
@@ -302,13 +290,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// `Err` and propagates; otherwise every unit gets its key applied and the // `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 // bytes pass through — a unit that decrypts to broken TS is the consumer's
// concern (the muxer drops it), never a read failure. // concern (the muxer drops it), never a read failure.
Self::decrypt_buf( Self::decrypt_buf(&mut buf[..n], &mut self.keys, lba, content_ref)?;
&mut buf[..n],
&mut self.keys,
self.unit_key_idx,
lba,
content_ref,
)?;
Ok(n) Ok(n)
} }
@@ -612,10 +594,10 @@ mod tests {
assert_eq!(io.kind(), std::io::ErrorKind::TimedOut); assert_eq!(io.kind(), std::io::ErrorKind::TimedOut);
} }
/// With AACS keys but an out-of-range `unit_key_idx`, the decrypt /// An AACS source reaching the decrypt step WITHOUT an installed key map
/// step must fail (DecryptFailed) rather than silently returning /// must fail loud (DecryptFailed) rather than silently return still-encrypted
/// still-encrypted bytes. Grounding: `decrypt_sectors`' unit-key /// bytes. Grounding: the map-only model — `decrypt_sectors`' AACS arm
/// lookup — `unit_keys.get(idx)` → None → Error::DecryptFailed. /// unconditionally returns `Error::DecryptFailed` when no map path handled it.
#[test] #[test]
fn aacs_missing_unit_key_errors() { fn aacs_missing_unit_key_errors() {
let src = PatternedSource { capacity: 16 }; let src = PatternedSource { capacity: 16 };