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
+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]
fn codec_private_none() {
let parser = DtsParser::new();
+16
View File
@@ -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();
+16
View File
@@ -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
+380 -7
View File
@@ -93,10 +93,17 @@ impl<R: Read + Seek> Mp4Reader<R> {
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<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 {
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
/// (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<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]
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<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]
fn mdhd_language_offsets_per_version() {
// "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
// the first non-forensic title (a menu playlist), and `build_iso_pipeline`
// aborts muxing any non-main title.
let segments: Vec<crate::aacs::segment::Segment> = 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<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 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);
}
}