diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 696fc08..a9ae5e1 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1975,6 +1975,88 @@ impl Disc { } } + /// The 40-hex AACS disc id (SHA1 of `Unit_Key_RO.inf`, no `0x` prefix), or + /// empty when this disc has no captured AACS state. Used to name the disc in + /// a [`Error::NoDiscKey`] so the application can tell the user which disc to + /// add to the keydb. + pub fn aacs_disc_hash(&self) -> String { + self.aacs + .as_ref() + .map(|a| a.disc_hash.trim_start_matches("0x").to_string()) + .unwrap_or_default() + } + + /// The system-wide decrypt correctness gate. + /// + /// Returns `Ok(())` when it is safe to proceed with a copy or mux, and a + /// clear typed error when decryption is **needed but unavailable** — the + /// case that would otherwise write ciphertext (disc→ISO) or feed the demux + /// undecryptable bytes (mux) and exit 0. Every copy/mux entry point calls + /// this **after key resolution and before any source-data processing + /// begins**, so the verdict is identical everywhere and the failure is a + /// pre-flight one (no partial output). + /// + /// The verdict, in order: + /// - `raw == true` → `Ok(())`. `--raw` intentionally skips decryption and + /// needs no key (the caller wants an encrypted image). + /// - `self.css_error.is_some()` → `Err(Error::CssKeyMissing)`. The scan saw + /// scrambled CSS sectors but recovered no title key (`self.css` is `None` + /// yet the content IS encrypted). Treating `css.is_none()` as + /// "unencrypted" would mux scrambled MPEG as plaintext garbage. + /// - AACS-encrypted (`self.aacs.is_some()`) with no usable key + /// (`decrypt_keys()` is `None`) → `Err(Error::NoDiscKey { .. })`, naming + /// the disc by hash. + /// - CSS-encrypted (`self.css.is_some()`) with no usable key → + /// `Err(Error::CssKeyMissing)`. (The disc-wide `decrypt_keys()` yields + /// `Css{..}` whenever `css.is_some()`, so this is defensive; the live + /// multi-VTS case is gated by [`Self::ensure_decryptable_keys`].) + /// - otherwise → `Ok(())`. A genuinely unencrypted disc has `None` keys + /// legitimately, and a CSS disc whose keyless crack succeeded has a key. + pub fn ensure_decryptable(&self, raw: bool) -> Result<()> { + self.ensure_decryptable_keys(raw, &self.decrypt_keys()) + } + + /// [`Self::ensure_decryptable`] against a caller-resolved key set, for the + /// per-title path. A multi-VTS CSS DVD resolves its key with + /// [`Self::decrypt_keys_for_title`] (which can return `None` when the chosen + /// title's VTS could not be re-cracked even though the disc-wide + /// `decrypt_keys()` is `Css{..}`); the gate must judge THAT key, not the + /// disc-wide one. The "is the source encrypted?" question is answered by the + /// scan-captured disc state (`css_error`/`aacs`/`css`), never by the keys — + /// so an unencrypted disc (no AACS/CSS state) never false-errors regardless + /// of `keys`. + pub fn ensure_decryptable_keys( + &self, + raw: bool, + keys: &crate::decrypt::DecryptKeys, + ) -> Result<()> { + // --raw skips decryption entirely: never error, even on an encrypted + // disc with no key (the user asked for the encrypted image). + if raw { + return Ok(()); + } + // Scrambled-but-uncracked CSS: the disc is encrypted but `css` is None, + // so the key check below can't see it. Surface the recorded hard error. + if self.css_error.is_some() { + return Err(Error::CssKeyMissing); + } + // Decryption is needed iff the disc carries cipher state. A no-key + // verdict on a non-encrypted disc is impossible here (the disc has no + // AACS/CSS state), so a genuinely unencrypted disc never errors. + let needs_key = matches!(keys, crate::decrypt::DecryptKeys::None); + if needs_key { + if self.aacs.is_some() { + return Err(Error::NoDiscKey { + disc_hash: self.aacs_disc_hash(), + }); + } + if self.css.is_some() { + return Err(Error::CssKeyMissing); + } + } + Ok(()) + } + /// Resolve decryption keys for muxing a *specific* title. /// /// CSS title keys are per-VTS. The scan cracks one key (from the main @@ -2246,6 +2328,15 @@ impl Disc { path: &std::path::Path, opts: &CopyOptions, ) -> Result { + // Pre-flight decrypt gate. A decrypting copy (`opts.decrypt == true`, + // i.e. NOT `--raw`) of an encrypted disc with no usable key would wrap + // the reader in a pass-through `DecryptingSectorSource` and write + // ciphertext to the ISO, then return `Ok` (bytes_good > 0) — a silent + // garbage success at exit 0. Refuse here, BEFORE any sweep/patch reads a + // single sector, so the failure is pre-flight and no partial ISO is + // written. `opts.decrypt == false` is `--raw`: the gate is a no-op (the + // user wants the encrypted image), and an unencrypted disc passes too. + self.ensure_decryptable(!opts.decrypt)?; if opts.multipass { let mf_path = self.mapfile_for(path); if mf_path.exists() { @@ -2434,6 +2525,13 @@ impl Disc { use crate::sector::{DecryptingSectorSource, SectorSource}; use sweep::{ProgressSnapshot, SweepSink, WorkItem, try_recv_progress}; + // Pre-flight decrypt gate (also enforced in `copy`; re-checked here so a + // direct `sweep` caller can't bypass it). A decrypting sweep of an + // encrypted disc with no usable key would write ciphertext to the ISO at + // exit 0; refuse before reading any sector. No-op for `--raw` + // (`opts.decrypt == false`) and unencrypted discs. + self.ensure_decryptable(!opts.decrypt)?; + let total_bytes = self.capacity_sectors as u64 * 2048; let keys = if opts.decrypt { self.decrypt_keys() @@ -3857,6 +3955,135 @@ mod tests { } } + // ── ensure_decryptable: the system-wide decrypt verdict matrix ────────── + // + // This is the single gate every copy/mux entry point calls. The cases below + // are the full truth table: only "decryption needed AND unavailable AND not + // --raw" may error; every legit non-error case (raw / unencrypted / a + // resolved key) must proceed. + + fn css_state() -> crate::css::CssState { + crate::css::CssState { + title_key: [0u8; 5], + crack_span: None, + } + } + + /// AACS-encrypted disc, decryption requested, no unit key resolved → the + /// gate must fail with NoDiscKey (this is the headline bug: a pass-through + /// `DecryptingSectorSource` would otherwise write ciphertext at exit 0). + #[test] + fn ensure_decryptable_aacs_no_key_errors() { + let mut disc = make_test_disc(1000, "UHD"); + disc.encrypted = true; + disc.aacs = Some(aacs_with(Vec::new())); // present but no unit keys → None + assert!(matches!( + disc.decrypt_keys(), + crate::decrypt::DecryptKeys::None + )); + let err = disc + .ensure_decryptable(false) + .expect_err("AACS disc, no key, !raw must error"); + assert_eq!( + err.code(), + crate::error::Error::NoDiscKey { + disc_hash: String::new() + } + .code() + ); + } + + /// Same AACS-no-key disc under `--raw` (raw=true) must PROCEED — the user + /// asked for the encrypted image and needs no key. + #[test] + fn ensure_decryptable_aacs_no_key_raw_proceeds() { + let mut disc = make_test_disc(1000, "UHD"); + disc.encrypted = true; + disc.aacs = Some(aacs_with(Vec::new())); + assert!(disc.ensure_decryptable(true).is_ok(), "--raw must proceed"); + } + + /// AACS disc WITH a resolved unit key → proceed (decrypt_keys is Aacs). + #[test] + fn ensure_decryptable_aacs_with_key_proceeds() { + let mut disc = make_test_disc(1000, "UHD"); + disc.encrypted = true; + disc.aacs = Some(aacs_with(vec![(0, [0x11u8; 16])])); + assert!(disc.ensure_decryptable(false).is_ok()); + } + + /// A genuinely unencrypted disc has `None` keys legitimately — the gate must + /// NOT false-error. This is the "is the source encrypted?" guard: the answer + /// is the scan-captured disc state, not the keys. + #[test] + fn ensure_decryptable_unencrypted_proceeds() { + let disc = make_test_disc(1000, "BD"); // aacs/css/css_error all None + assert!(matches!( + disc.decrypt_keys(), + crate::decrypt::DecryptKeys::None + )); + assert!( + disc.ensure_decryptable(false).is_ok(), + "unencrypted disc with None keys must proceed, not false-error" + ); + } + + /// CSS scrambled-but-uncracked (the keyless crack failed): `css` is None but + /// `css_error` is Some — the disc IS encrypted. The gate must fail with + /// CssKeyMissing rather than read `css.is_none()` as "unencrypted". + #[test] + fn ensure_decryptable_css_error_errors() { + let mut disc = make_test_disc(1000, "DVD"); + disc.encrypted = true; + disc.css_error = Some(crate::error::Error::CssKeyMissing); + let err = disc + .ensure_decryptable(false) + .expect_err("scrambled-but-uncracked CSS must error"); + assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code()); + // --raw is exempt. + assert!(disc.ensure_decryptable(true).is_ok()); + } + + /// CSS-keyless-crack SUCCESS: `css` is Some with a title key → proceed. + #[test] + fn ensure_decryptable_css_with_key_proceeds() { + let mut disc = make_test_disc(1000, "DVD"); + disc.encrypted = true; + disc.css = Some(css_state()); + assert!(disc.ensure_decryptable(false).is_ok()); + } + + /// Per-title gate: a multi-VTS CSS disc whose chosen title's VTS could not + /// be re-cracked yields `DecryptKeys::None` even though the disc-wide + /// `decrypt_keys()` is `Css{..}`. `ensure_decryptable_keys` judges the + /// per-title key and must fail with CssKeyMissing. + #[test] + fn ensure_decryptable_keys_css_per_title_none_errors() { + let mut disc = make_test_disc(1000, "DVD"); + disc.encrypted = true; + disc.css = Some(css_state()); + let err = disc + .ensure_decryptable_keys(false, &crate::decrypt::DecryptKeys::None) + .expect_err("CSS disc, per-title key None, !raw must error"); + assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code()); + // The same None key under --raw proceeds. + assert!( + disc.ensure_decryptable_keys(true, &crate::decrypt::DecryptKeys::None) + .is_ok() + ); + } + + /// `ensure_decryptable_keys` must never false-error an UNENCRYPTED disc no + /// matter the key argument (the verdict keys off disc state, not keys). + #[test] + fn ensure_decryptable_keys_unencrypted_never_errors() { + let disc = make_test_disc(1000, "BD"); + assert!( + disc.ensure_decryptable_keys(false, &crate::decrypt::DecryptKeys::None) + .is_ok() + ); + } + #[test] fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() { // VID-only state (resolved but no Unit Key yet) must read as None, not @@ -4288,6 +4515,77 @@ mod tests { ); } + /// disc→ISO correctness gate (the headline bug, at the copy entry point): + /// a DECRYPTING copy (`decrypt: true`, i.e. not --raw) of an AACS disc with + /// no resolved key must ERROR before reading any sector — never write + /// ciphertext to the ISO and return Ok. Asserts the error code is NoDiscKey + /// AND that no non-empty ISO was produced. + #[test] + fn copy_decrypting_aacs_no_key_errors_and_writes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let iso_path = tmp.path().join("garbage.iso"); + let sectors: u32 = 999; // 3-aligned for AACS units + let mut reader = MockReader { + total_sectors: sectors, + bad_sectors: std::collections::HashSet::new(), + }; + let mut disc = make_test_disc(sectors, "UHD"); + disc.encrypted = true; + disc.aacs = Some(aacs_with(Vec::new())); // encrypted, no unit key → None + let opts = CopyOptions { + decrypt: true, // NOT --raw → decryption is required + multipass: false, + progress: None, + halt: None, + vid: None, + unit_keys: Vec::new(), + }; + let err = disc + .copy(&mut reader, &iso_path, &opts) + .expect_err("decrypting copy of AACS-no-key disc must error pre-flight"); + assert_eq!( + err.code(), + crate::error::Error::NoDiscKey { + disc_hash: String::new() + } + .code(), + "must surface NoDiscKey, not silently write ciphertext" + ); + // No partial/garbage ISO: the gate fired before the sweep opened/sized + // the file, so either the file doesn't exist or it's empty. + let produced = std::fs::metadata(&iso_path).map(|m| m.len()).unwrap_or(0); + assert_eq!(produced, 0, "no ciphertext ISO may be written"); + } + + /// The same disc under `--raw` (`decrypt: false`) must PROCEED: the gate is + /// a no-op for raw, the sweep runs as a pass-through and writes the + /// encrypted image the user asked for. Proves the gate doesn't over-fire. + #[test] + fn copy_raw_aacs_no_key_proceeds() { + let tmp = tempfile::tempdir().unwrap(); + let iso_path = tmp.path().join("raw.iso"); + let sectors: u32 = 999; + let mut reader = MockReader { + total_sectors: sectors, + bad_sectors: std::collections::HashSet::new(), + }; + let mut disc = make_test_disc(sectors, "UHD"); + disc.encrypted = true; + disc.aacs = Some(aacs_with(Vec::new())); + let opts = CopyOptions { + decrypt: false, // --raw: no decryption, no key needed + multipass: false, + progress: None, + halt: None, + vid: None, + unit_keys: Vec::new(), + }; + assert!( + disc.copy(&mut reader, &iso_path, &opts).is_ok(), + "--raw copy of an encrypted disc must proceed (encrypted image is the goal)" + ); + } + #[test] fn sweep_to_dev_null_real() { let sectors: u32 = 1000; diff --git a/src/disc/patch.rs b/src/disc/patch.rs index d1117ca..2bf94d0 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -1677,6 +1677,13 @@ impl Disc { use crate::io::pipeline::{Pipeline, WRITE_THROUGH_DEPTH}; use crate::sector::{DecryptingSectorSource, SectorSource}; + // Pre-flight decrypt gate (also enforced in `copy`; re-checked here so a + // direct `patch` caller can't bypass it). A decrypting patch pass of an + // encrypted disc with no usable key would write ciphertext into the ISO's + // recovered ranges; refuse before reading any sector. No-op for `--raw` + // (`opts.decrypt == false`) and unencrypted discs. + self.ensure_decryptable(!opts.decrypt)?; + let patch_t0 = std::time::Instant::now(); let mapfile_path = self.mapfile_for(path); let (map, initial_stats, initial_entries, total_bytes, bad_ranges, work_total, is_regular) = diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 2d30b62..a17fb5e 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -223,43 +223,6 @@ pub struct InputOptions { pub raw: bool, } -/// Decide whether an ISO mux must abort for lack of a usable AACS key. -/// -/// Returns `true` only when ALL hold: decryption is requested (`!raw`), the -/// disc carries AACS state (`has_aacs` — AACS-encrypted, not CSS/unencrypted), -/// and key resolution produced no usable key (`keys` is -/// [`crate::decrypt::DecryptKeys::None`]). In that case muxing would emit -/// undecryptable garbage, so the caller fails fast with [`Error::NoDiscKey`]. -/// -/// `--raw` (raw=true) always returns `false` — raw intentionally skips -/// decryption and needs no key. A non-AACS disc (`has_aacs=false`) always -/// returns `false`: unencrypted content has `None` keys legitimately, and CSS -/// DVDs resolve to `DecryptKeys::Css{..}` (never `None`). -fn aacs_key_missing(raw: bool, has_aacs: bool, keys: &crate::decrypt::DecryptKeys) -> bool { - !raw && has_aacs && matches!(keys, crate::decrypt::DecryptKeys::None) -} - -/// CSS analogue of [`aacs_key_missing`]. Returns `true` when decryption is -/// requested (`!raw`), the disc is CSS-encrypted (`has_css`), and per-title key -/// resolution yielded no usable key (`keys` is -/// [`crate::decrypt::DecryptKeys::None`] — e.g. a multi-VTS DVD whose chosen -/// title's VTS could not be re-cracked). Muxing that would emit scrambled -/// ciphertext, so the caller fails fast with [`Error::CssKeyMissing`]. -fn css_key_missing(raw: bool, has_css: bool, keys: &crate::decrypt::DecryptKeys) -> bool { - !raw && has_css && matches!(keys, crate::decrypt::DecryptKeys::None) -} - -/// Scrambled-but-uncracked CSS guard (Fix 6). Returns `true` when decryption -/// is requested (`!raw`) and the scan recorded a hard CSS error -/// (`has_css_error` — `disc.css_error.is_some()`), meaning the content is -/// scrambled but no title key was recovered (so `disc.css` is `None`). Muxing -/// that case would pass scrambled MPEG through as plaintext, so the caller -/// fails fast with [`Error::CssKeyMissing`]. `--raw` is exempt (skips -/// decryption), so it always returns `false`. -fn css_error_aborts(raw: bool, has_css_error: bool) -> bool { - !raw && has_css_error -} - /// Open a PES input stream (produces PES frames). pub fn input(url: &str, opts: &InputOptions) -> io::Result> { let parsed = parse_url(url); @@ -295,32 +258,17 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result io::Error { e.into() })?; } - // CSS scrambled-but-uncracked guard (Fix 6): the scan saw scrambled - // sectors but recovered no title key, so `disc.css` is None yet the - // content IS encrypted. Without this, `css.is_none()` would be read - // as "unencrypted" and the scrambled MPEG would mux as plaintext - // garbage at exit 0. Surface the recorded hard error instead. - // `--raw` skips decryption, so it is exempt. - if css_error_aborts(opts.raw, disc.css_error.is_some()) { - return Err(crate::error::Error::CssKeyMissing.into()); - } - // No-key guard: if decryption is requested (not --raw) and the disc - // is AACS-encrypted but key resolution yielded no usable key, FAIL - // here — muxing an undecryptable stream produces ~100 MB of garbage - // (encrypted m2ts → no TS syncs → demuxer emits nothing). A cheap - // result-check on `decrypt_keys()`; no probe decryption needed. - // CSS (DVD) decrypts from compiled keys (`decrypt_keys()` returns - // `Css{..}`, never `None`), so this gate is AACS-only via `disc.aacs`. - if aacs_key_missing(opts.raw, disc.aacs.is_some(), &disc.decrypt_keys()) { - // Surface the disc hash (40-hex, no `0x` prefix) so the caller - // can name the disc. Empty if scan didn't capture it. - let disc_hash = disc - .aacs - .as_ref() - .map(|a| a.disc_hash.trim_start_matches("0x").to_string()) - .unwrap_or_default(); - return Err(crate::error::Error::NoDiscKey { disc_hash }.into()); - } + // Pre-flight decrypt gate (the single, system-wide verdict — see + // `Disc::ensure_decryptable`). Fails fast BEFORE any mux work when + // decryption is needed and unavailable: a scrambled-but-uncracked + // CSS disc (`css_error` set), or an AACS-encrypted disc with no + // usable key (would mux ~100 MB of garbage — encrypted m2ts → no TS + // syncs → demuxer emits nothing → empty/garbage output at exit 0). + // `--raw` and unencrypted/CSS-keyless-success discs pass. This is the + // disc-wide check; the per-title (multi-VTS CSS) check is below, once + // the chosen title's key is resolved. + disc.ensure_decryptable(opts.raw) + .map_err(|e| -> io::Error { e.into() })?; if disc.titles.is_empty() { return Err(crate::error::Error::NoStreams.into()); } @@ -342,13 +290,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result disc.decrypt_keys_for_title(idx, &mut crack_reader, 64), Err(_) => disc.decrypt_keys(), }; - // CSS no-key guard (parallel to the AACS gate above): on a CSS - // disc, decrypt_keys_for_title may return `None` when the chosen - // title's VTS could not be re-cracked. Muxing that would emit - // scrambled ciphertext verbatim, so fail loudly here instead. - if css_key_missing(opts.raw, disc.css.is_some(), &keys) { - return Err(crate::error::Error::CssKeyMissing.into()); - } + // Per-title decrypt gate (parallel to the disc-wide gate above): on + // a multi-VTS CSS disc, `decrypt_keys_for_title` may return `None` + // when the chosen title's VTS could not be re-cracked. Muxing that + // would emit scrambled ciphertext verbatim, so fail loudly here. + // Same verdict source as the disc-wide gate, judged against the + // per-title key. + disc.ensure_decryptable_keys(opts.raw, &keys) + .map_err(|e| -> io::Error { e.into() })?; // Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1) // by probing the first DECRYPTED access units of the chosen title. // A fresh reader avoids disturbing the mux reader below. Skipped in @@ -678,9 +627,6 @@ fn build_m2ts_pipeline( #[cfg(test)] mod tests { use super::StreamUrl; - use super::aacs_key_missing; - use super::css_error_aborts; - use super::css_key_missing; use super::parse_url; use super::validate_network_addr; use super::{build_demux_state, build_iso_pipeline, input, output}; @@ -690,6 +636,50 @@ mod tests { use crate::sector::SectorSource; use std::path::PathBuf; + /// `parse_url` must never panic on ANY input — it is the front door for + /// caller-supplied URL strings, so a panic here would crash the binary on + /// malformed input instead of surfacing a clean error downstream. Feed it a + /// battery of adversarial strings (empty, doubled/garbled schemes, embedded + /// NUL, unicode, a very long path, lone scheme markers) plus an exhaustive + /// sweep of every single byte 0x00..=0xFF as the whole input and as a scheme + /// suffix. Any `StreamUrl` variant is an acceptable result; the only failure + /// mode under test is a panic. + #[test] + fn parse_url_never_panics_on_adversarial_input() { + let mut cases: Vec = vec![ + String::new(), + "://".into(), + "//".into(), + ":".into(), + "disc".into(), + "disc:/".into(), + "disc:://".into(), + "disc://disc://".into(), + "iso://iso://x".into(), + "mkv://mkv://mkv://".into(), + "iso://\0/etc".into(), // embedded NUL + "iso://日本語/フィルム.iso".into(), // unicode path + "network://[::1]:9000".into(), + "ftp://host/x".into(), + format!("iso://{}", "a".repeat(100_000)), // very long path + "\u{feff}disc://".into(), // BOM prefix + ]; + // Every byte as the entire input, and as an iso:// path suffix. + for b in 0u8..=255 { + cases.push(String::from_utf8_lossy(&[b]).into_owned()); + cases.push(format!("iso://{}", String::from_utf8_lossy(&[b]))); + } + for c in &cases { + // The contract: returns SOME variant, never panics. We also exercise + // scheme()/path_str()/is_disc_source() so their match arms can't + // panic on the parsed result either. + let u = parse_url(c); + let _ = u.scheme(); + let _ = u.path_str(); + let _ = u.is_disc_source(); + } + } + #[test] fn disk_scheme_is_alias_for_disc() { // `disk://` must parse identically to `disc://`: empty = auto-detect @@ -744,83 +734,10 @@ mod tests { assert!(validate_network_addr("host:65535").is_ok()); } - fn aacs_keys() -> DecryptKeys { - DecryptKeys::Aacs { - unit_keys: vec![(1, [0x11u8; 16])], - read_data_key: None, - } - } - - fn css_keys() -> DecryptKeys { - DecryptKeys::Css { - title_key: [0u8; 5], - } - } - - #[test] - fn encrypted_no_key_aborts() { - // AACS disc, decryption requested, resolver yielded no key → abort. - assert!(aacs_key_missing(false, true, &DecryptKeys::None)); - } - - #[test] - fn encrypted_with_key_proceeds() { - // AACS disc with a usable key → proceed. - assert!(!aacs_key_missing(false, true, &aacs_keys())); - } - - #[test] - fn not_encrypted_proceeds() { - // No AACS state: unencrypted (None keys) and CSS (Css keys) both OK. - assert!(!aacs_key_missing(false, false, &DecryptKeys::None)); - assert!(!aacs_key_missing(false, false, &css_keys())); - } - - #[test] - fn css_no_key_aborts() { - // CSS disc, decryption requested, per-title resolver yielded None - // (e.g. an un-re-crackable VTS) → abort instead of muxing ciphertext. - assert!(css_key_missing(false, true, &DecryptKeys::None)); - } - - #[test] - fn css_with_key_proceeds() { - // CSS disc with a resolved title key → proceed. - assert!(!css_key_missing(false, true, &css_keys())); - } - - #[test] - fn css_raw_never_aborts() { - // --raw skips decryption: never abort even with no CSS key. - assert!(!css_key_missing(true, true, &DecryptKeys::None)); - } - - #[test] - fn css_guard_ignores_non_css() { - // No CSS state (AACS / unencrypted): the CSS guard never fires. - assert!(!css_key_missing(false, false, &DecryptKeys::None)); - } - - #[test] - fn css_error_field_aborts_unless_raw() { - // Fix 6: a scrambled-but-uncracked DVD records a hard error in - // `disc.css_error` (css is None). With decryption requested the - // input() guard must abort with CssKeyMissing. - assert!(css_error_aborts(false, true)); - // --raw skips decryption → never aborts on the css_error field. - assert!(!css_error_aborts(true, true)); - // No recorded css_error → the guard does not fire. - assert!(!css_error_aborts(false, false)); - } - - #[test] - fn raw_never_aborts() { - // --raw skips decryption — must never hit the no-key abort, even on an - // AACS disc with no key resolved. - assert!(!aacs_key_missing(true, true, &DecryptKeys::None)); - assert!(!aacs_key_missing(true, true, &aacs_keys())); - assert!(!aacs_key_missing(true, false, &DecryptKeys::None)); - } + // The decrypt-verdict matrix (raw / unencrypted / AACS-no-key / + // CSS-no-key / css_error) is owned by `Disc::ensure_decryptable[_keys]` and + // tested in `crate::disc` — `input()` now delegates to it, so the matrix is + // asserted once at the source of truth rather than re-tested here. // ── input()/output() routing + validation ─────────────────────────────