CSS: key the DVD crack on the disc, not on the container

`resolve_dvd_title_key` decided whether to run the DVD CSS crack by asking
what CONTAINER it was looking at — `ContentFormat::MpegPs`. MPEG program
stream is what DVD and HD-DVD have in common, so every HD-DVD title was
run through a crack scan for a copy-protection scheme HD-DVD does not
use and cannot carry: AACS is its family, and CSS appears nowhere on the
disc.

Both outcomes of that scan were wrong. The cheap one wasted up to 50,000
sector reads per title. The expensive one returned `CssKeyMissing` —
E7023 — refusing a perfectly good HD-DVD with a CSS error, which is what
a real CI run produced on the HD-DVD fixture. Reading E7023 there sends
whoever triages it looking for a missing DVD key on a disc that never had
one, which is how a routing bug spends a day disguised as a key problem.

The right axis was already in the codebase and already used: `mux/resolve`
asks `disc.format == DiscFormat::Dvd`. This threads the disc format down
to the decision and adds `DiscFormat::may_have_css` to name the question.

The asymmetry decides the default, so it is worth stating. Running CSS on
an HD-DVD costs a wasted scan or a false refusal — visible, recoverable,
annoying. NOT running it on a real DVD muxes scrambled sectors as
plaintext and exits 0, which is the failure-that-looks-like-success class
this project has shipped once already. So `may_have_css` is false ONLY
for the families proven CSS-free, and `DiscFormat::Unknown` — a bare
reader with no scan behind it — still cracks. An `== Dvd` allow-list
would have read as tighter while silently stranding every caller that
cannot name its disc.

Both directions are pinned: an HD-DVD title must never enter the crack,
and an unknown-format title must still enter it. Removing the disc-format
clause fails the first and leaves the second and every DVD test green.
This commit is contained in:
Matthew Jackson
2026-08-18 19:11:20 -07:00
parent 02e7bc605d
commit 4cd9b7baa1
6 changed files with 302 additions and 12 deletions
+68 -5
View File
@@ -208,12 +208,25 @@ impl DiscStream {
/// Works with physical drives and ISO files — both implement SectorSource.
/// The caller opens the source, scans for titles/keys, and passes them in.
/// The stream handles demuxing, decryption, and codec parsing internally.
///
/// `content_format` is the CONTAINER (TS vs PS demuxer). `disc_format` is
/// the DISC FAMILY, and it exists as its own parameter because the two are
/// not interchangeable: DVD and HD-DVD are both `ContentFormat::MpegPs`,
/// yet only DVD can carry CSS. It gates the per-title CSS crack below. A
/// caller that genuinely does not know the disc passes
/// [`crate::disc::DiscFormat::Unknown`], which still attempts the crack —
/// the safe direction (see [`crate::disc::DiscFormat::may_have_css`]).
// Eight params is inherent to a constructor that takes the source, the
// title, the keys, both format axes (container and disc family) and the
// read-mode flags; grouping them would only relocate the same fields.
#[allow(clippy::too_many_arguments)]
pub fn new(
mut reader: Box<dyn SectorSource>,
title: DiscTitle,
mut decrypt_keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
content_format: crate::disc::ContentFormat,
disc_format: crate::disc::DiscFormat,
raw: bool,
halt: Option<Halt>,
) -> std::io::Result<Self> {
@@ -223,17 +236,20 @@ impl DiscStream {
// Resolve this title's CSS key from the reader if the caller supplied
// none — the SAME shared step the file-backed mux highway
// (`build_iso_pipeline`) uses, so single-pass and multi-pass descramble a
// DVD identically. No-op for AACS / already-keyed / genuinely-clear input
// or `raw`; a scrambled-but-uncrackable DVD is a hard `CssKeyMissing`.
// `halt` is passed here (not deferred to `with_halt`) so a Stop during the
// crack scan is honored — the scan runs at construction, before the caller
// can attach a token.
// DVD identically. No-op for a disc format that cannot carry CSS (HD-DVD
// and the BD families — `disc_format`, NOT the MPEG-PS container, which
// DVD and HD-DVD share), for AACS / already-keyed / genuinely-clear
// input, and for `raw`; a scrambled-but-uncrackable DVD is a hard
// `CssKeyMissing`. `halt` is passed here (not deferred to `with_halt`)
// so a Stop during the crack scan is honored — the scan runs at
// construction, before the caller can attach a token.
crate::css::resolve_dvd_title_key(
&mut *reader,
&extents,
&mut decrypt_keys,
batch_sectors,
content_format,
disc_format,
raw,
halt.as_ref(),
)?;
@@ -1202,6 +1218,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8, // request 8 sectors (16384 B); the source delivers 1 (2048 B)
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1292,6 +1309,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1328,6 +1346,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1440,6 +1459,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1483,6 +1503,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1518,6 +1539,7 @@ mod tests {
aacs,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1680,6 +1702,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1796,6 +1819,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1859,6 +1883,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1979,6 +2004,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2038,6 +2064,7 @@ mod tests {
keys,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2137,6 +2164,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2181,6 +2209,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2238,6 +2267,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
crate::disc::DiscFormat::Dvd,
false,
None,
);
@@ -2247,6 +2277,36 @@ mod tests {
);
}
/// The HD-DVD counterpart of the test above, pinned at the SAME boundary so
/// the disc-format axis is proven to reach the shared CSS step through this
/// constructor and not just inside `css::resolve_dvd_title_key`.
///
/// Byte-for-byte identical input to `disc_stream_new_dvd_none_scrambled_hard_fails`
/// — same `LockedReader`, same MPEG-PS title, same `None` keys — with only
/// the disc format changed. The DVD case must still be refused (E7023) and
/// the HD-DVD case must construct: an HD-DVD is AACS and has no CSS, so
/// there is no CSS key for it to be missing. Catches the mutation of
/// dropping `disc_format` from `DiscStream::new`'s plumbing (or hardcoding
/// a CSS-capable value there), which is exactly the shape of the shipped
/// defect: E7023 on a perfectly good HD-DVD.
#[test]
fn disc_stream_new_hddvd_none_scrambled_does_not_hard_fail() {
let res = DiscStream::new(
Box::new(LockedReader),
mpegps_title(8),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
crate::disc::DiscFormat::HdDvd,
false,
None,
);
assert!(
res.is_ok(),
"an HD-DVD must never be refused for a missing CSS key — it carries no CSS"
);
}
/// `raw` must bypass the CSS crack at the DiscStream boundary too: the same
/// scrambled-uncrackable input that hard-fails above must CONSTRUCT in raw
/// mode (ciphertext passthrough), never hard-fail.
@@ -2258,6 +2318,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
crate::disc::DiscFormat::Dvd,
true, // raw
None,
);
@@ -2471,6 +2532,7 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
crate::disc::DiscFormat::Dvd,
false,
None,
)
@@ -2592,6 +2654,7 @@ mod tests {
},
3,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
+30 -1
View File
@@ -129,6 +129,15 @@ pub enum MuxInput<'a> {
title: DiscTitle,
/// Container format of the title (TS vs PS demuxer selection).
format: crate::disc::ContentFormat,
/// The scanned disc's FAMILY (`disc.format`) — a different axis from
/// `format`, which is only the container. DVD and HD-DVD are both
/// `ContentFormat::MpegPs`, yet only DVD can carry CSS, so this is what
/// gates the per-title CSS crack in [`build_iso_pipeline`]. Pass
/// [`crate::disc::DiscFormat::Unknown`] only when the disc was genuinely
/// never scanned: that value still runs the crack, which is the safe
/// direction (skipping it on a real DVD would mux ciphertext as
/// plaintext at exit 0).
disc_format: crate::disc::DiscFormat,
/// Decryption keys for the title (`DecryptKeys::None` for raw/clear).
keys: DecryptKeys,
/// Optional read-time key fetch closure (banked by `resolve_keys`).
@@ -151,6 +160,12 @@ pub enum MuxInput<'a> {
title: DiscTitle,
/// Container format (TS vs PS demux selection).
format: crate::disc::ContentFormat,
/// The scanned disc's FAMILY (`disc.format`), the CSS-eligibility axis
/// — see [`MuxInput::Iso::disc_format`]. Without it the inline
/// `DiscStream` cannot tell an HD-DVD `.evo` from a DVD `.vob` (both
/// are `ContentFormat::MpegPs`) and would run a CSS crack that an
/// AACS-family disc can never satisfy.
disc_format: crate::disc::DiscFormat,
/// Decryption keys the consumer already banked (`DecryptKeys::None` for
/// raw/clear). The driver consumes them as-is — never re-resolves.
keys: DecryptKeys,
@@ -351,6 +366,7 @@ pub fn mux_stream(
path,
title,
format,
disc_format,
keys,
key_fetch,
} => {
@@ -382,6 +398,7 @@ pub fn mux_stream(
keys,
opts.batch_sectors,
format,
disc_format,
opts.raw,
Some(halt.clone()),
Some(reader_event_fn(events.clone())),
@@ -396,7 +413,7 @@ pub fn mux_stream(
// Pull everything we need out of the disc as owned values so the
// immutable disc borrow is released before the mutable
// `take_reader` below.
let (mut title, format, mut keys, playlist, source) = {
let (mut title, format, disc_format, mut keys, playlist, source) = {
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(),
})?;
@@ -424,9 +441,15 @@ pub fn mux_stream(
};
// DVD CSS is per-VTS: resolve the per-title key via the pipeline
// (see `session_mux_keys`), never the whole-disc `decrypt_keys()`.
// `disc.content_format` is the container; `disc.format` is
// the disc FAMILY. Both are carried out of the borrow: the
// first picks the demuxer, the second decides whether a CSS
// crack is even meaningful (an HD-DVD is MPEG-PS too, and
// has no CSS).
(
title,
disc.content_format,
disc.format,
session_mux_keys(disc),
playlist,
source,
@@ -468,6 +491,7 @@ pub fn mux_stream(
keys,
opts.batch_sectors,
format,
disc_format,
opts.raw,
Some(halt.clone()),
)?;
@@ -488,6 +512,7 @@ pub fn mux_stream(
mut reader,
title,
format,
disc_format,
mut keys,
key_map,
} => {
@@ -546,6 +571,7 @@ pub fn mux_stream(
keys,
opts.batch_sectors,
format,
disc_format,
opts.raw,
Some(halt.clone()),
)?;
@@ -1631,6 +1657,7 @@ mod tests {
path: &iso_path,
title,
format: crate::disc::ContentFormat::BdTs,
disc_format: crate::disc::DiscFormat::BluRay,
keys: DecryptKeys::None,
key_fetch: None,
},
@@ -1742,6 +1769,7 @@ mod tests {
reader,
title,
format: crate::disc::ContentFormat::BdTs,
disc_format: crate::disc::DiscFormat::BluRay,
keys: DecryptKeys::None,
key_map: Some(map),
},
@@ -1864,6 +1892,7 @@ mod tests {
reader,
title,
format: crate::disc::ContentFormat::BdTs,
disc_format: crate::disc::DiscFormat::BluRay,
keys,
key_map: None, // plain AACS disc: the driver must resolve the base map
},
+26 -5
View File
@@ -613,6 +613,12 @@ where
}
let title = disc.titles[idx].clone();
let format = disc.content_format;
// The CSS-eligibility axis handed to the pipeline below. `content_format`
// above is only the container and cannot carry this decision: HD-DVD `.evo`
// is MPEG-PS exactly like DVD `.vob`, and gating the crack on the container
// is what sent every HD-DVD through a CSS scan it could never satisfy.
// Same value `is_dvd` was derived from further up.
let disc_format = disc.format;
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
// sequential read from fast storage, no bad sectors. Empirically
// optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
@@ -644,6 +650,7 @@ where
effective_keys,
ISO_MUX_BATCH_SECTORS,
format,
disc_format,
opts.raw,
None,
None,
@@ -2109,6 +2116,11 @@ pub(crate) fn resolve_mux_key_map_cached(
/// - `batch_sectors`: read batch size in logical (2048-byte) sectors — a
/// throughput/latency tuning knob, not a correctness parameter.
/// - `format`: container format (`BdTs` → TS demuxer, `MpegPs` → PS demuxer).
/// - `disc_format`: the disc FAMILY, a separate axis from `format` — DVD and
/// HD-DVD are both `MpegPs`, but only DVD can carry CSS. Gates the per-title
/// CSS crack below. A caller with no scanned disc passes
/// [`crate::disc::DiscFormat::Unknown`], which still cracks (the safe
/// direction — see [`crate::disc::DiscFormat::may_have_css`]).
/// - `raw`: ciphertext passthrough. When `true`, the per-title CSS crack
/// (`resolve_dvd_title_key`) is skipped entirely — no key is resolved and a
/// scrambled title is neither descrambled nor hard-failed.
@@ -2128,6 +2140,7 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
mut keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
format: ContentFormat,
disc_format: crate::disc::DiscFormat,
raw: bool,
halt: Option<crate::halt::Halt>,
event_fn: Option<crate::sector::prefetched::EventFn>,
@@ -2135,17 +2148,20 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
) -> io::Result<PipelinedPesStream> {
let extents = title.extents.clone();
// CSS (DVD) key resolution — the shared per-title step (also used by the
// live-drive single-pass `DiscStream`). A `None`/MPEG-PS title cracks its own
// key from the reader in playback order; AACS `.evo` (also MPEG-PS) arrives as
// `Aacs` and is untouched; a clear DVD stays `None`; `raw` skips it entirely.
// Without this a detection-miss CSS DVD would mux scrambled sectors as corrupt
// video. `halt` lets /api/stop interrupt the crack scan.
// live-drive single-pass `DiscStream`). A `None`/MPEG-PS title on a
// CSS-capable DISC FORMAT cracks its own key from the reader in playback
// order; an HD-DVD (also MPEG-PS, but AACS — no CSS exists to find) is
// skipped on the `disc_format` axis; AACS keys are untouched; a clear DVD
// stays `None`; `raw` skips it entirely. Without this a detection-miss CSS
// DVD would mux scrambled sectors as corrupt video. `halt` lets /api/stop
// interrupt the crack scan.
crate::css::resolve_dvd_title_key(
&mut reader,
&extents,
&mut keys,
batch_sectors,
format,
disc_format,
raw,
halt.as_ref(),
)?;
@@ -2991,6 +3007,7 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
None,
@@ -3033,6 +3050,7 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
None,
@@ -3130,6 +3148,7 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
None,
@@ -3169,6 +3188,7 @@ mod tests {
DecryptKeys::None,
0,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
None,
@@ -3211,6 +3231,7 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::MpegPs,
crate::disc::DiscFormat::Dvd,
false,
None,
None,