diff --git a/src/css/mod.rs b/src/css/mod.rs index 38b6dbc..c584e94 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -85,7 +85,7 @@ pub fn crack_key( /// /// - [`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 +/// scanned extents (`is_scrambled_pack` 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 @@ -515,7 +515,7 @@ 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 +/// [`has_scramble_flag_bits`] keys solely on bits 4-5 of byte 0x14. That 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) @@ -527,12 +527,15 @@ pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; /// 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.) +/// pass this check and still drive `ScrambledUncracked` when no key cracks. +/// +/// The DESCRAMBLE path gates on this same function — [`descramble_sector`] and +/// [`descramble_region`] both call it, not the raw flag test — because the raw +/// test does not merely mis-skip a sector there: it descrambles one that was +/// never scrambled and destroys it. The measured case is written up on +/// [`descramble_region`]: a `VIDEO_TS.IFO` sector holding 0x15 at offset 0x14 +/// lost 1912 of its 2048 bytes, taking TT_SRPT with it, and the disc's 38 +/// titles became 10 — silently, at exit 0. One gate, both paths. pub fn is_scrambled_pack(sector: &[u8]) -> bool { sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && (sector[0x14] >> 4) & 0x03 != 0 } @@ -618,9 +621,9 @@ mod tests { ); } - // ── is_scrambled ─────────────────────────────────────────────────────── + // ── has_scramble_flag_bits ───────────────────────────────────────────── - /// is_scrambled returns false for any buffer shorter than one sector, + /// has_scramble_flag_bits 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. /// @@ -630,13 +633,13 @@ mod tests { /// (`(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() { + fn has_scramble_flag_bits_short_buffer_is_false_no_panic() { assert!(!has_scramble_flag_bits(&[])); assert!(!has_scramble_flag_bits(&[0u8; 20])); // shorter than 0x14+1 even assert!(!has_scramble_flag_bits(&[0xFFu8; 2047])); // one byte short of a sector } - /// is_scrambled keys on bits 4-5 of byte 0x14 (the CSS scramble field). + /// has_scramble_flag_bits 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. /// @@ -644,7 +647,7 @@ mod tests { /// Mutation: widen mask to `& 0x0F` -> 0x40 reports scrambled, the 0x40 /// assert fails. #[test] - fn is_scrambled_uses_bits_4_5_only() { + fn has_scramble_flag_bits_uses_bits_4_5_only() { let mut s = vec![0u8; 2048]; for (flag, expected) in [ (0x00u8, false), @@ -665,14 +668,14 @@ mod tests { } } - /// is_scrambled accepts exactly 2048 bytes as the minimum (boundary at the + /// has_scramble_flag_bits 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() { + fn has_scramble_flag_bits_exact_sector_length_accepted() { let mut s = vec![0u8; 2048]; s[0x14] = 0x30; assert!( @@ -686,8 +689,8 @@ mod tests { /// 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. + /// reports `ScrambledUncracked` (the false E7023). The raw flag test + /// `has_scramble_flag_bits` 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 @@ -814,7 +817,7 @@ mod tests { } _ => { // Real DVD video sectors always open with the MPEG-PS - // pack-start code; `is_scrambled` (Fix 3) requires it + // pack-start code; `is_scrambled_pack` (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. @@ -1123,7 +1126,7 @@ mod tests { /// budget). With a small failing extent, every sector is attempted and the /// function returns None. /// - /// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled(...)` — + /// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled_pack(...)` — /// 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 diff --git a/src/dirimage/encode.rs b/src/dirimage/encode.rs index bebb332..b4be6cd 100644 --- a/src/dirimage/encode.rs +++ b/src/dirimage/encode.rs @@ -29,8 +29,12 @@ use super::layout::{DirNode, Layout}; use crate::error::{Error, Result}; use std::collections::BTreeMap; -/// Logical block / sector size. Fixed for every optical profile this crate reads. -pub(super) const SECTOR: usize = 2048; +/// Logical block / sector size. Fixed for every optical profile this crate +/// reads, and the same quantity as [`crate::consts::SECTOR_BYTES`] — aliased +/// rather than re-declared so the two cannot drift apart. The short name is +/// kept because it appears in ~25 extent and offset expressions across +/// `dirimage`, where the longer one would bury the arithmetic. +pub(super) use crate::consts::SECTOR_BYTES as SECTOR; /// Descriptor version recorded in every tag. 2 = ECMA-167 2nd edition, which /// is what UDF revisions up to and including 2.00 require. diff --git a/src/dirimage/tests.rs b/src/dirimage/tests.rs index 234214b..221ee5f 100644 --- a/src/dirimage/tests.rs +++ b/src/dirimage/tests.rs @@ -650,6 +650,83 @@ fn an_aacs_folder_with_scrambled_content_is_rejected() { assert_eq!(err.code(), crate::error::E_DIR_IMAGE_ENCRYPTED); } +// ── The OTHER door: `dir://` through the PES input path ───────────────────── +// +// `session::scan_dir` (covered above) and `mux::resolve::input("dir://…")` are +// two doors into the same folder, and they once disagreed: a folder that ripped +// fine through one failed through the other, because only `scan_dir` re-judged +// the tree-shape encryption verdict from CONTENT. The fix was to share +// `session::apply_folder_encryption_verdict` between them — see its doc +// comment, which names this exact failure. Nothing tested the second door, so +// dropping the `is_folder` argument at `mux::resolve`'s call site restored the +// bug silently. + +/// The two doors must AGREE. Same folder, same verdict, same extents. +/// +/// Stated as a differential rather than a bare `is_ok()` so it cannot go +/// vacuous: if the fixture ever stops producing an AACS state, a one-sided +/// `Ok` assertion would still pass while guarding nothing, whereas "both doors +/// see the same title" is the invariant the shared function actually exists to +/// hold. +#[test] +fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() { + // `s` MUST outlive `stream`: the pipeline's producer thread is still + // reading the folder until the stream is dropped, and `Scratch::drop` + // removes the directory out from under it. + let s = playable_bdmv("dirdoor", false); + s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]); + s.file("AACS/MKB_RO.inf", &[0u8; 64]); + + // Door 1 — the scan path. + let (disc, _reader) = + crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap(); + assert!( + !disc.encrypted, + "door 1 must judge the clear folder decrypted" + ); + let scanned_extents = disc.titles[0].extents.clone(); + assert!( + !scanned_extents.is_empty(), + "fixture must have real extents" + ); + + // Door 2 — the PES input path the CLI actually rips through. + let url = format!("dir://{}", s.path().display()); + let stream = crate::input(&url, &crate::InputOptions::default()) + .expect("dir:// input must open a clear folder, exactly as scan_dir does"); + assert_eq!( + stream.info().extents, + scanned_extents, + "the two doors selected different titles from the same folder" + ); + drop(stream); +} + +/// The other verdict, through the same door: a folder whose content units are +/// really scrambled is refused with the TYPED code, not muxed into garbage. +/// +/// This is the load-bearing half. `E_DIR_IMAGE_ENCRYPTED` is produced at +/// exactly one site in the crate (`session::apply_folder_encryption_verdict`), +/// reachable from here only through the `is_folder` argument — so this test +/// cannot pass if that argument is dropped, whatever else changes. +#[test] +fn a_scrambled_folder_is_refused_through_the_dir_url_door_too() { + let s = playable_bdmv("dirdoorenc", true); + s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]); + s.file("AACS/MKB_RO.inf", &[0u8; 64]); + + let url = format!("dir://{}", s.path().display()); + let err = match crate::input(&url, &crate::InputOptions::default()) { + Ok(_) => panic!("a scrambled folder must not open as a PES source"), + Err(e) => e, + }; + assert_eq!( + crate::error::error_code(&err), + Some(crate::error::E_DIR_IMAGE_ENCRYPTED), + "expected the typed dir-source-encrypted code, got: {err}" + ); +} + // ── External oracle ───────────────────────────────────────────────────────── /// Write a synthesized image to a real file and ask the OS to mount it. diff --git a/src/io/image_writer.rs b/src/io/image_writer.rs index 26afc0e..7892978 100644 --- a/src/io/image_writer.rs +++ b/src/io/image_writer.rs @@ -21,6 +21,7 @@ //! //! So: drive sources get `freemkv_engine::copy`. Everything else gets this. +use crate::consts::SECTOR_BYTES; use crate::error::{Error, Result}; use crate::halt::Halt; use crate::sector::SectorSource; @@ -33,9 +34,6 @@ use std::path::Path; /// a notable allocation and cancellation stays responsive. const BATCH_SECTORS: u32 = 2048; -/// Bytes per sector. Fixed for every medium this crate reads. -const SECTOR_BYTES: usize = 2048; - /// Write `total_sectors` sectors from `reader` to `dest`. /// /// Reads sequentially from LBA 0 and writes in order, so the output is a faithful