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:
+10
-2
@@ -76,7 +76,7 @@ pub struct Disc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Content format — determines how sectors are interpreted downstream.
|
/// Content format — determines how sectors are interpreted downstream.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum ContentFormat {
|
pub enum ContentFormat {
|
||||||
/// Blu-ray BD Transport Stream (192-byte packets)
|
/// Blu-ray BD Transport Stream (192-byte packets)
|
||||||
BdTs,
|
BdTs,
|
||||||
@@ -2396,14 +2396,22 @@ impl Disc {
|
|||||||
halt: Option<&crate::halt::Halt>,
|
halt: Option<&crate::halt::Halt>,
|
||||||
) -> Result<crate::decrypt::AacsKeyMap> {
|
) -> Result<crate::decrypt::AacsKeyMap> {
|
||||||
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
|
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 {
|
for title in &self.titles {
|
||||||
let map = crate::mux::resolve_mux_key_map(
|
let map = crate::mux::resolve::resolve_mux_key_map_cached(
|
||||||
reader,
|
reader,
|
||||||
title,
|
title,
|
||||||
keys,
|
keys,
|
||||||
fetch,
|
fetch,
|
||||||
self.content_format,
|
self.content_format,
|
||||||
halt,
|
halt,
|
||||||
|
&mut cps_cache,
|
||||||
)?;
|
)?;
|
||||||
ranges.extend_from_slice(map.ranges());
|
ranges.extend_from_slice(map.ranges());
|
||||||
}
|
}
|
||||||
|
|||||||
+437
-1
@@ -1265,6 +1265,62 @@ pub fn resolve_mux_key_map(
|
|||||||
fetch: Option<&crate::sector::KeyFetch>,
|
fetch: Option<&crate::sector::KeyFetch>,
|
||||||
format: ContentFormat,
|
format: ContentFormat,
|
||||||
halt: Option<&crate::halt::Halt>,
|
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> {
|
) -> io::Result<crate::decrypt::AacsKeyMap> {
|
||||||
use crate::aacs::content::{
|
use crate::aacs::content::{
|
||||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
|
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()) {
|
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||||
return Err(crate::error::Error::Halted.into());
|
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);
|
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
|
||||||
// Snapshot the current pool for the pure `pick` closure.
|
// Snapshot the current pool for the pure `pick` closure.
|
||||||
let pool: Vec<(u32, [u8; 16])> = match keys {
|
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
|
// encrypted content is classified. An extent with no sampleable encrypted
|
||||||
// units (nothing to mis-decrypt) carries the previous index harmlessly.
|
// units (nothing to mis-decrypt) carries the previous index harmlessly.
|
||||||
let idx = match idx {
|
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,
|
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()),
|
None => return Err(crate::error::Error::DecryptFailed.into()),
|
||||||
};
|
};
|
||||||
last_idx = idx;
|
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 ──────
|
// ── 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`),
|
/// A SectorSource whose every read is a transient I/O fault (`DiscRead`),
|
||||||
|
|||||||
Reference in New Issue
Block a user