From 13897e14f0416b149f72343bec77a267bf830d98 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:27:50 -0700 Subject: [PATCH] Resolve the forensic key map once per disc, not once per playlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_content_key_map loops every title into resolve_mux_key_map, which called resolve_fmts_key_map FIRST — before the CpsUnitCache — and on an FMTS disc returned immediately. So every playlist re-derived facts that belong to the DISC: a full UDF walk plus /AACS/IndividualSegment.tbl, and on an FMTS disc the anchor probe, the 32-index phase probe, and a fetch.fmts_indexes round trip. On a 60-playlist disc, measured on a synthetic fixture: 840 -> 14 metadata reads, 2,400 -> 40 probe reads, and 60 -> 1 key-service calls. Worst case before was up to 256 probe reads and 32 key-service calls per title. The 60 redundant key-service round trips are a strong candidate for the keyserver storm seen in the field. Two memos behind a pub(crate) DiscKeyCache. The table memo (UDF walk + tbl parse) is disc-invariant outright — nothing in that path mentions the title — and runs on EVERY disc, so a plain BD benefits too. Only the deterministic negatives are memoised as "not FMTS"; a DiscRead fault propagates uncached so a later title retries. A blind once-per-disc hoist of the PROBES was rejected as unsafe, and this is the load-bearing reasoning: the title enters through clip_byte_to_lba, which decides which segments are addressable and which LBA every probed clip byte reads from, so two titles with different extent lists probe different physical bytes. A hoist would serve title B an answer derived from title A's media and could silently turn a per-title FmtsKeyMissing into a success. The extent list is the ONLY per-title input, so keying on it is exactly sufficient — matching the ForcedProbeCache precedent. Result-identity was proved, not assumed: NEITHER probe reads the key pool. Verified here independently — probe_fmts_index_keys takes no keys parameter at all; index keys come from `fetch`, and the anchor's reply feeds the phase probe. So the pool's growth across titles, the one thing that does change between calls, cannot move a memoised value, and the result is order-independent. A test resolves three titles through a shared memo and through fresh memos and asserts both the per-title ranges and the final key pool (keys, slots, order) are identical. Not memoised, deliberately: fail-loud FmtsKeyMissing, and any run where an index hit a read fault — that is a property of a transient drive fault, not of the extents, and caching it would spread one bad read across 59 playlists. A fully-memoised title now does zero I/O, which made the old in-loop halt polls unreachable for it, so a check_halt on entry was added with a test that cancels after warming the memos. Also corrects my own overstatement from last round: the CpsUnitCache doc now says plainly that on an FMTS disc it removes NO reads, because this function returns before the extent loop ever runs. Five mutations, all verified red. Pre-existing bug flagged but not fixed: filter_addressable_segments only checks that a segment's START byte maps to some LBA in the title, so a play-all playlist can pass the filter while mapping segment bytes into the wrong clip, whose anchor then returns empty and aborts the sweep. --- CHANGELOG.md | 14 + src/disc/mod.rs | 30 +- src/mux/resolve.rs | 1073 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 1003 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 249ce60..5d7041b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,20 @@ ### Fixed +- **The FMTS (AACS 2.1) forensic key resolution now runs once per disc, not once + per title.** `Disc::resolve_content_key_map` resolves every title, and the FMTS + branch ran ahead of everything else — so each playlist re-walked the UDF + filesystem to re-read `/AACS/IndividualSegment.tbl`, re-ran the forensic anchor + probe and the per-index phase probe, and **re-asked the key service for the + disc's index-key set**. On a 60-playlist AACS 2.1 disc that was 60 identical + key-service round trips (a key-server storm) and tens of thousands of random + 6144-byte reads for one disc-wide answer. The UDF walk is now memoised for the + whole disc and the index keys + phases per distinct extent list — the only + per-title input to the probes. A read-faulted phase probe is deliberately never + memoised, so one bad read is not spread across the remaining playlists. The + per-title UDF walk was paid on **every** disc, FMTS or not, so a plain BD sweep + loses ~59 full-stroke seeks too. The multi-CPS extent memo added in 1.6.0 is + also reachable on an FMTS disc for the first time. - **Mux correctness pass** (the `v1.4.0..HEAD` 10-phase audit): DTS core-header false-drops that dropped good DTS frames; the TrueHD channel-correction probe now runs correctly on AACS discs (7.1/Atmos no longer understated as 5.1); diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 8b9a2a8..2bbe0bc 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -2396,13 +2396,27 @@ impl Disc { halt: Option<&crate::halt::Halt>, ) -> Result { 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(); + // ONE per-disc memo across every title. Without it every playlist re-derives + // the same disc-wide facts off the drive: + // + // * the multi-CPS "which held key opens this extent" decision — 8 random + // 6144-byte reads per extent, ~200 ms of seek apiece on a stock BD drive. + // A disc's playlists overwhelmingly reference the same handful of clips + // (main feature, play-all, per-chapter and seamless-branch variants), so + // this recomputed the same index from byte-identical input; + // * the UDF walk + `/AACS/IndividualSegment.tbl` read that decides whether + // the disc is FMTS at all — ~35 single-sector reads at low LBAs, reached + // from a head the previous title's content sampling left deep in the + // content area, so a full-stroke seek out and back per playlist. This + // runs on EVERY disc, FMTS or not; + // * on an FMTS (AACS 2.1) disc, the forensic anchor probe, the per-index + // phase probe AND the key-service round trip that returns the disc's + // index-key set. That last one is the key-server storm: one round trip + // per playlist for one disc-wide answer. + // + // The FMTS memos are what makes the multi-CPS memo reachable at all on an + // FMTS disc — that path returns its finished map before the extent loop. + let mut cache = crate::mux::resolve::DiscKeyCache::new(); for title in &self.titles { let map = crate::mux::resolve::resolve_mux_key_map_cached( reader, @@ -2411,7 +2425,7 @@ impl Disc { fetch, self.content_format, halt, - &mut cps_cache, + &mut cache, )?; ranges.extend_from_slice(map.ranges()); } diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index c3b75f8..c271c33 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -781,6 +781,13 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState { /// to clean TS and garbles the other ~40 (the alternate half), which the demux /// then drops, yielding one coherent stream. The base Unit Key covers everything /// outside a segment. +/// +/// Everything expensive here is memoised per DISC, not recomputed per title: +/// `cache.table` holds the UDF walk + `IndividualSegment.tbl` (disc-invariant outright) +/// and `cache.keys` holds the index keys + phases (keyed on the extent list, the only +/// per-title input). See [`resolve_mux_key_map_cached`] for why a hit is provably +/// the same answer. What remains per title is the pool→slot mapping, the LBA range +/// arithmetic and the base-key gap fill. fn resolve_fmts_key_map( reader: &mut dyn SectorSource, title: &DiscTitle, @@ -788,8 +795,9 @@ fn resolve_fmts_key_map( fetch: Option<&crate::sector::KeyFetch>, format: ContentFormat, halt: Option<&crate::halt::Halt>, + cache: &mut FmtsCache, ) -> io::Result> { - use crate::aacs::content::ALIGNED_UNIT_LEN; + let FmtsCache { table, keys: memo } = cache; use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments}; // Cooperative cancel: this probes the LIVE drive across up to a few hundred @@ -803,6 +811,10 @@ fn resolve_fmts_key_map( } Ok(()) }; + // Poll once on ENTRY as well as inside the probe loops: with both memos warm a + // title can reach the finished map without touching a single loop, so a + // 60-playlist sweep would otherwise run to completion after an operator Stop. + check_halt()?; // Load the segment map. Distinguish a genuine "not an FMTS disc" negative // from a transient live-drive I/O fault: swallowing the latter into Ok(None) @@ -815,22 +827,34 @@ fn resolve_fmts_key_map( // → genuinely not FMTS → Ok(None). // - any other error (notably `DiscRead`): a read fault → propagate so the // rip fails loud / can be retried rather than dropping forensic content. - let udf = match crate::udf::read_filesystem(reader) { - Ok(u) => u, - Err(crate::error::Error::UdfNotFilesystem) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let tbl = match udf.read_file(reader, "/AACS/IndividualSegment.tbl") { - Ok(t) => t, - Err(crate::error::Error::UdfNotFound { .. }) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let Some(segments) = parse_individual_segments(&tbl) else { - return Ok(None); - }; - if segments.is_empty() { - return Ok(None); + // …and memoise the outcome for the whole disc: the walk reads the same fixed low + // LBAs and the same file for every title (see `FmtsTableCache`). Only the two + // DETERMINISTIC negatives are cached; a read fault propagates uncached so a + // later title still retries. + if table.is_none() { + let udf = match crate::udf::read_filesystem(reader) { + Ok(u) => Some(u), + Err(crate::error::Error::UdfNotFilesystem) => None, + Err(e) => return Err(e.into()), + }; + let tbl = match udf { + None => None, + Some(u) => match u.read_file(reader, "/AACS/IndividualSegment.tbl") { + Ok(t) => Some(t), + Err(crate::error::Error::UdfNotFound { .. }) => None, + Err(e) => return Err(e.into()), + }, + }; + *table = Some( + tbl.as_deref() + .and_then(parse_individual_segments) + .filter(|s| !s.is_empty()), + ); } + let Some(Some(segments)) = table.as_ref() else { + return Ok(None); // not an FMTS disc + }; + let segments = segments.clone(); // The segment SPNs are in the FORENSIC FEATURE clip's byte space. A title // whose extents do not cover any segment's clip bytes carries no forensic // content (a menu/extras playlist, or simply a different clip): its base Unit @@ -852,6 +876,150 @@ fn resolve_fmts_key_map( let Some(fetch) = fetch else { return Err(crate::error::Error::FmtsKeyMissing.into()); }; + + // ── The expensive half — the anchor probe, the per-index phase probe and the + // ONE key-service round trip — depends on the title ONLY through its extent + // list, so it is resolved at most once per distinct extent list per disc + // (`FmtsKeyCache`). Without this, `resolve_content_key_map` re-ran the whole + // probe AND re-asked the key service once per playlist: on a 60-playlist disc + // that is 60 identical key-service round trips (the storm) and tens of + // thousands of random 6144-byte reads for one disc-wide answer. ──────────── + let ek = extent_key(format, title); + let (index_keys, phase_of_index) = match memo.get(&ek) { + Some((k, p)) => (k.clone(), p.clone()), + None => { + let probed = probe_fmts_index_keys(reader, title, &segments, fetch, format, halt)?; + // Only memoise a run whose every index reached a DEFINITE phase. A + // read-faulted index defaulted to `Phase::All` is a property of a + // transient live-drive fault, not of these extents — caching it would + // spread one bad read across every remaining title. + if probed.all_phases_definite { + memo.insert(ek, (probed.keys.clone(), probed.phases.clone())); + } + (probed.keys, probed.phases) + } + }; + + // Map array position → forensic index (element i = index i+1); add each key to + // the pool and remember its slot by tag. `base_idx` is the Unit Key (slot 0). + // Per TITLE, and deliberately so: the pool is the caller's and grows across + // titles, so a title reached with the keys already banked finds them by value at + // the same slots instead of appending duplicates. + let base_idx = 0usize; + let mut tag_slot: std::collections::HashMap = std::collections::HashMap::new(); + if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { + for (i, k) in index_keys.iter().enumerate() { + let tag = (i + 1) as u16; + let slot = match unit_keys.iter().position(|(_, h)| h == k) { + Some(s) => s, + None => { + let s = unit_keys.len(); + // CPS-unit id is cosmetic for the mapped decrypt (it indexes by + // slot); use a high, distinct number for the forensic keys. + unit_keys.push((1000 + s as u32, *k)); + s + } + }; + tag_slot.insert(tag, slot); + } + } + + // ── Build the per-segment LBA ranges: each forensic segment → its tag's key + // AND its index's phase. The mapped decrypt opens only that half and leaves + // the alternate as ciphertext (the muxer drops untouched ciphertext) — + // clean by construction, no garble. A segment straddling an extent boundary + // is left unmapped and tallied (a hard failure below). ──────────────────── + let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = + Vec::with_capacity(segments.len()); + let mut unresolved = 0usize; + for seg in &segments { + // SPNs are untrusted (from IndividualSegment.tbl); an inverted record + // (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below. + // (Mirrors the guard in `aacs::segment::fmts_key_ranges`.) + if seg.start_spn > seg.end_spn { + unresolved += 1; + continue; + } + let Some(&slot) = tag_slot.get(&seg.index) else { + unresolved += 1; + continue; + }; + let phase = phase_of_index + .get(&seg.index) + .copied() + .unwrap_or(crate::decrypt::Phase::All); + let start_byte = seg.start_spn as u64 * 192; + let end_byte = (seg.end_spn as u64 + 1) * 192; + let (Some(a), Some(b)) = ( + clip_byte_to_lba(&title.extents, start_byte), + clip_byte_to_lba(&title.extents, end_byte - 1), + ) else { + unresolved += 1; + continue; + }; + // Only emit a contiguous within-extent range (segments are ~480 KB; a rare + // extent-straddle is left unresolved rather than given a wrong span). + if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 { + ranges.push((a, b + 1, slot, phase)); + } else { + unresolved += 1; + } + } + // Every forensic segment must map to an index key. Any that did not is a hole + // in the rip — with the full 32-key set in hand this should never happen, so + // treat it as a hard failure rather than silently emitting a garbled segment. + if unresolved != 0 { + return Err(crate::error::Error::FmtsKeyMissing.into()); + } + + // Cover the NON-segment content with the base Unit Key: the forensic segments + // (added above with their index keys) carve holes out of the title's content + // extents; every other content unit uses the base UK. Fill the gaps so the map + // is a complete positive list — an LBA in no range is nav and passes through. + let base_gaps = fill_base_key_gaps(&title.extents, &ranges, base_idx); + ranges.extend(base_gaps); + + Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(ranges))) +} + +/// The disc-wide forensic answer [`probe_fmts_index_keys`] resolves: the ordered +/// index keys (element `i` = forensic index `i + 1`) and each index's decrypt phase. +struct FmtsIndexKeys { + keys: Vec<[u8; 16]>, + phases: std::collections::HashMap, + /// Every index reached a DEFINITE phase — no index fell back to `Phase::All` + /// after [`IndexProbe::ReadFault`]. Only such a run is safe to memoise; see + /// [`resolve_mux_key_map_cached`]. + all_phases_definite: bool, +} + +/// The drive- and key-service-hitting half of [`resolve_fmts_key_map`]: anchor the +/// disc's whole forensic index-key set from ONE key-service round trip, then probe +/// each index's interleave phase. Split out so the result can be memoised per disc +/// ([`FmtsKeyCache`]) — the arithmetic that turns these keys into a per-title LBA +/// map stays at the call site, where the title belongs. +/// +/// `segments` are the segments already filtered to those addressable within +/// `title`; every read is `clip_byte_to_lba(&title.extents, …)`. Neither probe reads +/// the caller's key pool, which is what makes the result independent of resolve +/// order across titles. +fn probe_fmts_index_keys( + reader: &mut dyn SectorSource, + title: &DiscTitle, + segments: &[crate::aacs::segment::Segment], + fetch: &crate::sector::KeyFetch, + format: ContentFormat, + halt: Option<&crate::halt::Halt>, +) -> io::Result { + use crate::aacs::content::ALIGNED_UNIT_LEN; + use crate::aacs::segment::clip_byte_to_lba; + + let check_halt = || -> io::Result<()> { + if halt.is_some_and(|h| h.is_cancelled()) { + return Err(crate::error::Error::Halted.into()); + } + Ok(()) + }; tracing::info!(target: "freemkv::keysource", segments = segments.len(), extents = title.extents.len(), "fmts: begin index-key resolution"); // Read aligned unit `index` of `seg`: clip byte `start_spn*192 + index*6144`. @@ -925,27 +1093,6 @@ fn resolve_fmts_key_map( return Err(crate::error::Error::FmtsKeyMissing.into()); } - // Map array position → forensic index (element i = index i+1); add each key to - // the pool and remember its slot by tag. `base_idx` is the Unit Key (slot 0). - let base_idx = 0usize; - let mut tag_slot: std::collections::HashMap = std::collections::HashMap::new(); - if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { - for (i, k) in index_keys.iter().enumerate() { - let tag = (i + 1) as u16; - let slot = match unit_keys.iter().position(|(_, h)| h == k) { - Some(s) => s, - None => { - let s = unit_keys.len(); - // CPS-unit id is cosmetic for the mapped decrypt (it indexes by - // slot); use a high, distinct number for the forensic keys. - unit_keys.push((1000 + s as u32, *k)); - s - } - }; - tag_slot.insert(tag, slot); - } - } - // ── PROBE each index's phase. A forensic segment interleaves two variants at // the aligned-unit level; only ONE parity is this index's real content (the // other is the alternate variant — a different key, garbles under ours). For @@ -956,6 +1103,7 @@ fn resolve_fmts_key_map( // Phase is per-index and shared by every segment carrying that index. ────── let mut phase_of_index: std::collections::HashMap = std::collections::HashMap::new(); + let mut all_phases_definite = true; for (i, k) in index_keys.iter().enumerate() { check_halt()?; let tag = (i + 1) as u16; @@ -966,7 +1114,7 @@ fn resolve_fmts_key_map( // read fault (zero decrypt evidence) — the load-bearing distinction so a // recoverable fault never hard-aborts a rip whose index keys are valid. match probe_index_phase( - &segments, + segments, tag, BATCH_UNITS, MAX_ANCHOR_ATTEMPTS, @@ -996,66 +1144,16 @@ fn resolve_fmts_key_map( // coherent-stream outcome the module doc describes for whole-range key // application). Degraded but complete; never a wrong-key abort. tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: index phase probe read-faulted on every segment — defaulting Phase::All (recoverable read fault, not a wrong key)"); + all_phases_definite = false; } } } - // ── Build the per-segment LBA ranges: each forensic segment → its tag's key - // AND its index's phase. The mapped decrypt opens only that half and leaves - // the alternate as ciphertext (the muxer drops untouched ciphertext) — - // clean by construction, no garble. A segment straddling an extent boundary - // is left unmapped and tallied (a hard failure below). ──────────────────── - let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = - Vec::with_capacity(segments.len()); - let mut unresolved = 0usize; - for seg in &segments { - // SPNs are untrusted (from IndividualSegment.tbl); an inverted record - // (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below. - // (Mirrors the guard in `aacs::segment::fmts_key_ranges`.) - if seg.start_spn > seg.end_spn { - unresolved += 1; - continue; - } - let Some(&slot) = tag_slot.get(&seg.index) else { - unresolved += 1; - continue; - }; - let phase = phase_of_index - .get(&seg.index) - .copied() - .unwrap_or(crate::decrypt::Phase::All); - let start_byte = seg.start_spn as u64 * 192; - let end_byte = (seg.end_spn as u64 + 1) * 192; - let (Some(a), Some(b)) = ( - clip_byte_to_lba(&title.extents, start_byte), - clip_byte_to_lba(&title.extents, end_byte - 1), - ) else { - unresolved += 1; - continue; - }; - // Only emit a contiguous within-extent range (segments are ~480 KB; a rare - // extent-straddle is left unresolved rather than given a wrong span). - if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 { - ranges.push((a, b + 1, slot, phase)); - } else { - unresolved += 1; - } - } - // Every forensic segment must map to an index key. Any that did not is a hole - // in the rip — with the full 32-key set in hand this should never happen, so - // treat it as a hard failure rather than silently emitting a garbled segment. - if unresolved != 0 { - return Err(crate::error::Error::FmtsKeyMissing.into()); - } - - // Cover the NON-segment content with the base Unit Key: the forensic segments - // (added above with their index keys) carve holes out of the title's content - // extents; every other content unit uses the base UK. Fill the gaps so the map - // is a complete positive list — an LBA in no range is nav and passes through. - let base_gaps = fill_base_key_gaps(&title.extents, &ranges, base_idx); - ranges.extend(base_gaps); - - Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(ranges))) + Ok(FmtsIndexKeys { + keys: index_keys, + phases: phase_of_index, + all_phases_definite, + }) } /// Keep only the forensic segments addressable within THIS title's extents: a @@ -1275,7 +1373,7 @@ pub fn resolve_mux_key_map( fetch, format, halt, - &mut CpsUnitCache::new(), + &mut DiscKeyCache::new(), ) } @@ -1293,13 +1391,101 @@ pub fn resolve_mux_key_map( /// 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. +/// +/// Scope, stated precisely: this memo covers the MULTI-CPS extent-sampling reads +/// and nothing else. On an FMTS (AACS 2.1) disc it removes NO reads at all, because +/// [`resolve_fmts_key_map`] runs first and returns a finished map before the extent +/// loop is ever reached — the per-title cost on those discs is removed by +/// [`FmtsTableCache`] and [`FmtsKeyCache`] instead. 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. +/// The disc's forensic segment table (`/AACS/IndividualSegment.tbl`), resolved at +/// most ONCE per disc: `None` = not looked for yet; `Some(None)` = looked for and +/// this disc is not FMTS; `Some(Some(v))` = the parsed, non-empty segment list. +/// +/// Every input is a property of the MEDIA, not of a title: the UDF walk +/// ([`crate::udf::read_filesystem`]) reads fixed low LBAs (the anchor at 256, the +/// VDS, the FSD, the root directory), `read_file` follows that file's own +/// allocation descriptors, and `parse_individual_segments` is pure. Re-running it +/// per title re-reads ~35 single sectors at low LBAs from a head the previous +/// title's content sampling left deep in the content area — a full-stroke seek out +/// and back per playlist, for a byte-identical answer. +/// +/// Only the two *deterministic* negatives are memoised as "not FMTS" +/// (`UdfNotFilesystem`, and `UdfNotFound` for the table): a read fault is NEVER +/// cached, so a transient failure still propagates and a later title still retries. +pub(crate) type FmtsTableCache = Option>>; + +/// Memoises the FMTS (AACS 2.1) forensic **index-key set and per-index phase** — +/// the expensive half of [`resolve_fmts_key_map`] — across the titles of ONE disc. +/// +/// Keyed by `(format, the title's exact extent list)`, mirroring +/// [`crate::disc::pgs_forced_probe::ForcedProbeCache`]. The extent list is the key +/// because it is the ONLY per-title input to the anchor probe, the phase probe and +/// the key-service call: every read those make is +/// `clip_byte_to_lba(&title.extents, …)`, and which segments are probed at all is +/// `filter_addressable_segments(_, &title.extents)`. Two titles with the same +/// extent list therefore feed byte-identical samples to a stateless +/// [`crate::sector::KeyFetch`] and to the same `is_clean` arithmetic under the same +/// `format` — see the safety argument on [`resolve_mux_key_map_cached`]. +/// +/// The value holds the ordered index keys (element `i` = forensic index `i + 1`) +/// and the resolved phase per index tag. It is key material: never logged, never +/// rendered, and dropped with the disc's resolve. +pub(crate) type FmtsKeyCache = std::collections::HashMap< + (ContentFormat, Vec<(u32, u32)>), + ( + Vec<[u8; 16]>, + std::collections::HashMap, + ), +>; + +/// The two FMTS memos, bundled so [`resolve_fmts_key_map`] takes one argument for +/// both and the multi-CPS memo can be borrowed independently. +#[derive(Default)] +pub(crate) struct FmtsCache { + /// The disc's forensic segment table — see [`FmtsTableCache`]. + table: FmtsTableCache, + /// The forensic index keys + phases — see [`FmtsKeyCache`]. + keys: FmtsKeyCache, +} + +/// Everything [`resolve_mux_key_map_cached`] memoises across the titles of ONE +/// disc. One value threaded through `resolve_content_key_map`'s per-title loop; the +/// fields are independent and are borrowed disjointly. +#[derive(Default)] +pub(crate) struct DiscKeyCache { + /// Multi-CPS "which held key opens this extent" — see [`CpsUnitCache`]. + pub(crate) cps: CpsUnitCache, + /// The FMTS segment table and index-key memos — see [`FmtsCache`]. + pub(crate) fmts: FmtsCache, +} + +impl DiscKeyCache { + pub(crate) fn new() -> Self { + Self::default() + } +} + +/// The `(format, extents)` cache key used by [`FmtsKeyCache`]. +fn extent_key(format: ContentFormat, title: &DiscTitle) -> (ContentFormat, Vec<(u32, u32)>) { + ( + format, + title + .extents + .iter() + .map(|e| (e.start_lba, e.sector_count)) + .collect(), + ) +} + +/// [`resolve_mux_key_map`] with a caller-owned per-disc cache ([`DiscKeyCache`]) +/// shared across the titles of one disc. /// /// # Why a cache cannot change a resolved key /// +/// ## Multi-CPS extents ([`CpsUnitCache`]) +/// /// 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: /// @@ -1312,7 +1498,46 @@ pub(crate) type CpsUnitCache = std::collections::HashMap<(ContentFormat, u32, u3 /// 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. +/// ## FMTS segment table ([`FmtsTableCache`]) +/// +/// Disc-invariant outright: no input to the UDF walk, the `read_file` or the parse +/// mentions the title. See the type's doc for the negatives that are (and are not) +/// memoised. +/// +/// ## FMTS index keys and phases ([`FmtsKeyCache`]) +/// +/// The title enters the anchor and phase probes through exactly one channel — the +/// extent list, via `clip_byte_to_lba` (which segments are addressable, and which +/// LBA each probed clip byte maps to) — and that list is IN the key. Given the same +/// list, the same `format` and unchanging media: +/// +/// * `filter_addressable_segments` yields the same segments in the same order, so +/// the anchor loop and each `probe_index_phase` visit the same segments. +/// * every probe read is at the same LBA, so the same ciphertext is fed to a +/// [`crate::sector::KeyFetch`] that is stateless by contract, and to the same +/// `aacs_unit_encrypted` / `decrypt_unit` / `is_clean` arithmetic. +/// * NEITHER probe reads the key pool. The anchor takes its keys from `fetch`; the +/// phase probe takes them from the anchor's reply. So the pool's growth across +/// titles — the one thing that does change between calls — cannot move this +/// result, and the cached value is independent of the order titles are resolved +/// in. +/// +/// What is deliberately NOT memoised, which is what makes this safe rather than +/// merely faster: +/// +/// * the fail-loud `FmtsKeyMissing` verdicts (no anchor, wrong key), so a retry +/// after a key source is reconfigured re-probes rather than inheriting nothing; +/// * a run where any index's phase probe came back +/// [`IndexProbe::ReadFault`] — that leaves the phase defaulted to `Phase::All` +/// (degraded but complete), a property of a transient live-drive fault and NOT of +/// the extents. Caching it would spread one bad read across every remaining title. +/// +/// Everything downstream of the cache still runs per title: the pool insertion that +/// turns index keys into slots, the per-segment LBA range arithmetic, and the +/// base-key gap fill — all of which genuinely depend on this title's extents. +/// +/// Halt is polled per extent AND once on entry to the FMTS branch, so a 60-playlist +/// sweep stays cancellable even when every title is served from cache. pub(crate) fn resolve_mux_key_map_cached( reader: &mut dyn SectorSource, title: &DiscTitle, @@ -1320,12 +1545,16 @@ pub(crate) fn resolve_mux_key_map_cached( fetch: Option<&crate::sector::KeyFetch>, format: ContentFormat, halt: Option<&crate::halt::Halt>, - cache: &mut CpsUnitCache, + cache: &mut DiscKeyCache, ) -> io::Result { use crate::aacs::content::{ ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean, }; + // Borrow the three memos disjointly: the FMTS branch needs two of them mutably + // while the multi-CPS loop below needs the third. + let DiscKeyCache { cps: cache, fmts } = cache; + // The base Unit Key pool is always resolved and banked by the caller before mux // (autorip's pre-rip gate; the ISO path's `decrypt_keys()`), so an AACS title // reaches here with a non-empty pool — an empty pool is reported as @@ -1342,7 +1571,7 @@ pub(crate) fn resolve_mux_key_map_cached( // front from the configured source and build a per-segment map. Returns `None` // when the disc is not FMTS, or no key source is configured (then the base UK // path below applies and the forensic units garble → demux drops them). - if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt)? { + if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt, fmts)? { return Ok(map); } if pool_len == 1 { @@ -3044,12 +3273,17 @@ mod tests { struct CountingCipherSource { inner: CipherSource, probes: u32, + /// Reads BELOW `CONTENT_LBA_FLOOR` — the FMTS branch's UDF walk and + /// `IndividualSegment.tbl` load, whose per-title repetition the disc-wide + /// `FmtsTableCache` exists to remove. + meta: u32, } impl CountingCipherSource { fn new(units: Vec<(u32, u32, Vec)>) -> Self { Self { inner: CipherSource { units }, probes: 0, + meta: 0, } } } @@ -3066,6 +3300,8 @@ mod tests { ) -> crate::error::Result { if lba >= CONTENT_LBA_FLOOR { self.probes += 1; + } else { + self.meta += 1; } self.inner.read_sectors(lba, count, buf, recovery) } @@ -3104,7 +3340,7 @@ mod tests { read_data_key: None, format: ContentFormat::BdTs, }; - let mut cache = super::CpsUnitCache::new(); + let mut cache = super::DiscKeyCache::new(); let title = multi_cps_title(shared_start, sectors); let first = super::resolve_mux_key_map_cached( @@ -3204,7 +3440,7 @@ mod tests { // Warm a cache, then serve the whole title from it. let mut warm = CountingCipherSource::new(units.clone()); - let mut cache = super::CpsUnitCache::new(); + let mut cache = super::DiscKeyCache::new(); for _ in 0..2 { super::resolve_mux_key_map_cached( &mut warm, @@ -3237,7 +3473,7 @@ mod tests { None, ContentFormat::BdTs, None, - &mut super::CpsUnitCache::new(), + &mut super::DiscKeyCache::new(), ) .expect("cold resolve"); @@ -3275,7 +3511,7 @@ mod tests { read_data_key: None, format: ContentFormat::BdTs, }; - let mut cache = super::CpsUnitCache::new(); + let mut cache = super::DiscKeyCache::new(); let mut with_c = DiscTitle::empty(); with_c.extents = vec![ @@ -3346,7 +3582,7 @@ mod tests { 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(); + let mut cache = super::DiscKeyCache::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. @@ -3365,7 +3601,7 @@ mod tests { &mut cache, ) .expect_err("no held key opens the extent → fail loud"); - assert!(cache.is_empty(), "a failed extent must not be memoised"); + assert!(cache.cps.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 { @@ -3437,6 +3673,7 @@ mod tests { None, ContentFormat::BdTs, None, + &mut super::FmtsCache::default(), ); let err = got.expect_err("a transient DiscRead must fail loud, never Ok(None)"); let expected = std::io::Error::from(crate::error::Error::DiscRead { @@ -3474,8 +3711,632 @@ mod tests { None, ContentFormat::BdTs, None, + &mut super::FmtsCache::default(), ) .expect("a structurally non-UDF disc is a clean not-FMTS negative"); assert!(got.is_none(), "not a UDF/FMTS disc → Ok(None)"); } + + // ── FMTS index-key resolution is memoised per DISC, not per title ───────── + // + // A synthetic AACS 2.1 disc: a real UDF tree (so `read_filesystem` walks it + // for real) carrying `/AACS/IndividualSegment.tbl`, plus a content region whose + // aligned units are genuinely encrypted — the EVEN units of a segment under its + // index key and the ODD units under the alternate interleaved variant's key, + // exactly the layout the phase probe exists to discover. The reader COUNTS its + // reads (metadata vs probe) and the `KeyFetch` double COUNTS its calls, so the + // tests can state the cost of N titles rather than assume it. + + /// First LBA of the synthetic disc's content. Everything below is UDF metadata. + const FMTS_CONTENT_LBA: u32 = 10_000; + /// Sectors in the content extent — enough to cover both forensic segments. + const FMTS_CONTENT_SECTORS: u32 = 2_000; + /// The base CPS Unit Key (pool slot 0) of the synthetic disc. + const FMTS_BASE_KEY: [u8; 16] = [0x01u8; 16]; + /// The disc's forensic index keys: element i = forensic index i+1. Two, not 32 — + /// the resolver sizes itself to whatever the source returns. + const FMTS_INDEX_KEYS: [[u8; 16]; 2] = [[0x21u8; 16], [0x22u8; 16]]; + /// The alternate interleaved variant's key: the ODD units of every segment are + /// encrypted under it, so they do NOT open under the segment's index key. + const FMTS_ALT_KEY: [u8; 16] = [0x99u8; 16]; + + /// The disc's forensic segments: one per index, 2560 packets each (the retail + /// size), on a 6144-byte-aligned start so units line up with sectors. + fn fmts_segments() -> Vec { + use crate::aacs::segment::Segment; + vec![ + Segment { + index: 1, + start_spn: 3200, + end_spn: 3200 + 2559, + }, + Segment { + index: 2, + start_spn: 6400, + end_spn: 6400 + 2559, + }, + ] + } + + /// Serialise `segs` as an `IndividualSegment.tbl` image (8-byte header: + /// type, record count, record size; then 16-byte records). + fn fmts_tbl(segs: &[crate::aacs::segment::Segment]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); + v.extend_from_slice(&(segs.len() as u16).to_be_bytes()); + v.extend_from_slice(&(crate::aacs::segment::SEGMENT_RECORD_LEN as u16).to_be_bytes()); + for s in segs { + v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); + v.extend_from_slice(&s.index.to_be_bytes()); + v.extend_from_slice(&1u16.to_be_bytes()); + v.extend_from_slice(&s.start_spn.to_be_bytes()); + v.extend_from_slice(&s.end_spn.to_be_bytes()); + } + v + } + + /// The synthetic FMTS disc. Counts UDF-metadata reads and content-probe reads + /// separately, and can be told to fault every read of one LBA span (a marginal + /// live drive) to exercise the read-fault path. + struct FmtsDisc { + meta_disc: crate::udf::fixture::MemDisc, + segs: Vec, + /// Reads below `FMTS_CONTENT_LBA`: the UDF walk + segment-table load. + meta_reads: u32, + /// Reads at/above `FMTS_CONTENT_LBA`: the anchor + phase probes. + probe_reads: u32, + /// `[start, end)` LBA span whose every read returns `DiscRead`. + fault_span: Option<(u32, u32)>, + } + + impl FmtsDisc { + fn new() -> Self { + use crate::udf::fixture::{DirSpec, build_udf_skeleton, file_with, lay_dir}; + let segs = fmts_segments(); + let mut meta_disc = crate::udf::fixture::MemDisc::new(); + build_udf_skeleton(&mut meta_disc, 10); + lay_dir( + &mut meta_disc, + &DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "AACS".to_string(), + icb_lba: 12, + dir_data_lba: 13, + files: vec![file_with( + "IndividualSegment.tbl", + 14, + 15, + fmts_tbl(&segs), + true, + )], + subdirs: Vec::new(), + }], + }, + ); + Self { + meta_disc, + segs, + meta_reads: 0, + probe_reads: 0, + fault_span: None, + } + } + + /// The 6144-byte ciphertext of the aligned unit starting at clip byte + /// `unit_byte`: inside a segment, EVEN units carry that index's content and + /// ODD units the alternate variant; outside, ordinary base-Unit-Key content. + fn unit_at(&self, unit_byte: u64) -> Vec { + for s in &self.segs { + let sb = s.start_byte(); + if unit_byte >= sb && unit_byte < sb + s.byte_len() { + let n = (unit_byte - sb) / crate::aacs::content::ALIGNED_UNIT_LEN as u64; + let key = if n % 2 == 0 { + FMTS_INDEX_KEYS[(s.index - 1) as usize] + } else { + FMTS_ALT_KEY + }; + return encrypted_clean_unit(&key); + } + } + encrypted_clean_unit(&FMTS_BASE_KEY) + } + } + + impl SectorSource for FmtsDisc { + fn capacity_sectors(&self) -> u32 { + 1_000_000 + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> crate::error::Result { + if let Some((a, b)) = self.fault_span { + if lba >= a && lba < b { + self.probe_reads += 1; + return Err(crate::error::Error::DiscRead { + sector: lba as u64, + status: None, + sense: None, + }); + } + } + if lba < FMTS_CONTENT_LBA { + self.meta_reads += 1; + return self.meta_disc.read_sectors(lba, count, buf, recovery); + } + self.probe_reads += 1; + let want = count as usize * 2048; + buf[..want].fill(0); + for s in 0..count as u32 { + let off = (lba + s - FMTS_CONTENT_LBA) as u64; + let unit_byte = (off / 3) * crate::aacs::content::ALIGNED_UNIT_LEN as u64; + let within = (off % 3) as usize * 2048; + let unit = self.unit_at(unit_byte); + let dst = s as usize * 2048; + buf[dst..dst + 2048].copy_from_slice(&unit[within..within + 2048]); + } + Ok(want) + } + } + + /// A `KeyFetch` whose `fmts_indexes` counts its calls and behaves like the real + /// service: it replies with the disc's COMPLETE ordered index-key set only for a + /// genuine index-1 anchor batch (one that opens under index key 1), and empty + /// otherwise. `unit_keys` is never used on this path. + fn counting_fmts_fetch( + calls: std::sync::Arc, + ) -> crate::sector::KeyFetch { + crate::sector::KeyFetch::new( + std::sync::Arc::new(|_| Vec::new()), + std::sync::Arc::new(move |batch: &[Vec]| { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let Some(first) = batch.first() else { + return Vec::new(); + }; + let mut u = first.clone(); + crate::aacs::content::decrypt_unit(&mut u, &FMTS_INDEX_KEYS[0]); + if crate::aacs::content::is_clean(&u, ContentFormat::BdTs) { + FMTS_INDEX_KEYS.to_vec() + } else { + Vec::new() + } + }), + ) + } + + fn fmts_title(sectors: u32) -> DiscTitle { + multi_cps_title(FMTS_CONTENT_LBA, sectors) + } + + fn fmts_keys() -> DecryptKeys { + DecryptKeys::Aacs { + unit_keys: vec![(0, FMTS_BASE_KEY)], + read_data_key: None, + format: ContentFormat::BdTs, + } + } + + fn pool_of(keys: &DecryptKeys) -> Vec<(u32, [u8; 16])> { + match keys { + DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(), + _ => Vec::new(), + } + } + + /// The defect this fixes: `resolve_content_key_map` resolves EVERY title, and the + /// FMTS branch used to re-walk the UDF filesystem, re-run the anchor probe, re-run + /// the 2-index phase probe AND re-ask the key service — once per playlist — for an + /// answer that is a property of the DISC. N titles must cost ONE UDF walk, ONE + /// index probe and ONE `fmts_indexes` call. + /// + /// Mutations, and which assert kills each: + /// * `if table.is_none()` → `if true` (never memoise the segment table): + /// the `meta_reads` assert fails (the walk repeats per title). + /// * removing the `memo.get(&ek)` short-circuit: the `probe_reads` AND the + /// `fmts_indexes` call-count asserts both fail. + /// * `if probed.all_phases_definite` → `if false` (never insert): same two. + #[test] + fn fmts_index_keys_resolved_once_per_disc_not_once_per_title() { + use std::sync::atomic::Ordering; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls.clone()); + let mut reader = FmtsDisc::new(); + let mut keys = fmts_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_title(FMTS_CONTENT_SECTORS); + + let first = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("the synthetic FMTS disc resolves"); + let (meta1, probe1) = (reader.meta_reads, reader.probe_reads); + let calls1 = calls.load(Ordering::SeqCst); + assert!(meta1 > 0, "the first title really does walk the UDF tree"); + assert_eq!( + probe1, 40, + "the first title pays the anchor (8 reads) plus a 16-read phase probe per index" + ); + assert_eq!(calls1, 1, "ONE anchor batch resolves the whole index set"); + // The map really is the forensic one: two segment ranges on their index-key + // slots with the probed phase, and the base key over the rest. + assert_eq!( + first.key_idx_for(10_300), + Some(1), + "segment index 1 keys to its own pool slot" + ); + assert_eq!( + first.key_idx_for(10_600), + Some(2), + "segment index 2 keys to its own pool slot" + ); + assert_eq!( + first.key_idx_for(10_000), + Some(0), + "base key outside segments" + ); + + // 59 more playlists of the same clip — the shape of a real BD. + for _ in 0..59 { + let again = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("a later title resolves from the memo"); + assert_eq!( + again.ranges(), + first.ranges(), + "a memoised title must produce the byte-identical map" + ); + } + assert_eq!( + reader.meta_reads, meta1, + "the UDF walk / segment table is a DISC fact: exactly one walk for 60 titles" + ); + assert_eq!( + reader.probe_reads, probe1, + "the anchor and phase probes must not re-run per title" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "the key service must be asked ONCE per disc, not once per playlist" + ); + // And the pool grew exactly once — the index keys were banked, not duplicated. + assert_eq!( + pool_of(&keys).len(), + 1 + FMTS_INDEX_KEYS.len(), + "the base key plus one slot per forensic index, appended once" + ); + } + + /// Result-identity, proven rather than assumed: the same three titles resolved + /// with a SHARED per-disc memo must produce the same maps — and leave the key + /// pool in the same state — as resolving each with a FRESH memo (i.e. the + /// unmemoised per-title recomputation). + /// + /// Mutation: key the FMTS memo on something NOT title-invariant (e.g. drop the + /// extent list from `extent_key` and key on `format` alone) → title 3's differing + /// extents are served the wrong answer and the range comparison fails. + #[test] + fn fmts_memoised_map_equals_per_title_recomputation() { + let titles = [ + fmts_title(FMTS_CONTENT_SECTORS), + fmts_title(FMTS_CONTENT_SECTORS), + // A DIFFERENT extent list (a longer clip): a memo miss that must be + // recomputed, not served the first title's answer. + fmts_title(FMTS_CONTENT_SECTORS + 500), + ]; + + let mut shared_cache = super::DiscKeyCache::new(); + let mut shared_keys = fmts_keys(); + let mut reader = FmtsDisc::new(); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls.clone()); + let shared: Vec<_> = titles + .iter() + .map(|t| { + super::resolve_mux_key_map_cached( + &mut reader, + t, + &mut shared_keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut shared_cache, + ) + .expect("shared-memo resolve") + }) + .collect(); + + let mut fresh_keys = fmts_keys(); + let mut reader2 = FmtsDisc::new(); + let calls2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch2 = counting_fmts_fetch(calls2.clone()); + let fresh: Vec<_> = titles + .iter() + .map(|t| { + super::resolve_mux_key_map_cached( + &mut reader2, + t, + &mut fresh_keys, + Some(&fetch2), + ContentFormat::BdTs, + None, + // A fresh memo per title == the unmemoised behaviour. + &mut super::DiscKeyCache::new(), + ) + .expect("per-title recompute") + }) + .collect(); + + for (i, (a, b)) in shared.iter().zip(fresh.iter()).enumerate() { + assert_eq!( + a.ranges(), + b.ranges(), + "title {i}: the memoised map must equal the per-title recomputation" + ); + } + assert_eq!( + pool_of(&shared_keys), + pool_of(&fresh_keys), + "the key pool must end in the same state — same keys, same slots, same order" + ); + // And the memo really was doing work: the unmemoised run paid 3 walks and 3 + // key-service calls where the memoised one paid 1 walk and 2 calls (title 3's + // extent list is a genuine miss). + assert!( + reader2.meta_reads > reader.meta_reads, + "the per-title recomputation re-walks the UDF tree; the memoised run does not" + ); + assert_eq!( + ( + calls.load(std::sync::atomic::Ordering::SeqCst), + calls2.load(std::sync::atomic::Ordering::SeqCst) + ), + (2, 3), + "memoised: one call per DISTINCT extent list; unmemoised: one per title" + ); + } + + /// A DIFFERENT extent list is a genuine miss: the extent list is the only + /// per-title input to the probes, so it is IN the memo key and a title with a + /// different one must re-probe rather than inherit. + /// + /// Mutation: drop `title.extents` from `extent_key` → the second title hits and + /// the `probe_reads` / call-count asserts fail. + #[test] + fn fmts_different_extent_list_is_a_miss_and_reprobes() { + use std::sync::atomic::Ordering; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls.clone()); + let mut reader = FmtsDisc::new(); + let mut keys = fmts_keys(); + let mut cache = super::DiscKeyCache::new(); + + let a = fmts_title(FMTS_CONTENT_SECTORS); + super::resolve_mux_key_map_cached( + &mut reader, + &a, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("first title resolves"); + let (meta1, probe1) = (reader.meta_reads, reader.probe_reads); + + let b = fmts_title(FMTS_CONTENT_SECTORS + 500); + super::resolve_mux_key_map_cached( + &mut reader, + &b, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("second title resolves"); + assert_eq!( + reader.meta_reads, meta1, + "the segment table is disc-wide: still no second UDF walk" + ); + assert_eq!( + reader.probe_reads, + probe1 * 2, + "a different extent list must re-probe from its own bytes" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "and re-ask the key service for that extent list" + ); + } + + /// A phase probe that READ-FAULTED on every segment of an index leaves that + /// index defaulted to `Phase::All` — degraded but complete. That is a property of + /// a transient live-drive fault, NOT of the title's extents, so it must NOT be + /// memoised: caching it would spread one bad read across every remaining + /// playlist. The next title must re-probe. + /// + /// Mutation: memoise unconditionally (drop the `all_phases_definite` guard) → + /// the second title is served from cache and both the `probe_reads` and the + /// key-service call-count asserts fail. + #[test] + fn fmts_read_faulted_phase_is_not_memoised() { + use std::sync::atomic::Ordering; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls.clone()); + let mut reader = FmtsDisc::new(); + // Fault every read of the index-2 segment (clip bytes 6400*192.. → LBAs + // 10600..10840): the anchor (index 1) still succeeds, so the keys are in + // hand, but index 2's phase probe has zero decrypt evidence. + reader.fault_span = Some((10_600, 10_840)); + let mut keys = fmts_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_title(FMTS_CONTENT_SECTORS); + + let first = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("a read fault must NOT abort a rip whose index keys are good"); + assert_eq!( + first.entry_for(10_600).map(|(_, p, _)| p), + Some(crate::decrypt::Phase::All), + "the read-faulted index defaults to Phase::All" + ); + assert_eq!( + first.entry_for(10_300).map(|(_, p, _)| p), + Some(crate::decrypt::Phase::Even), + "the index that DID probe keeps its resolved phase" + ); + let probe1 = reader.probe_reads; + + super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("second title resolves"); + assert!( + reader.probe_reads > probe1, + "a read-faulted run must not be memoised — the next title re-probes" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "and re-anchors rather than inheriting a degraded answer" + ); + } + + /// The UDF walk that decides whether a disc is FMTS at all runs on EVERY disc, + /// FMTS or not. On a plain (non-UDF / non-FMTS) BD it must be attempted ONCE for + /// the whole disc, not once per playlist — ~35 low-LBA single-sector reads reached + /// by a full-stroke seek back from wherever the last title's content sampling left + /// the head. + /// + /// Mutation: `if table.is_none()` → `if true` → the second title re-reads the + /// metadata and the `meta` assert fails. + #[test] + fn non_fmts_disc_walks_the_filesystem_once_for_every_title() { + let key_a = [0x01u8; 16]; + let key_b = [0x02u8; 16]; + // All-zero metadata → no AVDP at sector 256 → `UdfNotFilesystem`, the + // deterministic "not an FMTS disc" negative that IS memoised. + let mut reader = CountingCipherSource::new(vec![ + (1000, 1030, encrypted_clean_unit(&key_b)), + (9000, 9030, encrypted_clean_unit(&key_b)), + ]); + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key_a), (1, key_b)], + read_data_key: None, + format: ContentFormat::BdTs, + }; + let mut cache = super::DiscKeyCache::new(); + + super::resolve_mux_key_map_cached( + &mut reader, + &multi_cps_title(1000, 30), + &mut keys, + None, + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("first title resolves"); + let meta1 = reader.meta; + assert!( + meta1 > 0, + "the first title really does attempt the UDF walk" + ); + + // A second title with DIFFERENT extents (so the CPS memo misses and the + // title is genuinely resolved) must still not re-walk the filesystem. + super::resolve_mux_key_map_cached( + &mut reader, + &multi_cps_title(9000, 30), + &mut keys, + None, + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("second title resolves"); + assert_eq!( + reader.meta, meta1, + "the not-FMTS verdict is a disc fact: one filesystem walk for the disc" + ); + } + + /// Halt must stay responsive even when both FMTS memos are warm and a title does + /// no I/O at all: `resolve_content_key_map` polls nothing itself, so the entry + /// check inside the FMTS branch is the only cancellation point a fully-memoised + /// title reaches. + /// + /// Mutation: remove the `check_halt()?` at the top of `resolve_fmts_key_map` → + /// the cancelled second title returns `Ok(map)` and `expect_err` fails. + #[test] + fn fmts_memoised_title_still_honors_halt() { + use crate::halt::Halt; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls); + let mut reader = FmtsDisc::new(); + let mut keys = fmts_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_title(FMTS_CONTENT_SECTORS); + let halt = Halt::new(); + + // Warm both memos. + super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + Some(&halt), + &mut cache, + ) + .expect("first title resolves"); + + halt.cancel(); + let err = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + Some(&halt), + &mut cache, + ) + .expect_err("a cancelled halt must abort even a fully-memoised title"); + assert!(crate::error::is_halt(&err), "expected Halted, got: {err}"); + } }