libfreemkv: hard-error when decryption is needed but no key is available
Adds Disc::ensure_decryptable / ensure_decryptable_keys, the single decrypt gate consulted before any copy or mux. When the source is encrypted and no key resolved (and not --raw), abort with a typed error and write nothing, instead of silently emitting ciphertext at exit 0. Unifies the prior ad-hoc CSS/AACS checks.
This commit is contained in:
+298
@@ -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<CopyResult> {
|
||||
// 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;
|
||||
|
||||
@@ -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) =
|
||||
|
||||
+67
-150
@@ -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<Box<dyn crate::pes::Stream>> {
|
||||
let parsed = parse_url(url);
|
||||
@@ -295,32 +258,17 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[])
|
||||
.map_err(|e| -> 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<Box<dyn crate::pes::S
|
||||
Ok(mut crack_reader) => 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<R: std::io::Read + Send + 'static>(
|
||||
#[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<String> = 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 ─────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user