550 lines
22 KiB
Rust
550 lines
22 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 auth;
|
|
pub mod lfsr;
|
|
pub mod stevenson;
|
|
pub(crate) mod tables;
|
|
|
|
use crate::disc::Extent;
|
|
use crate::sector::SectorSource;
|
|
|
|
/// CSS decryption state for a DVD title.
|
|
#[derive(Debug, 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)>,
|
|
}
|
|
|
|
/// 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 `AttackPattern`: 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 scrambled 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.
|
|
pub fn crack_key(
|
|
reader: &mut dyn SectorSource,
|
|
extents: &[Extent],
|
|
batch_sectors: u16,
|
|
) -> Option<CssState> {
|
|
crack_key_halt(reader, extents, batch_sectors, None)
|
|
}
|
|
|
|
/// 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
|
|
/// ([`crate::error::Error::CssKeyMissing`]) instead of falling through to
|
|
/// "unencrypted".
|
|
#[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.
|
|
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)
|
|
}
|
|
|
|
/// [`crack_key`] with 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. This variant polls `halt`
|
|
/// once per batch (the same cadence sweep/patch use) so an operator Stop or a
|
|
/// scan-level watchdog can interrupt the scan, and emits a
|
|
/// `freemkv::heartbeat` beat ("css_crack") each batch so a stuck scan is
|
|
/// visible in the log.
|
|
pub fn crack_key_halt(
|
|
reader: &mut dyn SectorSource,
|
|
extents: &[Extent],
|
|
batch_sectors: u16,
|
|
halt: Option<&crate::halt::Halt>,
|
|
) -> Option<CssState> {
|
|
crack_key_scan(reader, extents, batch_sectors, halt).into_state()
|
|
}
|
|
|
|
/// 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`] / [`crack_key_halt`]
|
|
/// `Option` wrappers collapse both to `None`).
|
|
fn crack_key_scan(
|
|
reader: &mut dyn SectorSource,
|
|
extents: &[Extent],
|
|
batch_sectors: u16,
|
|
halt: Option<&crate::halt::Halt>,
|
|
) -> 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;
|
|
|
|
'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 {
|
|
if 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(_) => {
|
|
for s in 0..n as usize {
|
|
tried += 1;
|
|
let sect = &buf[s * 2048..(s + 1) * 2048];
|
|
if is_scrambled(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 (bad sectors) still counts toward the budget so a
|
|
// damaged region can't loop forever; skip ahead by the batch.
|
|
Err(_) => tried += n,
|
|
}
|
|
i += n;
|
|
}
|
|
}
|
|
|
|
// Budget exhausted / extents walked with no key recovered. Distinguish the
|
|
// two indistinguishable-in-`Option` cases: if scrambled sectors were seen
|
|
// (case b: crack failed; case c: scrambled but the crackable region was
|
|
// unreadable), this is encrypted-but-uncracked — a hard failure. Only a
|
|
// scan that NEVER saw a scrambled sector is genuinely unencrypted (case a).
|
|
if saw_scrambled {
|
|
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);
|
|
}
|
|
|
|
/// Check if a sector has the CSS scramble flag set.
|
|
pub fn is_scrambled(sector: &[u8]) -> bool {
|
|
sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::error::{Error, Result};
|
|
|
|
// ── 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");
|
|
}
|
|
|
|
// ── 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,
|
|
}
|
|
|
|
impl MockSource {
|
|
fn new(flag_byte: u8) -> Self {
|
|
Self {
|
|
reads: std::cell::RefCell::new(Vec::new()),
|
|
flag_byte,
|
|
fail_all: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
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.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;
|
|
}
|
|
if buf.len() > 0x14 {
|
|
buf[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:?}"
|
|
);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|