Memoise multi-CPS key-map sampling per extent, not per title

resolve_content_key_map calls resolve_mux_key_map once per title, and on a
multi-CPS disc that path issues 8 random single-unit reads per extent. A disc's
playlists overwhelmingly reference the same few clips — main feature, play-all,
per-chapter and seamless-branch variants — so the same physical extents were
re-sampled from the drive once per playlist. On a 60-playlist / 15-clip disc
that is ~2,400 non-sequential 6144-byte reads, roughly 8 minutes of pure seeking
at 200 ms per seek, before the mux starts. Now ~600 reads.

Keyed per EXTENT — (format, start_lba, sector_count) — rather than per title's
whole extent list, which is finer-grained than the forced-subtitle probe's cache
and strictly better here: a play-all playlist sharing 4 of 5 extents with the
main feature still hits on those 4.

Why a cached pool index is provably identical to a recomputed one, verified
rather than assumed:

  * `pick` iterates the pool IN ORDER and returns the FIRST index whose key
    decrypts a sample to clean.
  * The pool is APPEND-ONLY. Checked across the whole crate: only `push`, with no
    insert/remove/clear/retain/sort/dedup/truncate/drain/swap/reverse anywhere.
    So appended keys can only land AFTER a matched index, and the first match for
    the same samples cannot shift.
  * The samples are a pure function of the three values in the key, read from
    read-only optical media.

Two outcomes are deliberately NOT cached, which is what makes this safe rather
than merely faster:

  * the inherited index (`None if samples.is_empty() => last_idx`) is per-TITLE
    state, not a property of the extent — caching it would let one title's
    carry-in index leak into another title's clear extent, i.e. a WRONG key;
  * the fail-loud DecryptFailed verdict, so a retry after a key source banks the
    missing key re-samples instead of inheriting a stale answer.

Halt is still polled before the cache lookup, so cancellation is unchanged.
resolve_mux_key_map keeps its exact signature and delegates with a fresh cache,
so there is no public API change. ContentFormat gains Eq + Hash (additive).

Four tests, and the two mutants that matter both verified red: disabling the
cache short-circuit fails the hit and recompute-equivalence tests, and wrongly
caching the inherited index fails multi_cps_inherited_index_is_not_cached.
This commit is contained in:
Matthew Jackson
2026-07-29 19:49:55 -07:00
parent e3676e7cdf
commit 38aa895038
2 changed files with 447 additions and 3 deletions
+10 -2
View File
@@ -76,7 +76,7 @@ pub struct Disc {
}
/// Content format — determines how sectors are interpreted downstream.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContentFormat {
/// Blu-ray BD Transport Stream (192-byte packets)
BdTs,
@@ -2396,14 +2396,22 @@ impl Disc {
halt: Option<&crate::halt::Halt>,
) -> Result<crate::decrypt::AacsKeyMap> {
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
// One multi-CPS extent cache across every title. A disc's playlists
// overwhelmingly reference the same handful of clips (main feature,
// play-all, per-chapter and seamless-branch variants), so without this the
// same extents are re-sampled off the drive once per playlist — 8 random
// 6144-byte reads each, ~200 ms of seek apiece on a stock BD drive, all to
// recompute the same index from byte-identical input.
let mut cps_cache = crate::mux::resolve::CpsUnitCache::new();
for title in &self.titles {
let map = crate::mux::resolve_mux_key_map(
let map = crate::mux::resolve::resolve_mux_key_map_cached(
reader,
title,
keys,
fetch,
self.content_format,
halt,
&mut cps_cache,
)?;
ranges.extend_from_slice(map.ranges());
}
+437 -1
View File
@@ -1265,6 +1265,62 @@ pub fn resolve_mux_key_map(
fetch: Option<&crate::sector::KeyFetch>,
format: ContentFormat,
halt: Option<&crate::halt::Halt>,
) -> io::Result<crate::decrypt::AacsKeyMap> {
// One-shot: a single title shares no extents with anything, so a fresh cache
// never hits. Multi-title callers use `resolve_mux_key_map_cached` instead.
resolve_mux_key_map_cached(
reader,
title,
keys,
fetch,
format,
halt,
&mut CpsUnitCache::new(),
)
}
/// Memoises the multi-CPS "which held unit key opens this extent" decision across
/// the titles of ONE disc, for [`resolve_mux_key_map_cached`].
///
/// Keyed by content format plus the extent's exact `(start_lba, sector_count)`, so
/// a hit returns the index that was resolved from *those same physical bytes* —
/// see the safety argument on [`resolve_mux_key_map_cached`]. Only successfully
/// resolved extents are memoised; a no-samples extent (whose index is inherited
/// from the preceding extent of the SAME title, i.e. not a property of the extent)
/// and the fail-loud "no key opens it" outcome are never cached.
///
/// A disc's playlists overwhelmingly reference the same handful of clips (main
/// feature, play-all, per-chapter and seamless-branch variants), so without this
/// the same extents are re-sampled off the drive once per playlist: 8 random
/// 6144-byte reads each, ~200 ms of seek apiece on a stock BD drive.
pub(crate) type CpsUnitCache = std::collections::HashMap<(ContentFormat, u32, u32), usize>;
/// [`resolve_mux_key_map`] with a caller-owned multi-CPS extent cache
/// ([`CpsUnitCache`]) shared across the titles of one disc.
///
/// # Why a cache cannot change a resolved key
///
/// The cached value is the pool index `pick` chose for an extent, and every input
/// to that choice is stable across the titles of one disc:
///
/// * The samples are a deterministic function of the extent's `(start_lba,
/// sector_count)` and `format` — both in the key — read from unchanging media.
/// * The key pool is APPEND-only here (base-key fetch and the FMTS resolver only
/// push), and `pick` returns the FIRST pool entry that opens a sample, so a
/// later, longer pool yields the same first match for the same samples.
/// * [`crate::sector::KeyFetch`] is stateless by contract, and any key a hit's
/// index refers to was already banked into the pool on the miss that filled the
/// entry — so skipping the re-fetch skips no side effect the map depends on.
///
/// Halt is still polled per extent, so cancellation behaves as before.
pub(crate) fn resolve_mux_key_map_cached(
reader: &mut dyn SectorSource,
title: &DiscTitle,
keys: &mut crate::decrypt::DecryptKeys,
fetch: Option<&crate::sector::KeyFetch>,
format: ContentFormat,
halt: Option<&crate::halt::Halt>,
cache: &mut CpsUnitCache,
) -> io::Result<crate::decrypt::AacsKeyMap> {
use crate::aacs::content::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
@@ -1342,6 +1398,18 @@ pub fn resolve_mux_key_map(
if halt.is_some_and(|h| h.is_cancelled()) {
return Err(crate::error::Error::Halted.into());
}
// Already resolved for this exact extent (a clip another playlist shares):
// reuse the index instead of re-sampling the same physical units.
let ck = (format, ext.start_lba, ext.sector_count);
if let Some(&hit) = cache.get(&ck) {
last_idx = hit;
ranges.push((
ext.start_lba,
ext.start_lba.saturating_add(ext.sector_count),
hit,
));
continue;
}
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
// Snapshot the current pool for the pure `pick` closure.
let pool: Vec<(u32, [u8; 16])> = match keys {
@@ -1373,8 +1441,18 @@ pub fn resolve_mux_key_map(
// encrypted content is classified. An extent with no sampleable encrypted
// units (nothing to mis-decrypt) carries the previous index harmlessly.
let idx = match idx {
Some(i) => i,
// A real decision from this extent's own ciphertext — memoise it.
Some(i) => {
cache.insert(ck, i);
i
}
// Inherited from the PRECEDING extent of THIS title, so it is not a
// property of this extent: never cache it (another title reaching the
// same extent may carry a different previous index).
None if samples.is_empty() => last_idx,
// Fail-loud, and not cached: a later extent's fetch may bank the key
// that opens this one, so a retry must re-sample rather than inherit a
// stale verdict.
None => return Err(crate::error::Error::DecryptFailed.into()),
};
last_idx = idx;
@@ -2952,6 +3030,364 @@ mod tests {
);
}
// ── Multi-CPS extent cache: shared clips are sampled ONCE per disc ───────
/// LBA below which a read is disc metadata, not content sampling: the FMTS
/// branch probes the UDF anchor/metadata before concluding "not an FMTS disc",
/// and every content extent in these tests starts at or above this.
const CONTENT_LBA_FLOOR: u32 = 1000;
/// [`CipherSource`] plus a counter of CONTENT reads (`lba >=
/// CONTENT_LBA_FLOOR`) — the `sample_units` probes whose cost this cache
/// exists to remove. Low-LBA UDF metadata probes are excluded so the counts
/// speak only about extent sampling.
struct CountingCipherSource {
inner: CipherSource,
probes: u32,
}
impl CountingCipherSource {
fn new(units: Vec<(u32, u32, Vec<u8>)>) -> Self {
Self {
inner: CipherSource { units },
probes: 0,
}
}
}
impl SectorSource for CountingCipherSource {
fn capacity_sectors(&self) -> u32 {
self.inner.capacity_sectors()
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> crate::error::Result<usize> {
if lba >= CONTENT_LBA_FLOOR {
self.probes += 1;
}
self.inner.read_sectors(lba, count, buf, recovery)
}
}
/// A disc's playlists overwhelmingly share clips, and the multi-CPS path issues
/// 8 random 6144-byte reads per extent. The SECOND title over the same extent
/// must cost ZERO further reads and resolve the SAME index; a DIFFERENT extent
/// must still be sampled and get its own index.
///
/// Mutation: dropping the `cache.get` short-circuit re-samples → the
/// zero-further-reads assert fails. Caching the wrong index (e.g. inserting
/// `last_idx`) breaks the same-index asserts.
#[test]
fn multi_cps_shared_extent_is_served_from_cache_not_resampled() {
let key_a = [0x01u8; 16];
let key_b = [0x02u8; 16];
let key_c = [0x03u8; 16];
let shared_start = 1000u32; // ciphertext under key_c → pool index 2
let other_start = 9000u32; // ciphertext under key_b → pool index 1
let sectors = 30u32; // 10 aligned units → all 8 probes land
let mut reader = CountingCipherSource::new(vec![
(
shared_start,
shared_start + sectors,
encrypted_clean_unit(&key_c),
),
(
other_start,
other_start + sectors,
encrypted_clean_unit(&key_b),
),
]);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut cache = super::CpsUnitCache::new();
let title = multi_cps_title(shared_start, sectors);
let first = super::resolve_mux_key_map_cached(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect("first title resolves");
let after_first = reader.probes;
assert_eq!(
after_first, 8,
"the first title must actually sample the extent (8 probes)"
);
assert_eq!(first.key_idx_for(shared_start), Some(2));
// Same clip referenced by another playlist → cache hit, no further reads.
let second = super::resolve_mux_key_map_cached(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect("second title resolves");
assert_eq!(
reader.probes, after_first,
"an identical extent must be served from cache with no further reads"
);
assert_eq!(
second.key_idx_for(shared_start),
first.key_idx_for(shared_start),
"the cached index must equal the one originally resolved"
);
// A DIFFERENT extent is a miss: it must still be sampled, and must get its
// OWN index (1), never the cached neighbour's (2).
let third = multi_cps_title(other_start, sectors);
let map = super::resolve_mux_key_map_cached(
&mut reader,
&third,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect("a different extent resolves");
assert_eq!(
reader.probes,
after_first + 8,
"a different extent must not hit the cache"
);
assert_eq!(
map.key_idx_for(other_start),
Some(1),
"each distinct extent keeps its own resolved index"
);
}
/// The cached index must be exactly what a full recompute produces: resolve the
/// same title twice, once through a warm shared cache and once through a cold
/// one (a real re-read), and compare the maps range for range.
///
/// Mutation: caching under a key that ignores `start_lba`/`sector_count`, or
/// storing anything but `pick`'s index, diverges here.
#[test]
fn multi_cps_cache_hit_matches_a_full_recompute() {
let key_a = [0x01u8; 16];
let key_b = [0x02u8; 16];
let key_c = [0x03u8; 16];
let units = vec![
(1000u32, 1030u32, encrypted_clean_unit(&key_c)),
(9000u32, 9030u32, encrypted_clean_unit(&key_b)),
];
let mut title = DiscTitle::empty();
title.extents = vec![
Extent {
start_lba: 1000,
sector_count: 30,
},
Extent {
start_lba: 9000,
sector_count: 30,
},
];
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
// Warm a cache, then serve the whole title from it.
let mut warm = CountingCipherSource::new(units.clone());
let mut cache = super::CpsUnitCache::new();
for _ in 0..2 {
super::resolve_mux_key_map_cached(
&mut warm,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect("warm resolve");
}
let cached = super::resolve_mux_key_map_cached(
&mut warm,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect("cached resolve");
// The same title resolved from scratch, re-reading every unit.
let mut cold = CountingCipherSource::new(units);
let recomputed = super::resolve_mux_key_map_cached(
&mut cold,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut super::CpsUnitCache::new(),
)
.expect("cold resolve");
assert_eq!(
cached.ranges(),
recomputed.ranges(),
"a cache hit must produce byte-for-byte the same map as a recompute"
);
assert_eq!(cold.probes, 16, "the cold resolve samples both extents");
assert_eq!(
warm.probes, 16,
"the warm reader samples each extent exactly once across three resolves"
);
}
/// An extent with no sampleable encrypted units inherits the PRECEDING extent's
/// index — per-title state, not a property of the extent — so it must never be
/// memoised. Title A reaches the clear extent carrying index 2, title B carries
/// index 1: B's clear extent must key to 1, not to A's cached 2.
///
/// Mutation: caching the `samples.is_empty() => last_idx` arm makes B's clear
/// extent resolve to 2 and this fails.
#[test]
fn multi_cps_inherited_index_is_not_cached() {
let key_a = [0x01u8; 16];
let key_b = [0x02u8; 16];
let key_c = [0x03u8; 16];
let clear = 5000u32; // registered nowhere → reads as zeros → no samples
let mut reader = CountingCipherSource::new(vec![
(1000, 1030, encrypted_clean_unit(&key_c)), // → index 2
(9000, 9030, encrypted_clean_unit(&key_b)), // → index 1
]);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut cache = super::CpsUnitCache::new();
let mut with_c = DiscTitle::empty();
with_c.extents = vec![
Extent {
start_lba: 1000,
sector_count: 30,
},
Extent {
start_lba: clear,
sector_count: 30,
},
];
let a = super::resolve_mux_key_map_cached(
&mut reader,
&with_c,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect("title A resolves");
assert_eq!(
a.key_idx_for(clear),
Some(2),
"a clear extent inherits the preceding extent's index"
);
let mut with_b = DiscTitle::empty();
with_b.extents = vec![
Extent {
start_lba: 9000,
sector_count: 30,
},
Extent {
start_lba: clear,
sector_count: 30,
},
];
let b = super::resolve_mux_key_map_cached(
&mut reader,
&with_b,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect("title B resolves");
assert_eq!(
b.key_idx_for(clear),
Some(1),
"the inherited index must not be served from another title's cache entry"
);
}
/// A fail-loud extent (real ciphertext no key opens) must not be memoised: a
/// later retry, after a key source banked the missing key, has to re-sample and
/// succeed rather than inherit the earlier failure.
///
/// Mutation: caching before the `None => Err(DecryptFailed)` arm (or caching the
/// error) leaves the retry unable to resolve.
#[test]
fn multi_cps_failed_extent_is_not_cached() {
let key_a = [0x01u8; 16];
let key_x = [0x09u8; 16];
let start = 1000u32;
let mut reader =
CountingCipherSource::new(vec![(start, start + 30, encrypted_clean_unit(&key_x))]);
let title = multi_cps_title(start, 30);
let mut cache = super::CpsUnitCache::new();
// Two held keys → the multi-CPS sampling path (a one-key pool short-circuits
// to index 0 without sampling); neither opens the extent → fail loud.
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, [0x02u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
super::resolve_mux_key_map_cached(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect_err("no held key opens the extent → fail loud");
assert!(cache.is_empty(), "a failed extent must not be memoised");
// The operator supplies the missing key; the retry must resolve it.
if let DecryptKeys::Aacs { unit_keys, .. } = &mut keys {
unit_keys.push((2, key_x));
}
let map = super::resolve_mux_key_map_cached(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
&mut cache,
)
.expect("the retry resolves once the key is banked");
assert_eq!(
map.key_idx_for(start),
Some(2),
"the retry re-samples and resolves to the newly banked key"
);
}
// ── Fix 1: read-fault vs genuinely-not-FMTS in resolve_fmts_key_map ──────
/// A SectorSource whose every read is a transient I/O fault (`DiscRead`),