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.
This commit is contained in:
@@ -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
|
||||
|
||||
+11
-3
@@ -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],
|
||||
|
||||
+84
-8
@@ -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<crate::error::Error>,
|
||||
/// 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() {
|
||||
|
||||
+94
-5
@@ -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<u16> {
|
||||
/// 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,
|
||||
|
||||
Reference in New Issue
Block a user