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:
+15
-15
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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(×cale.to_be_bytes());
|
||||||
|
mp4_box(b"mdhd", &p)
|
||||||
|
};
|
||||||
|
let hdlr = {
|
||||||
|
// version+flags(4) pre_defined(4) handler_type(4).
|
||||||
|
let mut p = vec![0u8; 12];
|
||||||
|
p[8..12].copy_from_slice(b"soun");
|
||||||
|
mp4_box(b"hdlr", &p)
|
||||||
|
};
|
||||||
|
let stsd = {
|
||||||
|
let mut p = Vec::new();
|
||||||
|
p.extend_from_slice(&[0, 0, 0, 0]); // version+flags
|
||||||
|
p.extend_from_slice(&1u32.to_be_bytes()); // entry_count
|
||||||
|
p.extend_from_slice(&8u32.to_be_bytes()); // sample entry size (header only)
|
||||||
|
p.extend_from_slice(b"ac-3"); // fourcc → Codec::Ac3
|
||||||
|
mp4_box(b"stsd", &p)
|
||||||
|
};
|
||||||
|
let stsz = {
|
||||||
|
let mut p = Vec::new();
|
||||||
|
p.extend_from_slice(&[0, 0, 0, 0]); // version+flags
|
||||||
|
p.extend_from_slice(&10u32.to_be_bytes()); // sample_size (fixed) = 10
|
||||||
|
p.extend_from_slice(&1u32.to_be_bytes()); // count = 1
|
||||||
|
mp4_box(b"stsz", &p)
|
||||||
|
};
|
||||||
|
let stco = {
|
||||||
|
let mut p = Vec::new();
|
||||||
|
p.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
p.extend_from_slice(&1u32.to_be_bytes()); // count
|
||||||
|
p.extend_from_slice(&0u32.to_be_bytes()); // chunk offset 0
|
||||||
|
mp4_box(b"stco", &p)
|
||||||
|
};
|
||||||
|
let stsc = {
|
||||||
|
let mut p = Vec::new();
|
||||||
|
p.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
p.extend_from_slice(&1u32.to_be_bytes()); // count
|
||||||
|
p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
|
||||||
|
p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk
|
||||||
|
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
|
||||||
|
mp4_box(b"stsc", &p)
|
||||||
|
};
|
||||||
|
let mut stbl = Vec::new();
|
||||||
|
stbl.extend_from_slice(&stsd);
|
||||||
|
stbl.extend_from_slice(&stsz);
|
||||||
|
stbl.extend_from_slice(&stco);
|
||||||
|
stbl.extend_from_slice(&stsc);
|
||||||
|
let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl));
|
||||||
|
let mut mdia = Vec::new();
|
||||||
|
mdia.extend_from_slice(&mdhd);
|
||||||
|
mdia.extend_from_slice(&hdlr);
|
||||||
|
mdia.extend_from_slice(&minf);
|
||||||
|
mp4_box(b"trak", &mp4_box(b"mdia", &mdia))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mdhd_timescale_zero_does_not_divide_by_zero() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
// Without the `.filter(|&t| t != 0)` guard the per-sample `to_ns` closure
|
||||||
|
// divides by the zero timescale and panics; with it the track falls back
|
||||||
|
// to the 90 kHz default and is indexed normally.
|
||||||
|
let moov = mp4_box(b"moov", &audio_trak(0));
|
||||||
|
let rd = Mp4Reader::from_reader(Cursor::new(moov), "ts0".into());
|
||||||
|
assert!(
|
||||||
|
rd.is_ok(),
|
||||||
|
"timescale 0 must be handled via fallback, no divide-by-zero panic"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rd.unwrap().info().streams.len(),
|
||||||
|
1,
|
||||||
|
"the timescale-0 track is still indexed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trak_loop_stops_at_max_tracks() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
// A crafted moov packing more than MAX_TRACKS trak boxes must not index
|
||||||
|
// past the cap — the per-track PID `0x1011 + idx` overflows u16 past ~61k
|
||||||
|
// tracks. Without the cap this indexes all MAX_TRACKS + 50 tracks.
|
||||||
|
let mut traks = Vec::new();
|
||||||
|
for _ in 0..(MAX_TRACKS + 50) {
|
||||||
|
traks.extend_from_slice(&audio_trak(48_000));
|
||||||
|
}
|
||||||
|
let moov = mp4_box(b"moov", &traks);
|
||||||
|
let rd = Mp4Reader::from_reader(Cursor::new(moov), "many".into()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rd.info().streams.len(),
|
||||||
|
MAX_TRACKS,
|
||||||
|
"trak loop must stop at MAX_TRACKS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A crafted file with a *fixed-size* `stsz` (sample_size != 0) claiming
|
||||||
|
/// count = 0xFFFFFFFF must not inflate the sample index past the file's own
|
||||||
|
/// byte length. `from_reader` sets `sample_budget = MAX_SAMPLE_COUNT.min(file_len)`,
|
||||||
|
/// so a few-hundred-byte file bounds the `Vec<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
@@ -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
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user