Files
libfreemkv/src/css/mod.rs
T
Matthew Jackson dc5b67ed46 Stop reporting an uncrackable CSS disc as N empty titles
Same shape as the mkv:// conflation fixed earlier in this round, found by
looking for it deliberately. E7023 carried two conditions with opposite
correct responses: one title on a multi-VTS DVD failing its own re-crack,
where skipping it and finishing the rest is right, and the main feature's
crack failing outright, which is disc-wide and dooms every title
identically. Because both raised the same code and that code is in
is_skippable_title_stub, an uncrackable disc walked all N titles printing
"title skipped, it was empty" and exited 0.

The disc-wide condition gets E7027 CssNoDiscKey, mirroring the AACS-side
E7022 NoDiscKey it is the analogue of, and joins is_disc_level_no_key.
The per-title raise keeps E7023 and stays skippable. Because the engine's
classifier already tests is_disc_level_no_key before the skippable
branch, this reaches the right outcome downstream with no change there:
such a disc now stops on the first title and reports no-key instead of
returning success with nothing written.

Disc::css_error deliberately still stores CssKeyMissing — autorip matches
that variant on the field to pick the CSS rather than AACS message, and
what consumers classify on is the gate's returned verdict, which is the
only thing that changed.

Two neighbouring CSS raises were examined and deliberately left alone:
the no-key branch in the same function is genuinely unreachable via
ensure_decryptable and documented as defensive, and resolve_dvd_title_key
is per-title on both of its call paths.

Verified by removing the new code from is_disc_level_no_key, which fails
both new tests; each pins both directions so neither can silently flip.
Not proven end to end against a real uncrackable disc — none available.
2026-07-29 22:29:51 -07:00

1225 lines
53 KiB
Rust

//! CSS (Content Scramble System) — DVD disc encryption.
//!
//! CSS uses a weak 40-bit LFSR stream cipher (broken since 1999).
//!
//! The title key is recovered keylessly: [`crack_key`] runs the Stevenson
//! known-plaintext attack (see the [`stevenson`] module) on the scrambled
//! data, needing no player keys, disc-key crack, or external key file.
//! Sectors are then decrypted with [`descramble_sector`].
//!
//! Usage:
//! ```rust,ignore
//! if let Some(state) = css::crack_key(reader, extents, batch) {
//! css::descramble_sector(&state, &mut sector);
//! }
//! ```
pub mod lfsr;
pub mod stevenson;
pub(crate) mod tables;
use crate::disc::Extent;
use crate::sector::SectorSource;
/// Consecutive CSS-locked (`05/6F/03`) reads before the crack scan early-bails.
/// The bus-auth read gate is global (all-or-nothing), so a run this long means
/// it is shut and nothing here is crackable — bail instead of grinding the full
/// 50_000-sector budget (which is what made rc5 appear to hang on a wedged USB
/// bridge). The counter resets to 0 on any readable batch.
const CSS_LOCKED_BAIL: u32 = 64;
/// CSS decryption state for a DVD title.
#[derive(Clone)]
pub struct CssState {
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
pub title_key: [u8; 5],
/// LBA half-open span `[start, end)` of the extent set this key was
/// cracked from. CSS title keys are per-VTS: a key cracked from one
/// VTS does NOT descramble a title living in a different VTS. The mux
/// path checks whether the title being opened overlaps this span; if
/// not, it re-cracks from that title's own extents. `None` for keys
/// of unknown provenance (e.g. test fixtures) — treated as "applies
/// everywhere" for backward compatibility.
pub crack_span: Option<(u32, u32)>,
}
// Redacting `Debug`: `CssState` is reachable via the public `Disc.css` field, so
// a `{:?}` on a `Disc` would otherwise print the raw CSS title key. Print only
// the (non-secret) crack span. Guarded by `css_state_debug_is_redacted`.
impl std::fmt::Debug for CssState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CssState")
.field("title_key", &"<redacted>")
.field("crack_span", &self.crack_span)
.finish()
}
}
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
///
/// The crib comes from the periodic-run detector: a scrambled sector's cleartext region
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
/// constant fill); the attack assumes that run continues across the 0x80
/// boundary into the encrypted region, giving the known plaintext the 2^16
/// LFSR recovery needs. We scan up to 50000 sectors across the
/// extents and return the first sector that yields a key — no player keys, no
/// disc-key crack. Works on a live drive (after bus-auth unlocks reads) and on
/// disc images alike.
/// This convenience form runs to completion (no cancellation) and returns just
/// the key; callers needing an operator-Stop / watchdog cancel, or the three-way
/// [`CrackOutcome`] (to distinguish "unencrypted" from "encrypted-but-uncracked"),
/// use [`crack_key_outcome`], which takes a `halt` token.
pub fn crack_key(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
) -> Option<CssState> {
crack_key_scan(reader, extents, batch_sectors, None, false).into_state()
}
/// Outcome of a CSS crack scan that distinguishes the THREE cases the bare
/// `Option<CssState>` conflated (and which caused a silent-failure bug:
/// scrambled-but-uncracked content was treated as "unencrypted" and muxed as
/// plaintext garbage at exit 0):
///
/// - [`CrackOutcome::Cracked`] — a scrambled sector yielded a title key.
/// - [`CrackOutcome::Unencrypted`] — NO scrambled sector was seen across the
/// scanned extents (`is_scrambled` never true): the content is genuinely
/// plaintext, so proceeding without a key is correct.
/// - [`CrackOutcome::ScrambledUncracked`] — scrambled sectors WERE seen but no
/// key could be recovered (the Stevenson attack found no crackable crib, or
/// the scrambled region was unreadable). The content is encrypted; muxing it
/// as plaintext would emit garbage, so callers MUST surface a hard error
/// instead of falling through to "unencrypted" — the per-title
/// [`crate::error::Error::CssKeyMissing`] when it is ONE title's own re-crack
/// that failed (skippable: a sibling VTS may still crack), or the disc-level
/// [`crate::error::Error::CssNoDiscKey`] when it is the disc-wide scan
/// (`Disc::css_error`, every title fails identically).
#[derive(Debug, Clone)]
pub enum CrackOutcome {
Cracked(CssState),
Unencrypted,
ScrambledUncracked,
}
impl CrackOutcome {
/// The cracked `CssState`, if any. `None` for `Unencrypted` /
/// `ScrambledUncracked`. Lets the `Option`-returning wrappers stay thin.
pub fn into_state(self) -> Option<CssState> {
match self {
CrackOutcome::Cracked(s) => Some(s),
_ => None,
}
}
/// True when scrambled sectors were seen but no key was recovered — the
/// case callers must surface as a hard error instead of "unencrypted".
pub fn is_scrambled_uncracked(&self) -> bool {
matches!(self, CrackOutcome::ScrambledUncracked)
}
}
/// [`crack_key`] returning the full [`CrackOutcome`] (Cracked / Unencrypted /
/// ScrambledUncracked) so callers can distinguish "genuinely unencrypted" from
/// "encrypted but uncrackable" — the latter must become a hard error, never a
/// silent fall-through to plaintext.
///
/// Takes an optional cooperative-cancellation token. "No silent hangs": the
/// crack scans up to 50_000 sectors, which on a live drive hitting bad sectors
/// can take a long time, so it polls `halt` once per batch (the same cadence
/// sweep/patch use) and emits a `freemkv::heartbeat` beat ("css_crack") each
/// batch so a stuck scan is visible in the log.
pub fn crack_key_outcome(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
) -> CrackOutcome {
crack_key_scan(reader, extents, batch_sectors, halt, true)
}
/// Resolve a DVD title's CSS descramble key from the reader when the caller
/// supplied none — the SINGLE place every DVD read path obtains a title key, so
/// the file-backed mux highway ([`crate::build_iso_pipeline`]) and the
/// live-drive single-pass [`crate::DiscStream`] descramble a DVD identically
/// ("reading is reading"). CSS keys are per-VTS and crackable from the scrambled
/// data itself, so a `None`/MPEG-PS title cracks its own key here, in playback
/// order over `extents`. Everything else is left untouched:
/// - AACS keys (HD-DVD `.evo` is also MPEG-PS but arrives as `Aacs`) — no CSS.
/// - a title that already carries a key — nothing to resolve.
/// - a genuinely clear DVD (no scrambled sector) — stays `None`, a mux no-op.
///
/// A scrambled-but-uncrackable title is a hard [`crate::error::Error::CssKeyMissing`],
/// never a silent scrambled-passthrough mux. That code is the PER-TITLE one
/// (`error::is_skippable_title_stub`), which is correct here: this function
/// cracks ONE title's own extents, and another VTS on the same disc may still
/// yield its key, so an all-titles rip skips this title and finishes the rest.
/// The whole-disc failure is [`crate::error::Error::CssNoDiscKey`], raised by
/// `Disc::ensure_decryptable_keys` from the scan's `css_error`.
pub(crate) fn resolve_dvd_title_key(
reader: &mut dyn SectorSource,
extents: &[Extent],
keys: &mut crate::decrypt::DecryptKeys,
batch_sectors: u16,
format: crate::disc::ContentFormat,
raw: bool,
halt: Option<&crate::halt::Halt>,
) -> std::io::Result<()> {
// `--raw` = deliberate ciphertext passthrough: never crack or descramble, and
// never hard-fail on scrambled-uncrackable — the user asked for the scrambled
// bytes. (In raw mode the caller hands us `None` on purpose; without this
// guard we'd install a real key and silently DECRYPT, or abort a raw mux.)
if raw {
return Ok(());
}
if matches!(keys, crate::decrypt::DecryptKeys::None)
&& format == crate::disc::ContentFormat::MpegPs
{
// `halt` threads the caller's cancellation token so /api/stop can
// interrupt a long crack scan (the old scan-time crack honored it too).
let outcome = crack_key_outcome(reader, extents, batch_sectors, halt);
// A cancelled crack breaks out early, so its outcome is a TRUNCATED scan
// — not a real verdict. Interpreting it would either hard-fail a good disc
// as `ScrambledUncracked` (quarantining staging on a Stop) or, worse,
// read a half-scanned title as `Unencrypted` and mux scrambled bytes as
// plaintext. Surface the cancellation as `Halted` so the caller takes its
// graceful-stop path instead of trusting the partial outcome.
if halt.map(|h| h.is_cancelled()).unwrap_or(false) {
return Err(crate::error::Error::Halted.into());
}
match outcome {
CrackOutcome::Cracked(state) => {
*keys = crate::decrypt::DecryptKeys::Css {
title_key: state.title_key,
};
}
CrackOutcome::ScrambledUncracked => {
return Err(crate::error::Error::CssKeyMissing.into());
}
CrackOutcome::Unencrypted => {}
}
}
Ok(())
}
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
/// collapses both to `None` via [`CrackOutcome::into_state`]).
fn crack_key_scan(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
// True only on the INITIAL scan: a fully CSS-locked (`05/6F/03`) result is a
// hard `ScrambledUncracked`. False on the per-VTS re-crack so a lapsed-AGID
// locked read returns None instead of killing a genuinely crackable title.
fail_on_locked: bool,
) -> CrackOutcome {
// Batch the reads: a live optical drive at 1 sector/read is glacial, and the
// crack only needs to FIND one scrambled sector whose 0x80 plaintext matches
// a known PES header. `batch_sectors` MUST be sized to the source — a drive
// rejects a READ(10) larger than its per-command max (DVD = 16) and
// `Drive::read` does not chunk, so an over-large batch fails every read and
// scans nothing. Callers pass `detect_max_batch_sectors(device_path)` for a
// live drive, a file-safe value for an image, or 1 to force per-sector.
let batch = (batch_sectors.max(1)) as u32;
// Record the LBA span the key is being cracked from so the per-title mux
// path can tell whether a later title lives in the same VTS (overlaps the
// span → key applies) or a different one (→ re-crack). Half-open [min,max).
let crack_span = extents
.iter()
.filter(|e| e.sector_count > 0)
.map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count)))
.reduce(|(amin, amax), (bmin, bmax)| (amin.min(bmin), amax.max(bmax)));
let mut tried = 0u32;
let max_tries = 50_000u32;
let mut buf = vec![0u8; batch as usize * 2048];
let mut hb = crate::progress::Heartbeat::new("css_crack");
// Track whether ANY scrambled sector was observed. If we exhaust the scan
// budget having seen scrambled data but never recovered a key, the content
// is encrypted-but-uncrackable — a HARD failure the caller must surface,
// NOT silently treat as unencrypted (which would mux scrambled MPEG as
// plaintext → garbage at exit 0). See `CrackOutcome::ScrambledUncracked`.
let mut saw_scrambled = false;
// A read rejected with sense `05/6F/03` ("scrambled sector without
// authentication") is positive proof of CSS encryption — never collapse it
// to "unencrypted". A run of consecutive locked reads means the bus-auth
// gate is shut (it is global, so reads are all-or-nothing), so the scan
// early-bails. `consecutive_locked` resets on any readable batch, so a
// crackable title (gate open) never trips it.
let mut saw_locked = false;
let mut consecutive_locked = 0u32;
'outer: for (extent_idx, ext) in extents.iter().enumerate() {
let mut i = 0u32;
while i < ext.sector_count && tried < max_tries {
// Cooperative cancellation — poll once per batch, the same cadence
// sweep/patch use, so a Stop / watchdog can interrupt the scan.
if let Some(h) = halt
&& h.is_cancelled()
{
break 'outer;
}
// Liveness beacon: a long scan over a damaged disc stays visible.
// The heartbeat is time-throttled; only when it actually beats do
// we emit the crack-specific context (tried/lba/extent_idx).
if hb.tick(tried as u64, max_tries as u64) {
tracing::debug!(
target: "freemkv::heartbeat",
phase = "css_crack",
tried,
lba = ext.start_lba + i,
extent_idx,
"scanning"
);
}
let n = (ext.sector_count - i).min(batch);
let want = n as usize * 2048;
match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) {
Ok(_) => {
// A readable batch: the gate is open — reset the locked run.
consecutive_locked = 0;
for s in 0..n as usize {
tried += 1;
let sect = &buf[s * 2048..(s + 1) * 2048];
// Use the HARDENED pack-gated check (Fix 3): a clear stub
// sector with stray bits at 0x14 must NOT count as
// scramble evidence, or a genuinely-unencrypted title
// would falsely report ScrambledUncracked (a false E7023).
if is_scrambled_pack(sect) {
saw_scrambled = true;
if let Some(key) = stevenson::crack_title_key(sect) {
return CrackOutcome::Cracked(CssState {
title_key: key,
crack_span,
});
}
}
if tried >= max_tries {
break 'outer;
}
}
}
// A failed batch still counts toward the budget so a damaged
// region can't loop forever. A CSS-locked failure (`05/6F/03`)
// proves encryption and, in a long enough run, means the read
// gate is shut — track it and early-bail rather than grind.
Err(e) => {
tried += n;
if e.scsi_sense().is_some_and(|s| s.is_css_locked()) {
saw_locked = true;
consecutive_locked += 1;
if consecutive_locked >= CSS_LOCKED_BAIL {
break 'outer;
}
} else {
consecutive_locked = 0;
}
}
}
i += n;
}
}
// Budget exhausted / extents walked / early-bailed with no key recovered.
// The disc is ENCRYPTED-but-uncracked (a hard failure on the initial scan)
// when EITHER a scrambled sector was actually seen, OR — on the initial scan
// only (`fail_on_locked`) — every read was CSS-locked (`05/6F/03`), itself
// proof of scrambling. A re-crack (`fail_on_locked` false) stays soft: a
// lapsed-AGID locked read yields None, not a hard fail, so a crackable title
// in another VTS isn't killed. Only a scan that saw neither a scrambled
// sector nor a CSS-lock is genuinely unencrypted.
if saw_scrambled || (saw_locked && fail_on_locked) {
CrackOutcome::ScrambledUncracked
} else {
CrackOutcome::Unencrypted
}
}
/// Descramble a single CSS-encrypted sector in place.
pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
lfsr::descramble_sector(&state.title_key, sector);
}
/// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB
/// region boundary. `title_key` is a CACHE of the last crack, not a fixed disc
/// key: it changes per VTS/VOB region, so it is validated on every scrambled
/// sector and re-cracked on a miss (the standard on-demand per-region rekey).
///
/// This CSS key acquisition is intrinsic to the cipher — CSS has no external key
/// source, the ONLY way to a title key is cracking the data — so it lives with
/// the CSS primitives and runs inside `decrypt::decrypt_sectors` (a public,
/// self-contained CSS decrypt), NOT at the post-decrypt recovery seam that AACS
/// key-fetch and FMTS segment-skip use (those consume external inputs).
///
/// The clear header (`<0x80`) is never scrambled, so its periodic crib predicts
/// the plaintext at `0x80`. Descramble with the cached key; if the crib fails to
/// reappear the key region changed (or the primed key was wrong) — restore the
/// ciphertext, re-crack from this very sector, and descramble again. A crib-less
/// sector (no periodic run) can be neither validated nor cracked, so it rides the
/// cached key — correct, because it lives in the same region as the nearby crib
/// sector that set the cache.
pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) {
for chunk in buf.chunks_mut(2048) {
if chunk.len() < 2048 || !is_scrambled(chunk) {
continue;
}
let crib = stevenson::attack_crib(chunk);
// Snapshot the ciphertext (chunk is exactly 2048 here) only when there is
// a crib to validate against, so the common cache-hit path costs no
// per-sector heap allocation.
let mut original = [0u8; 2048];
if crib.is_some() {
original.copy_from_slice(chunk);
}
lfsr::descramble_sector(title_key, chunk);
if let Some(crib) = crib
&& chunk[0x80..0x80 + 10] != crib[..]
{
// Cached key is stale for this region — restore the ciphertext and
// crack this sector's own key.
chunk.copy_from_slice(&original);
if let Some(fresh) = stevenson::crack_title_key(chunk) {
*title_key = fresh;
}
lfsr::descramble_sector(title_key, chunk);
}
}
}
/// Check if a sector has the CSS scramble flag set.
///
/// This is the RAW flag test — bits 4-5 of the sub-header byte 0x14 — used by
/// the descramble loop (`decrypt::decrypt_sectors`), which has already committed
/// to descrambling a known title's VOB data and only needs to skip the clear
/// NAV packs interleaved in it. For the CRACK SCAN's "did this disc actually
/// contain scrambled content?" decision (which must not false-positive on a
/// clear stub), use [`is_scrambled_pack`] instead.
pub fn is_scrambled(sector: &[u8]) -> bool {
sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0
}
/// The 4-byte MPEG-2 Program Stream pack-start code (`00 00 01 BA`) every DVD
/// video sector opens with. CSS leaves the clear header (`0x00..0x80`)
/// untouched, so this signature survives scrambling.
pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
/// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED test
/// the crack scan uses to set its `saw_scrambled` evidence flag (Fix 3).
///
/// [`is_scrambled`] keys solely on bits 4-5 of byte 0x14. That single byte is
/// only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack,
/// which ALWAYS begins with the 32-bit pack-start code `00 00 01 BA` at offset
/// 0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title)
/// can carry arbitrary bytes that happen to set bits 4-5 of byte 0x14; trusting
/// byte 0x14 alone there would flip the scan's `saw_scrambled` gate and make a
/// genuinely-UNENCRYPTED title report `ScrambledUncracked` — a false E7023.
///
/// Requiring the pack-start signature FIRST means only a sector that is
/// structurally a DVD video pack can be counted as scramble evidence. This does
/// NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real
/// scrambled feature is made of valid PS packs, so its scrambled sectors still
/// pass this check and still drive `ScrambledUncracked` when no key cracks. (The
/// descramble loop keeps the looser [`is_scrambled`]: by the time it runs we
/// already know the title is CSS, and it only needs to skip interleaved clear
/// NAV packs — a wrongly-skipped or wrongly-included sector there is recoverable
/// per-sector, whereas a false scramble verdict in the scan poisons the whole
/// title's outcome.)
pub fn is_scrambled_pack(sector: &[u8]) -> bool {
sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && (sector[0x14] >> 4) & 0x03 != 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{Error, Result};
/// `CssState` is reachable via the public `Disc.css` field, so a `{:?}` on a
/// `Disc` must not print the raw CSS title key. Sentinel byte 213 (0xD5);
/// `crack_span` is non-secret and none of its values are 213.
#[test]
fn css_state_debug_is_redacted() {
let s = CssState {
title_key: [0xD5; 5],
crack_span: Some((10, 20)),
};
let dbg = format!("{s:?}");
assert!(
!dbg.contains("213"),
"CssState Debug leaked the title key: {dbg}"
);
assert!(
dbg.contains("redacted"),
"CssState Debug missing marker: {dbg}"
);
}
// ── is_scrambled ───────────────────────────────────────────────────────
/// is_scrambled returns false for any buffer shorter than one sector,
/// WITHOUT indexing byte 0x14 (which would panic on a tiny buffer). The
/// length guard is short-circuited before the flag read.
///
/// Grounding: `sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0` —
/// `&&` short-circuits so a 20-byte buffer never reads index 0x14.
/// Mutation: swap the operands so the flag is read first
/// (`(sector[0x14]...) && sector.len() >= 2048`) -> panics indexing a
/// 20-byte slice; this test catches it.
#[test]
fn is_scrambled_short_buffer_is_false_no_panic() {
assert!(!is_scrambled(&[]));
assert!(!is_scrambled(&[0u8; 20])); // shorter than 0x14+1 even
assert!(!is_scrambled(&[0xFFu8; 2047])); // one byte short of a sector
}
/// is_scrambled keys on bits 4-5 of byte 0x14 (the CSS scramble field).
/// A full sector flagged 0x10/0x20/0x30 is scrambled; 0x00 and the
/// high-bit-only values 0x40/0x80 are clear.
///
/// Grounding: `(sector[0x14] >> 4) & 0x03`.
/// Mutation: widen mask to `& 0x0F` -> 0x40 reports scrambled, the 0x40
/// assert fails.
#[test]
fn is_scrambled_uses_bits_4_5_only() {
let mut s = vec![0u8; 2048];
for (flag, expected) in [
(0x00u8, false),
(0x10, true),
(0x20, true),
(0x30, true),
(0x40, false),
(0x80, false),
(0xC0, false),
(0xFF, true), // bits 4-5 set within 0xFF
] {
s[0x14] = flag;
assert_eq!(
is_scrambled(&s),
expected,
"flag byte {flag:#04x} scramble detection"
);
}
}
/// is_scrambled accepts exactly 2048 bytes as the minimum (boundary at the
/// inclusive value 2048).
///
/// Grounding: `sector.len() >= 2048`.
/// Mutation: change `>= 2048` to `> 2048` -> an exact 2048-byte scrambled
/// sector reports false; this fails.
#[test]
fn is_scrambled_exact_sector_length_accepted() {
let mut s = vec![0u8; 2048];
s[0x14] = 0x30;
assert!(is_scrambled(&s), "exactly 2048 bytes must be eligible");
}
/// Fix 3 hardening: `is_scrambled_pack` (the crack-scan evidence gate)
/// requires BOTH the MPEG-PS pack-start code at 0x00 AND the 0x14 scramble
/// bits. A clear / nav-only stub whose bytes happen to set bits 4-5 of 0x14
/// but lacks the pack-start is NOT counted as scramble evidence — without
/// this the scan flips `saw_scrambled` and a genuinely unencrypted title
/// reports `ScrambledUncracked` (the false E7023). The looser `is_scrambled`
/// (descramble gate) still reads the same sector as flagged.
///
/// Grounding: `sector[0x00..0x04] == 00 00 01 BA && (sector[0x14] >> 4)...`.
/// Mutation: drop the pack-start clause -> the 0x14-only sector counts as a
/// scrambled pack; the first assert fails.
#[test]
fn is_scrambled_pack_requires_pack_start_signature() {
let mut s = vec![0u8; 2048];
s[0x14] = 0x30; // scramble bits set, but no pack-start at 0x00
assert!(
!is_scrambled_pack(&s),
"0x14 bits without the MPEG-PS pack-start must NOT count as a scrambled pack"
);
// The looser descramble-gate check still sees the raw flag.
assert!(is_scrambled(&s), "is_scrambled keys on the 0x14 flag alone");
// A near-miss pack-start (wrong final byte) is still rejected.
s[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBB]);
assert!(
!is_scrambled_pack(&s),
"a wrong pack-start byte must not qualify"
);
// The real signature flips it to a scrambled pack.
s[0x00..0x04].copy_from_slice(&PACK_START);
assert!(
is_scrambled_pack(&s),
"valid pack-start + 0x14 bits → scrambled pack"
);
}
// ── crack_key scanning over a mock SectorSource ────────────────────────
/// Records every (lba, count) read; returns a caller-supplied flag byte at
/// 0x14 so we can drive scrambled/clear sectors, or an injected error.
struct MockSource {
reads: std::cell::RefCell<Vec<u32>>,
flag_byte: u8,
fail_all: bool,
/// Every read fails with CSS-locked sense `05/6F/03` (drive refusing
/// scrambled reads because the bus-auth gate isn't open).
lock_all: bool,
/// When set, the sector at `crackable.0` is served as a full
/// Stevenson-crackable scrambled sector (`crackable.1`, 2048 bytes)
/// instead of the uniform `flag_byte` fill. Lets the scan actually
/// reach `CrackOutcome::Cracked` from a synthetic ISO.
crackable: Option<(u32, Vec<u8>)>,
}
impl MockSource {
fn new(flag_byte: u8) -> Self {
Self {
reads: std::cell::RefCell::new(Vec::new()),
flag_byte,
fail_all: false,
lock_all: false,
crackable: None,
}
}
}
/// Build a Stevenson-crackable scrambled sector for `(title_key, seed)`:
/// the cleartext header (0x59..0x80) carries a periodic run that continues
/// across the 0x80 boundary into the encrypted region — the crib
/// `stevenson::crack_title_key` recovers a key from. Mirrors the
/// `synth_periodic_sector` fixture in the stevenson tests but built here
/// from the crate-internal `scramble_sector`.
fn crackable_sector(title_key: &[u8; 5], seed: &[u8; 5], period: usize) -> Vec<u8> {
const RUN_START: usize = 0x59;
const SEED_OFFSET: usize = 0x54;
let mut plaintext = vec![0u8; 2048];
plaintext[0x00..0x04].copy_from_slice(&PACK_START); // valid DVD pack header
plaintext[0x14] = 0x10; // scramble flag
let pat: Vec<u8> = (0..period)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
.collect();
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
*b = pat[i % period];
}
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
lfsr::scramble_sector(title_key, &mut plaintext);
plaintext
}
impl SectorSource for MockSource {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.reads.borrow_mut().push(lba);
if self.lock_all {
return Err(Error::DiscRead {
sector: lba as u64,
status: Some(2),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x6F,
ascq: 0x03,
}),
});
}
if self.fail_all {
return Err(Error::DecryptFailed);
}
let n = count as usize * 2048;
let end = n.min(buf.len());
for b in buf[..end].iter_mut() {
*b = 0;
}
// Fill each sector in the batch with the uniform flag byte, EXCEPT a
// designated crackable LBA which gets the full synthetic sector.
for s in 0..count as u32 {
let sect_lba = lba + s;
let base = s as usize * 2048;
if base + 2048 > end {
break;
}
match &self.crackable {
Some((clba, sector)) if *clba == sect_lba => {
buf[base..base + 2048].copy_from_slice(sector);
}
_ => {
// Real DVD video sectors always open with the MPEG-PS
// pack-start code; `is_scrambled` (Fix 3) requires it
// before trusting the 0x14 scramble bits, so the fixture
// must include it for a `flag_byte` of 0x30 to register
// as scrambled.
buf[base..base + 4].copy_from_slice(&PACK_START);
buf[base + 0x14] = self.flag_byte;
}
}
}
Ok(n)
}
}
/// crack_key caps total scanned sectors at 50_000 even when extents are
/// far larger, and counts EVERY scanned sector (clear ones included)
/// toward the budget. With one 200_000-sector extent of clear sectors, it
/// must read exactly 50_000 sectors and return None — never run away.
///
/// Grounding: `let max_tries = 50_000; ... tried += 1` before the read,
/// loop guard `tried < max_tries`.
/// Mutation: change `50_000` to `500_000` -> read count exceeds 50_000;
/// the exact-count assert fails. Removing the `tried += 1` increment ->
/// would read all 200_000; also fails.
#[test]
fn crack_key_caps_total_tries_at_50000() {
let mut src = MockSource::new(0x00); // clear sectors, never a hit
let extents = [Extent {
start_lba: 0,
sector_count: 200_000,
}];
let res = crack_key(&mut src, &extents, 1);
assert!(res.is_none(), "clear sectors yield no key");
assert_eq!(
src.reads.borrow().len(),
50_000,
"scan must stop at the 50_000-sector budget"
);
}
// ── CrackOutcome: scrambled-but-uncracked vs genuinely unencrypted (Fix 6) ─
/// A scan over CLEAR sectors (scramble flag never set) returns
/// `Unencrypted` — the content is genuinely plaintext, so proceeding
/// without a key is correct.
#[test]
fn crack_outcome_clear_sectors_is_unencrypted() {
let mut src = MockSource::new(0x00); // never scrambled
let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
matches!(outcome, CrackOutcome::Unencrypted),
"no scrambled sector seen → Unencrypted, got {outcome:?}"
);
// The Option wrapper collapses Unencrypted → None.
assert!(crack_key(&mut MockSource::new(0x00), &extents, 1).is_none());
}
/// THE Fix 6 regression: a scan that SEES scrambled sectors (flag set) but
/// recovers no key (the mock's zeroed data has no Stevenson crib) must
/// return `ScrambledUncracked` — a HARD failure — NOT `Unencrypted`. The
/// old code conflated this with "unencrypted" and muxed scrambled MPEG as
/// plaintext (garbage at exit 0).
#[test]
fn crack_outcome_scrambled_uncracked_is_hard_failure() {
let mut src = MockSource::new(0x30); // scrambled flag set, no crackable crib
let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
outcome.is_scrambled_uncracked(),
"scrambled sectors seen but no key → ScrambledUncracked, got {outcome:?}"
);
// The legacy Option wrapper still collapses this to None (the callers
// that need the distinction now use crack_key_outcome instead).
assert!(crack_key(&mut MockSource::new(0x30), &extents, 1).is_none());
}
/// Even when every read FAILS, a scan that never managed to observe a
/// scrambled sector reports `Unencrypted` (we cannot prove encryption from
/// unreadable data alone — the AACS/keydb paths and the disc-level
/// `css_error` plumbing cover genuinely unreadable encrypted discs).
#[test]
fn crack_outcome_all_reads_fail_is_unencrypted() {
let mut src = MockSource::new(0x30);
src.fail_all = true; // no sector is ever inspected
let extents = [Extent {
start_lba: 0,
sector_count: 10,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
matches!(outcome, CrackOutcome::Unencrypted),
"no readable scrambled sector → Unencrypted, got {outcome:?}"
);
}
/// Fix C (rc.5.1): on the INITIAL scan, a drive that refuses every read with
/// CSS-locked sense (`05/6F/03`) is encrypted-but-locked →
/// `ScrambledUncracked` (a hard failure), NOT `Unencrypted`. This is the
/// rc4.3 bug: every VOB read came back `6F/03`, so the scan saw no scrambled
/// sector and wrongly declared the disc unencrypted → 19 KB garbage.
#[test]
fn crack_outcome_css_locked_initial_is_scrambled_uncracked() {
let mut src = MockSource::new(0x30);
src.lock_all = true; // every read → 05/6F/03
let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
outcome.is_scrambled_uncracked(),
"every read 6F/03 on the initial scan → ScrambledUncracked, got {outcome:?}"
);
}
/// MISSING #1 guard: the re-crack path (the `Option`-returning `crack_key`,
/// `fail_on_locked == false`) must NOT hard-fail on a CSS-locked read — it
/// returns `None`. A lapsed-AGID re-crack of another VTS stays soft so a
/// genuinely crackable title isn't killed by a transient locked read.
#[test]
fn crack_key_recrack_locked_is_none_not_hard_fail() {
let mut src = MockSource::new(0x30);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
assert!(crack_key(&mut src, &extents, 1).is_none());
}
/// Fix F: a fully CSS-locked scan early-bails near `CSS_LOCKED_BAIL`
/// consecutive locked reads instead of grinding the whole 50_000-sector
/// budget (the rc5 "stuck Scanning…" hang on a wedged bridge).
#[test]
fn crack_css_locked_scan_early_bails() {
let mut src = MockSource::new(0x30);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 10_000,
}];
let _ = crack_key_outcome(&mut src, &extents, 1, None);
let n = src.reads.borrow().len();
assert!(
n <= (CSS_LOCKED_BAIL as usize) + 1,
"locked scan early-bails near {CSS_LOCKED_BAIL}, not 10000; read {n}"
);
}
/// The budget spans ALL extents, not per-extent: two extents summing past
/// the cap must still stop at 50_000 total reads.
///
/// Grounding: `tried` is declared outside the `for ext in extents` loop;
/// `if tried >= max_tries { break }` after each extent.
/// Mutation: move `let mut tried = 0` inside the extent loop -> each extent
/// gets its own 50_000 budget; total reads would be 80_000, this fails.
#[test]
fn crack_key_budget_is_shared_across_extents() {
let mut src = MockSource::new(0x00);
let extents = [
Extent {
start_lba: 0,
sector_count: 40_000,
},
Extent {
start_lba: 100_000,
sector_count: 40_000,
},
];
let res = crack_key(&mut src, &extents, 1);
assert!(res.is_none());
assert_eq!(
src.reads.borrow().len(),
50_000,
"the 50_000 budget is shared across all extents"
);
}
/// crack_key scans sequentially from each extent's start_lba. The first
/// reads must be at the extent's start_lba, start_lba+1, ... pinning the
/// LBA arithmetic `ext.start_lba + i`.
///
/// Grounding: `reader.read_sectors(ext.start_lba + i, 1, ...)`.
/// Mutation: change `ext.start_lba + i` to just `i` -> the recorded LBAs
/// would start at 0, not 5000; this fails.
#[test]
fn crack_key_scans_from_extent_start_lba() {
let mut src = MockSource::new(0x00);
let extents = [Extent {
start_lba: 5_000,
sector_count: 4,
}];
let _ = crack_key(&mut src, &extents, 1);
let reads = src.reads.borrow();
assert_eq!(
&reads[..],
&[5_000, 5_001, 5_002, 5_003],
"sequential scan from start_lba"
);
}
/// A read error on a sector does NOT abort the scan: crack_key keeps
/// scanning subsequent sectors (the error sector still counts toward the
/// budget). With a small failing extent, every sector is attempted and the
/// function returns None.
///
/// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled(...)` —
/// an Err simply falls through to `i += 1`.
/// Mutation: change the read-error handling to `reader.read_sectors(...)?`
/// (propagate) -> crack_key would stop after the first error and read only
/// 1 sector; this asserts all 10 were attempted.
#[test]
fn crack_key_continues_past_read_errors() {
let mut src = MockSource::new(0x30);
src.fail_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 10,
}];
let res = crack_key(&mut src, &extents, 1);
assert!(res.is_none());
assert_eq!(
src.reads.borrow().len(),
10,
"read errors must not abort the scan"
);
}
/// Empty extents (no sectors) -> crack_key reads nothing and returns None.
/// A zero-sector extent must not read its start_lba.
///
/// Grounding: `while i < ext.sector_count` with sector_count == 0 never
/// enters.
/// Mutation: change `i < ext.sector_count` to `i <= ext.sector_count` ->
/// one spurious read at start_lba; this asserts zero reads.
#[test]
fn crack_key_empty_extent_reads_nothing() {
let mut src = MockSource::new(0x30);
let extents = [Extent {
start_lba: 42,
sector_count: 0,
}];
let res = crack_key(&mut src, &extents, 1);
assert!(res.is_none());
assert_eq!(
src.reads.borrow().len(),
0,
"zero-sector extent reads nothing"
);
}
/// No extents at all -> immediate None, zero reads.
///
/// Grounding: `for ext in extents` over an empty slice is a no-op.
/// Mutation: any change that reads before the loop would break this.
#[test]
fn crack_key_no_extents_is_none() {
let mut src = MockSource::new(0x30);
let res = crack_key(&mut src, &[], 1);
assert!(res.is_none());
assert_eq!(src.reads.borrow().len(), 0);
}
// ── Scan-level Cracked branch + per-VTS re-crack success (audit §2 / §5 #8) ─
/// SCAN-LEVEL CRACKED (audit gap "MockSource never yields a crackable
/// sector"): drive the full `crack_key_scan` over a synthetic ISO whose
/// scan hits a Stevenson-crackable scrambled sector. The outcome must be
/// `CrackOutcome::Cracked` with a key that round-trips the sector, AND the
/// `crack_span` must be recorded as the half-open extent span (the per-VTS
/// routing key the mux path needs). Previously only the leaf crack and the
/// Uncracked/Unencrypted branches were tested — the Cracked branch and
/// `crack_span` recording were never exercised end-to-end.
#[test]
fn crack_outcome_reaches_cracked_with_span() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
let crackable = crackable_sector(&title_key, &seed, 8);
// The crackable sector sits a few sectors into the extent.
let mut src = MockSource::new(0x00); // surrounding sectors: clear
src.crackable = Some((1003, crackable.clone()));
let extents = [Extent {
start_lba: 1000,
sector_count: 50,
}];
let outcome = crack_key_outcome(&mut src, &extents, 4, None);
let state = match outcome {
CrackOutcome::Cracked(s) => s,
other => panic!("expected Cracked, got {other:?}"),
};
// The recovered key descrambles the crackable sector body.
let mut test = crackable.clone();
descramble_sector(&state, &mut test);
let mut plain = crackable;
lfsr::descramble_sector(&title_key, &mut plain);
assert_eq!(
&test[0x80..],
&plain[0x80..],
"recovered key must round-trip the scrambled sector body"
);
// crack_span = half-open [start, start+count) of the scanned extent.
assert_eq!(
state.crack_span,
Some((1000, 1050)),
"crack_span must record the extent LBA span for per-VTS routing"
);
}
/// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read
/// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass)
/// call, so these pin its full contract at the shared boundary.
///
/// Crack path: a `None`-keyed MPEG-PS title with a crackable scrambled sector
/// installs a `Css` key that round-trips the sector.
#[test]
fn resolve_dvd_title_key_cracks_none_mpegps() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
let crackable = crackable_sector(&title_key, &seed, 8);
let mut src = MockSource::new(0x00);
src.crackable = Some((1003, crackable));
let extents = [Extent {
start_lba: 1000,
sector_count: 50,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("crackable title resolves");
match keys {
crate::decrypt::DecryptKeys::Css { title_key: got } => {
assert_eq!(got, title_key, "installed key must be the cracked key")
}
_ => panic!("expected Css key"),
}
}
/// Hard-fail path: a scrambled-but-uncrackable `None`-keyed MPEG-PS title must
/// return `CssKeyMissing`, never leave `keys` as `None` (which would mux
/// scrambled bytes as plaintext — the 328k-decode-error corruption).
#[test]
fn resolve_dvd_title_key_scrambled_uncrackable_hard_fails() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // every read CSS-locked → ScrambledUncracked
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
let err = resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect_err("scrambled-uncrackable must hard-fail");
// The Error::CssKeyMissing flattens into io::Error carrying its E-code
// (7023) in the message — assert that specific code survived.
assert!(
err.to_string()
.contains(&format!("E{}", crate::error::E_CSS_KEY_MISSING)),
"must surface CssKeyMissing (E{}), got: {err}",
crate::error::E_CSS_KEY_MISSING
);
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"keys must stay None on hard-fail (never a scrambled-passthrough key)"
);
}
/// `raw` is deliberate ciphertext passthrough: even a scrambled-uncrackable
/// title must return `Ok` and leave `keys` untouched (`None`) — no crack, no
/// hard-fail. This is the `--raw` guarantee.
#[test]
fn resolve_dvd_title_key_raw_skips_crack_and_never_fails() {
let mut src = MockSource::new(0x00);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
true, // raw
None,
)
.expect("raw must never hard-fail");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"raw must leave keys None (no descramble)"
);
assert!(
src.reads.borrow().is_empty(),
"raw must not read any sector for a crack"
);
}
/// AACS gate: an MPEG-PS title carrying `Aacs` keys (HD-DVD `.evo`) must be
/// left untouched — resolve only fires on `None` keys, never overwriting a
/// real key set or cracking AACS ciphertext as CSS.
#[test]
fn resolve_dvd_title_key_leaves_aacs_untouched() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // would hard-fail IF it ran the crack
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::MpegPs,
};
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("AACS title must be left untouched, not cracked");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }),
"Aacs keys must survive unchanged"
);
assert!(
src.reads.borrow().is_empty(),
"must not read for a crack when keys are already Aacs"
);
}
/// Clear DVD: a `None`-keyed MPEG-PS title with no scrambled sector stays
/// `None` (a mux no-op) and returns `Ok` — genuinely-unencrypted DVDs pass.
#[test]
fn resolve_dvd_title_key_clear_dvd_stays_none() {
let mut src = MockSource::new(0x00); // all-clear sectors
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("clear DVD passes");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"a clear DVD must keep None keys"
);
}
/// A cancelled crack (user Stop mid-scan) must surface as `Halted`, NOT be
/// misread from the truncated scan as `Unencrypted` (→ scrambled passthrough,
/// corruption) or `ScrambledUncracked` (→ CssKeyMissing, which quarantines a
/// good disc). This pins the halt-outcome fix.
#[test]
fn resolve_dvd_title_key_halt_surfaces_as_halted_not_a_verdict() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // without the halt guard this would be ScrambledUncracked
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let halt = crate::halt::Halt::new();
halt.cancel(); // Stop already pressed
let mut keys = crate::decrypt::DecryptKeys::None;
let err = resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
Some(&halt),
)
.expect_err("a cancelled crack must return an error");
assert!(
err.to_string()
.contains(&format!("E{}", crate::error::E_HALTED)),
"cancelled crack must surface Halted (E{}), got: {err}",
crate::error::E_HALTED
);
}
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
/// real encrypted-but-unauthenticated disc image does) must produce the exact
/// outcome the scan converts into `disc.css_error = Some(Error::CssKeyMissing)`
/// — i.e. `CrackOutcome::ScrambledUncracked` / `is_scrambled_uncracked()`,
/// NOT `Unencrypted`. disc/mod.rs's `crack_key_outcome → ScrambledUncracked`
/// arm (where it stamps css_error) is driven by exactly this signal, so this
/// pins the css-layer contract that arm depends on without touching the
/// scan plumbing.
#[test]
fn all_locked_synthetic_iso_yields_css_key_missing_signal() {
let mut src = MockSource::new(0x30);
src.lock_all = true; // every read → 05/6F/03 across the whole "ISO"
let extents = [
Extent {
start_lba: 0,
sector_count: 30,
},
Extent {
start_lba: 5_000,
sector_count: 30,
},
];
let outcome = crack_key_outcome(&mut src, &extents, 16, None);
assert!(
outcome.is_scrambled_uncracked(),
"all-locked ISO → ScrambledUncracked (the css_error=CssKeyMissing \
signal), got {outcome:?}"
);
// The legacy Option wrapper still collapses it to None — callers that
// surface the hard error must use crack_key_outcome, which this proves.
let mut src2 = MockSource::new(0x30);
src2.lock_all = true;
assert!(crack_key(&mut src2, &extents, 16).is_none());
}
/// PER-VTS RE-CRACK SUCCESS (audit gap "success path missing"): the prior
/// re-crack test only covered the locked→None path. Here a re-crack
/// (`crack_key`, `fail_on_locked == false`) over a DIFFERENT VTS's extents
/// finds that VTS's own crackable sector and returns a `CssState` whose
/// `crack_span` matches the new extents — proving a key cracked for one VTS
/// is genuinely re-derived (not reused) for another.
#[test]
fn recrack_succeeds_on_other_vts_extents() {
let title_key = [0xFE, 0xDC, 0xBA, 0x98, 0x76];
let seed = [0x00, 0xFF, 0x80, 0x7F, 0x01];
let crackable = crackable_sector(&title_key, &seed, 5);
let mut src = MockSource::new(0x00);
// The second VTS lives at a disjoint LBA range; its crackable sector is
// the first one in the extent.
src.crackable = Some((9000, crackable));
let other_vts = [Extent {
start_lba: 9000,
sector_count: 20,
}];
let state = crack_key(&mut src, &other_vts, 4).expect("re-crack must recover a key");
assert_eq!(
state.crack_span,
Some((9000, 9020)),
"re-crack span must reflect the OTHER VTS extents, not a reused span"
);
}
}