diff --git a/CHANGELOG.md b/CHANGELOG.md index 7da5baf..91cd3c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,17 @@ ### Fixed +- **An undecryptable CSS disc no longer exits successfully.** + `Error::CssKeyMissing` (E7023) carried two conditions needing opposite + responses: one title of a multi-VTS DVD failing its own re-crack — correctly + skippable, the rest of the disc still rips — and the *whole disc* failing its + crack (`Disc::css_error`), where every title fails identically. Both raised + E7023, which `is_skippable_title_stub` classifies, so an uncrackable disc + iterated all N titles logging "title skipped" and exited 0. The disc-wide gate + (`Disc::ensure_decryptable[_keys]`) now raises `Error::CssNoDiscKey` + (**E7027**) — the CSS analogue of `NoDiscKey` (E7022), classified by + `is_disc_level_no_key`, so a rip loop fails fast. The per-title raise keeps + E7023 and stays skippable. - **A corrupt `mkv://` input is no longer reported as a title worth silently skipping.** `Error::MkvInvalid` (E6008) carried two unrelated meanings: the genuine "this title produced no muxable frames" stub — which diff --git a/src/css/mod.rs b/src/css/mod.rs index ab8e928..b4844ac 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -91,8 +91,11 @@ pub fn crack_key( /// 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". +/// 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), @@ -148,7 +151,12 @@ pub fn crack_key_outcome( /// - 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. +/// 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], diff --git a/src/disc/mod.rs b/src/disc/mod.rs index e3b541d..68f5097 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -70,6 +70,12 @@ pub struct Disc { /// plaintext garbage. `None` when no scrambled sector was seen (genuinely /// unencrypted) or a key was recovered (`css.is_some()`). The CSS analogue /// of [`Self::aacs_error`]. + /// + /// This records the MAIN feature's crack, so it is a WHOLE-DISC signal: the + /// gates convert it into [`crate::error::Error::CssNoDiscKey`] (disc-level, + /// `error::is_disc_level_no_key`), not the per-title + /// [`crate::error::Error::CssKeyMissing`] the field itself carries as its + /// recorded reason. pub css_error: Option, /// Content format (BD transport stream vs DVD program stream) pub content_format: ContentFormat, @@ -2530,10 +2536,13 @@ impl Disc { /// 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 + /// - `self.css_error.is_some()` → `Err(Error::CssNoDiscKey)`. 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. + /// "unencrypted" would mux scrambled MPEG as plaintext garbage. A + /// DISC-LEVEL verdict (`error::is_disc_level_no_key`) — the main feature's + /// crack failed, so every title fails the same way and the rip loop must + /// stop rather than skip each title in turn. /// - AACS-encrypted (`self.aacs.is_some()`) with no usable key /// (`decrypt_keys()` is `None`) → `Err(Error::NoDiscKey { .. })`, naming /// the disc by hash. @@ -2567,9 +2576,15 @@ impl Disc { 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. + // so the key check below can't see it. `css_error` records the MAIN + // feature's crack, so this is a WHOLE-DISC verdict — every title would + // fail identically — and it is raised as `CssNoDiscKey` (disc-level, + // `error::is_disc_level_no_key`), never as the per-title + // `CssKeyMissing` (`error::is_skippable_title_stub`). Raised as the + // latter, an undecryptable disc made the rip loop iterate all N titles + // logging "title skipped, it was an empty stub" and exit 0. if self.css_error.is_some() { - return Err(Error::CssKeyMissing); + return Err(Error::CssNoDiscKey); } // 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 @@ -2726,12 +2741,20 @@ impl Disc { // detection missed — exactly the case the per-title crack exists to catch. // Without this, an uncrackable DVD title would fall through to `Ok` and mux // scrambled sectors as corrupt PES at exit 0. + // + // `CssKeyMissing` (per-title, `error::is_skippable_title_stub`) is the + // RIGHT code here and must stay: this is one title of a multi-VTS disc, + // and a sibling title in another VTS may still crack its own key, so an + // all-titles rip skips this one and finishes the rest. The whole-disc + // failure — nothing on the disc cracked — is the gate above's + // `CssNoDiscKey`. if self.format == DiscFormat::Dvd && !title_is_clear && !keys.is_encrypted() { return Err(Error::CssKeyMissing); } // A usable per-title key was resolved (a freshly-cracked CSS key, or AACS // unit keys) — the title IS decryptable, so pass it WITHOUT consulting the - // disc-wide gate. `ensure_decryptable_keys` hard-fails on `self.css_error` + // disc-wide gate. `ensure_decryptable_keys` hard-fails (disc-level, + // `CssNoDiscKey`) on `self.css_error` // unconditionally, which reflects the MAIN feature's crack: a bonus title // in a different VTS that just cracked its own key must not be blocked by // the main title having failed. @@ -3914,8 +3937,10 @@ mod tests { } /// 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". + /// `css_error` is Some — the disc IS encrypted. The gate must fail rather + /// than read `css.is_none()` as "unencrypted", and with the DISC-LEVEL + /// `CssNoDiscKey` (not the per-title, skippable `CssKeyMissing`): `css_error` + /// reflects the main feature's crack, so every title fails identically. #[test] fn ensure_decryptable_css_error_errors() { let mut disc = make_test_disc(1000, "DVD"); @@ -3924,11 +3949,62 @@ mod tests { let err = disc .ensure_decryptable(false) .expect_err("scrambled-but-uncracked CSS must error"); - assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code()); + assert_eq!(err.code(), crate::error::Error::CssNoDiscKey.code()); // --raw is exempt. assert!(disc.ensure_decryptable(true).is_ok()); } + /// The two CSS no-key conditions are NOT the same verdict and must classify + /// oppositely through the public predicates: + /// + /// - **disc-wide** — `css_error` is set: the MAIN feature's crack failed, so + /// every title of this disc fails identically. Must be + /// [`crate::error::is_disc_level_no_key`] (the rip loop fail-fasts) and + /// must NOT be [`crate::error::is_skippable_title_stub`]. While both + /// conditions shared `E_CSS_KEY_MISSING`, an uncrackable CSS disc iterated + /// all N titles logging "title skipped" and exited 0 — a total failure + /// reported as success. + /// - **per-title** — one title's own re-crack failed on a multi-VTS disc + /// (`title_is_clear == false`, no key): skipping it and finishing the rest + /// is correct policy, so it must STAY skippable and must NOT be disc-level. + /// + /// Pinned in both directions so a future change cannot silently flip either. + #[test] + fn css_disc_wide_no_key_is_disc_level_while_per_title_stays_skippable() { + // Disc-wide: the scan saw scrambled sectors and recovered no key. + let mut disc = make_test_disc(1000, "DVD"); + disc.encrypted = true; + disc.css_error = Some(crate::error::Error::CssKeyMissing); + let wide: std::io::Error = disc + .ensure_decryptable(false) + .expect_err("scrambled-but-uncracked CSS disc must error") + .into(); + assert!( + crate::error::is_disc_level_no_key(&wide), + "a whole-disc CSS crack failure must classify as disc-level: {wide}" + ); + assert!( + !crate::error::is_skippable_title_stub(&wide), + "a whole-disc CSS crack failure must NOT be a skippable title stub: {wide}" + ); + + // Per-title: this title's VTS could not be re-cracked; the rest of the + // disc may still rip. + let (stub_disc, _) = css_disc_with_clear_stub(); + let per_title: std::io::Error = stub_disc + .ensure_title_decryptable(false, &crate::decrypt::DecryptKeys::None, false) + .expect_err("scrambled-uncracked title must error") + .into(); + assert!( + crate::error::is_skippable_title_stub(&per_title), + "a per-title CSS re-crack failure must stay skippable: {per_title}" + ); + assert!( + !crate::error::is_disc_level_no_key(&per_title), + "a per-title CSS re-crack failure must NOT stop the whole rip: {per_title}" + ); + } + /// CSS-keyless-crack SUCCESS: `css` is Some with a title key → proceed. #[test] fn ensure_decryptable_css_with_key_proceeds() { diff --git a/src/error.rs b/src/error.rs index e140ea3..450400d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -91,6 +91,16 @@ pub const E_CSS_KEY_MISSING: u16 = 7023; pub const E_AACS_NO_HOST_CERT: u16 = 7024; pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025; pub const E_FMTS_KEY_MISSING: u16 = 7026; +/// The CSS disc as a WHOLE could not be decrypted — the scan saw scrambled +/// sectors and the known-plaintext crack recovered no title key at all, so every +/// title will fail identically. The CSS analogue of [`E_NO_DISC_KEY`], and +/// deliberately NOT [`E_CSS_KEY_MISSING`], which [`is_skippable_title_stub`] +/// treats as one skippable per-title stub: while both conditions shared +/// `E_CSS_KEY_MISSING`, an uncrackable CSS disc was iterated title by title, +/// each one logged as skipped, and the run exited reporting success — a total +/// failure reported as success. [`is_disc_level_no_key`] classifies this code, so +/// a multi-title rip loop fails fast on it. +pub const E_CSS_NO_DISC_KEY: u16 = 7027; // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; @@ -419,7 +429,24 @@ pub enum Error { /// title (e.g. a multi-VTS DVD where the title's VTS could not be /// re-cracked). Muxing would emit scrambled ciphertext, so the caller /// fails fast instead. CSS analogue of [`Error::NoDiscKey`]. + /// + /// PER-TITLE by construction: a sibling title in another VTS may still crack + /// its own key, so [`is_skippable_title_stub`] classifies this code and an + /// all-titles rip skips the title and finishes the rest. The whole-disc + /// counterpart — the main feature's crack failed, so nothing on the disc can + /// be decrypted — is [`Error::CssNoDiscKey`]. CssKeyMissing, + /// The disc is CSS-encrypted and decryption was requested, but the + /// known-plaintext crack recovered NO title key for the disc at all (the scan + /// saw scrambled sectors and stamped `Disc::css_error`). A whole-disc + /// condition: every title would fail the same way, so a multi-title rip loop + /// must stop instead of iterating. The CSS analogue of [`Error::NoDiscKey`] + /// on the disc-wide axis, and classified by [`is_disc_level_no_key`] — NOT by + /// [`is_skippable_title_stub`], which owns the per-title + /// [`Error::CssKeyMissing`]. Raising this as `CssKeyMissing` (as the disc-wide + /// gate once did) makes an undecryptable disc log one "title skipped" notice + /// per title and exit successfully. + CssNoDiscKey, /// The live-drive AACS cert-auth handshake (the OEM/AACS baseline route) /// could not run because NO host certificate was available from any key /// source. Host certs are keysource-served, never compiled in, so without @@ -687,6 +714,7 @@ impl Error { Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE, Error::NoDiscKey { .. } => E_NO_DISC_KEY, Error::CssKeyMissing => E_CSS_KEY_MISSING, + Error::CssNoDiscKey => E_CSS_NO_DISC_KEY, Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT, Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE, Error::FmtsKeyMissing => E_FMTS_KEY_MISSING, @@ -1029,6 +1057,13 @@ fn io_error_code(e: &std::io::Error) -> Option { /// worth silently passing over by a run that then exited successfully. They now /// carry their own codes and are fatal here — as is /// [`Error::MuxHeaderBufferExceeded`]. +/// +/// Nor is a WHOLE-DISC key failure. [`Error::CssNoDiscKey`] (the disc's CSS +/// crack recovered no key at all) is the same conflation on the decrypt axis: +/// while it too was raised as [`Error::CssKeyMissing`], every title of an +/// undecryptable disc classified as a skippable stub, so the rip loop skipped +/// all of them and exited successfully. It is [`is_disc_level_no_key`]'s, and +/// fatal here. pub fn is_skippable_title_stub(e: &std::io::Error) -> bool { matches!(io_error_code(e), Some(E_MKV_INVALID | E_CSS_KEY_MISSING)) } @@ -1046,14 +1081,21 @@ pub fn is_halt(e: &std::io::Error) -> bool { /// the disc as a whole cannot be decrypted, so EVERY title will fail the same /// way. Distinct from a per-title skippable stub /// ([`is_skippable_title_stub`]): `E_NO_DISC_KEY` (keydb present but no entry -/// for this disc), `E_KEYDB_LOAD` (no keydb at all), and `E_AACS_NO_KEYS` (no -/// usable AACS key material) are all whole-disc conditions. A multi-title rip -/// loop should stop immediately on this (fail-fast) rather than iterate every -/// title re-printing the same error. +/// for this disc), `E_KEYDB_LOAD` (no keydb at all), `E_AACS_NO_KEYS` (no +/// usable AACS key material), and `E_CSS_NO_DISC_KEY` (the CSS crack recovered +/// no title key for the disc at all) are all whole-disc conditions. A +/// multi-title rip loop should stop immediately on this (fail-fast) rather than +/// iterate every title re-printing the same error. +/// +/// `E_CSS_NO_DISC_KEY` is the CSS side of exactly that split, and it exists +/// because the disc-wide CSS failure used to be raised as the per-title +/// [`E_CSS_KEY_MISSING`]: an undecryptable CSS disc landed in +/// [`is_skippable_title_stub`], so the rip loop skipped all N titles with an +/// "empty stub" notice and exited successfully. pub fn is_disc_level_no_key(e: &std::io::Error) -> bool { matches!( io_error_code(e), - Some(E_NO_DISC_KEY | E_KEYDB_LOAD | E_AACS_NO_KEYS) + Some(E_NO_DISC_KEY | E_KEYDB_LOAD | E_AACS_NO_KEYS | E_CSS_NO_DISC_KEY) ) } @@ -1205,6 +1247,49 @@ mod tests { assert!(!is_skippable_title_stub(&cap)); } + /// The two CSS no-key conditions must land on OPPOSITE sides of the + /// per-title / whole-disc split, and the AACS pair must keep doing the same. + /// + /// [`Error::CssNoDiscKey`] is the disc-wide verdict (the main feature's crack + /// failed, so every title fails identically) and belongs ONLY to + /// [`is_disc_level_no_key`], exactly like its AACS analogue + /// [`Error::NoDiscKey`]. [`Error::CssKeyMissing`] is the per-title verdict + /// (one VTS of a multi-VTS DVD could not be re-cracked) and belongs ONLY to + /// [`is_skippable_title_stub`], so an all-titles rip skips that title and + /// finishes the rest. While the disc-wide raise also used + /// `E_CSS_KEY_MISSING`, an uncrackable CSS disc was iterated title by title, + /// each one "skipped", and the run exited 0. + #[test] + fn css_no_key_codes_split_disc_level_from_skippable() { + let wide: std::io::Error = Error::CssNoDiscKey.into(); + assert!( + is_disc_level_no_key(&wide), + "the disc-wide CSS no-key code must be disc-level: {wide}" + ); + assert!( + !is_skippable_title_stub(&wide), + "the disc-wide CSS no-key code must not be skippable: {wide}" + ); + + let per_title: std::io::Error = Error::CssKeyMissing.into(); + assert!( + is_skippable_title_stub(&per_title), + "the per-title CSS no-key code must stay skippable: {per_title}" + ); + assert!( + !is_disc_level_no_key(&per_title), + "the per-title CSS no-key code must not stop the whole rip: {per_title}" + ); + + // The AACS side of the same split, unchanged. + let aacs: std::io::Error = Error::NoDiscKey { + disc_hash: String::new(), + } + .into(); + assert!(is_disc_level_no_key(&aacs)); + assert!(!is_skippable_title_stub(&aacs)); + } + #[test] fn new_variants_have_distinct_codes() { let codes = [ @@ -1285,6 +1370,9 @@ mod tests { (Error::MapfileInvalid { kind: "hex" }, E_MAPFILE_INVALID), (Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT), (Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED), + // Both CSS no-key verdicts: numeric-only Display, no English. + (Error::CssKeyMissing, E_CSS_KEY_MISSING), + (Error::CssNoDiscKey, E_CSS_NO_DISC_KEY), ]; for (e, want_code) in cases { let s = e.to_string(); @@ -1485,6 +1573,7 @@ mod tests { E_VID_CDB_UNAVAILABLE, E_NO_DISC_KEY, E_CSS_KEY_MISSING, + E_CSS_NO_DISC_KEY, E_AACS_NO_HOST_CERT, E_AACS_BUS_KEY_UNAVAILABLE, E_FMTS_KEY_MISSING,