From 197489fb7cb2600c2896f499dd2276bd30989241 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:00:00 -0700 Subject: [PATCH] Cache AacsKeyMap key indices; extract + test whole-disc range merge - AacsKeyMap now derives its distinct key-index set once at construction (from_ranges_phased) instead of re-allocating/sorting it on every decrypt batch; key_indices() returns the cached slice. - Extract the whole-disc content-map range merge out of resolve_content_key_map into merge_content_key_ranges and cover it: sort/disjoint, shared-clip dedup, overlap drop, adjacent-kept. --- src/decrypt.rs | 24 ++++++++----- src/disc/mod.rs | 89 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/decrypt.rs b/src/decrypt.rs index 487febe..ceab7f8 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -209,6 +209,10 @@ pub struct AacsKeyMap { // (start_lba, end_lba, key_idx, phase). An LBA in NO range is passed through // untouched — the map is a positive list of "this key here", nothing more. ranges: Vec<(u32, u32, usize, Phase)>, + // Distinct, sorted key indices the map selects — derived from `ranges` once at + // construction so the per-batch decrypt bounds check does not re-allocate/sort + // it on every read. Kept in sync by building both in `from_ranges_phased`. + key_indices: Vec, } impl AacsKeyMap { @@ -228,7 +232,13 @@ impl AacsKeyMap { /// for base/CPS). Ranges are sorted; an LBA in no range is passed through. pub fn from_ranges_phased(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Self { ranges.sort_by_key(|&(start, _, _, _)| start); - Self { ranges } + let mut key_indices: Vec = ranges.iter().map(|&(_, _, i, _)| i).collect(); + key_indices.sort_unstable(); + key_indices.dedup(); + Self { + ranges, + key_indices, + } } /// The `(key_idx, phase, range_start_lba)` for the aligned unit at `lba`, or @@ -264,12 +274,10 @@ impl AacsKeyMap { } /// The distinct key indices this map selects — the CPS units / segments the - /// title actually reaches. The resolver secures exactly these up front. - pub fn key_indices(&self) -> Vec { - let mut v: Vec = self.ranges.iter().map(|&(_, _, i, _)| i).collect(); - v.sort_unstable(); - v.dedup(); - v + /// title actually reaches. The resolver secures exactly these up front. Computed + /// once at construction (see [`from_ranges_phased`](Self::from_ranges_phased)). + pub fn key_indices(&self) -> &[usize] { + &self.key_indices } /// Build the FMTS **read plan**: the title's aligned units filtered down to @@ -392,7 +400,7 @@ pub fn decrypt_sectors_mapped( // Validate every selectable index up front (fail loud) so the per-unit hot // loop can index without bounds churn and a resolver gap never silently // passes ciphertext through as "decrypted". - for idx in map.key_indices() { + for &idx in map.key_indices() { if unit_keys.get(idx).is_none() { return Err(crate::error::Error::DecryptFailed); } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 8c7864d..415dbf7 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -589,6 +589,25 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut } } +/// Merge per-title AACS key ranges into the sorted, disjoint set the whole-disc map +/// needs ([`crate::decrypt::AacsKeyMap::entry_for`] requires disjoint ranges). +/// Titles that share a clip resolve the SAME physical span (same LBAs → same CPS +/// unit → same key), so a later range that starts before the previous kept range's +/// end is that duplicate and is dropped. A real disc never produces two DIFFERENT +/// keys for one LBA, so the drop is a dedup, not a conflict resolution. +fn merge_content_key_ranges( + mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>, +) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> { + ranges.sort_by_key(|&(s, _, _, _)| s); + let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); + for r in ranges { + if merged.last().is_none_or(|&(_, e, _, _)| r.0 >= e) { + merged.push(r); + } + } + merged +} + /// Calculate how many bytes of bad/unreadable data fall within a title's extents. /// `pub(crate)` so autorip can use it for main-movie lost_ms computation. pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 { @@ -2376,16 +2395,9 @@ impl Disc { crate::mux::resolve_mux_key_map(reader, title, keys, fetch, self.content_format)?; ranges.extend_from_slice(map.ranges()); } - ranges.sort_by_key(|&(s, _, _, _)| s); - let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); - for r in ranges { - // Drop a range that overlaps one already kept (a clip shared by two - // titles resolves the same span twice) — entry_for needs disjoint ranges. - if merged.last().is_none_or(|&(_, e, _, _)| r.0 >= e) { - merged.push(r); - } - } - Ok(crate::decrypt::AacsKeyMap::from_ranges_phased(merged)) + Ok(crate::decrypt::AacsKeyMap::from_ranges_phased( + merge_content_key_ranges(ranges), + )) } /// The disc's AACS-encrypted content as a sorted, merged, disjoint set of @@ -4320,6 +4332,63 @@ mod tests { assert_eq!(merged_extents(v.iter()), vec![(100, 50)]); } + // ── merge_content_key_ranges (whole-disc AACS map assembly) ──────────────── + use crate::decrypt::Phase; + + /// Ranges from different titles are sorted by start LBA and kept disjoint. + #[test] + fn merge_key_ranges_sorts_and_keeps_disjoint() { + let v = vec![ + (500u32, 600u32, 1usize, Phase::All), + (100, 200, 0, Phase::All), + (300, 400, 2, Phase::All), + ]; + assert_eq!( + merge_content_key_ranges(v), + vec![ + (100, 200, 0, Phase::All), + (300, 400, 2, Phase::All), + (500, 600, 1, Phase::All), + ] + ); + } + + /// A clip shared by two titles resolves the SAME span twice; the duplicate is + /// dropped so `entry_for` sees a disjoint set (one key for the span). + #[test] + fn merge_key_ranges_dedups_shared_clip_span() { + let v = vec![ + (100u32, 300u32, 0usize, Phase::All), + (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 + /// dropped — the map stays disjoint rather than admitting an ambiguous LBA. + #[test] + fn merge_key_ranges_drops_overlap() { + let v = vec![ + (100u32, 400u32, 0usize, Phase::All), + (200, 500, 0, Phase::All), + ]; + assert_eq!(merge_content_key_ranges(v), vec![(100, 400, 0, Phase::All)]); + } + + /// Adjacent (touching) ranges are BOTH kept — `r.0 >= prev_end` holds when the + /// next starts exactly at the previous end, so no coverage is lost. + #[test] + fn merge_key_ranges_keeps_adjacent() { + let v = vec![ + (100u32, 200u32, 0usize, Phase::All), + (200, 300, 1, Phase::All), + ]; + assert_eq!( + merge_content_key_ranges(v), + vec![(100, 200, 0, Phase::All), (200, 300, 1, Phase::All)] + ); + } + /// A Windows-form optical device path (`\\.\CdRom0`, `\\.\D:`) must never /// fall through to the block default (8192 sectors = 16 MiB, well over the /// optical 510-sector cap). It has no forward slash, so the Linux-sysfs