CSS: fix the decrypted-HD-DVD false E7023 at the detection layer, not the public API

Commit 4cd9b7b ("key the DVD crack on the disc, not on the container") fixed a
real bug — a decrypted HD-DVD hit E7023 (CssKeyMissing) because the per-title
CSS crack keyed on the MPEG-PS container, which DVD and HD-DVD share — but did
it by adding a required `disc_format: DiscFormat` parameter to the PUBLIC
`DiscStream::new` and `build_iso_pipeline`, a `disc_format` field to
`MuxInput::Iso`/`Live`, and `DiscFormat::may_have_css`, threading the axis down
through mux/driver and mux/resolve. That changed the public API and broke every
downstream caller's compilation (freemkv-engine's integration test now needed 8
args, autorip's MuxInput arms a new field). 1.6.4 shipped and worked with these
exact signatures; a bug fix must not reshape them, and needing a whole
disc-format plumb for HD-DVD was a code smell.

Revert all of that plumbing (public signatures restored to their pre-4cd9b7b
form; no `disc_format` parameter or field, no `may_have_css`, anywhere), and fix
the ACTUAL bug where it lives: the scramble-detection heuristic.

Root cause: `is_scrambled_pack` counted a sector as CSS scramble evidence on
pack-start (00 00 01 BA) + bits 4-5 of byte 0x14. Offset 0x14 is only the PES
scrambling-control field when the sector is a genuine elementary-stream pack. An
HD-DVD `.evo` RDI navigation pack is private_stream_2 (stream_id 0xBF), an
MPEG-PS pack exactly like a DVD VOB, whose byte 0x14 is raw nav payload that
routinely has bits 4-5 set. On a decrypted HD-DVD (None keys, MPEG-PS, so it
reaches the crack) those nav packs flipped the scan's `saw_scrambled` flag; the
crack then found no key — there is no CSS on an HD-DVD — and the scan returned
ScrambledUncracked, hard-failing a good disc with E7023.

Fix: exclude the MPEG-PS structural stream_ids CSS never scrambles — system
header (0xBB), padding (0xBE), private_stream_2 (0xBF) — by the stream_id at
offset 0x11. This is the refinement the DVD design notes already called for
("matches CrackTitleKey"). It needs no format plumbing because byte 0x11 lives
in the CSS-clear header (0x00-0x7F, untouched by scrambling), so it is the true
stream_id even on ciphertext. A decrypted HD-DVD now scans to Unencrypted and
muxes cleanly.

The DVD CSS crack is preserved and proven: a genuinely CSS-scrambled DVD sector
is always video (0xE0-0xEF) or private_stream_1 (0xBD), never an excluded id, so
its scrambled packs still set saw_scrambled and still hard-fail an uncrackable
disc — the "ciphertext muxed as plaintext at rc=0" catastrophe cannot slip
through. Red-before-green both directions: dropping the 0x11 exclusion turns the
decrypted-HD-DVD case back into E7023; inverting it (only nav ids count) turns a
real uncrackable DVD into Unencrypted and strands a crackable one. Both mutations
are caught by tests.

Gate (cargo +1.97): fmt, clippy --all-targets -D warnings, 3539 tests green;
freemkv-engine and autorip both compile against this tree again; precommit.sh
libfreemkv clean.
This commit is contained in:
Matthew Jackson
2026-08-18 23:26:22 -07:00
parent efb69e3ba5
commit 25893f1be4
7 changed files with 150 additions and 307 deletions
+5 -68
View File
@@ -208,25 +208,12 @@ 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> {
@@ -236,20 +223,17 @@ 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 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.
// 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.
crate::css::resolve_dvd_title_key(
&mut *reader,
&extents,
&mut decrypt_keys,
batch_sectors,
content_format,
disc_format,
raw,
halt.as_ref(),
)?;
@@ -1218,7 +1202,6 @@ 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,
)
@@ -1309,7 +1292,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1346,7 +1328,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1459,7 +1440,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1503,7 +1483,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1539,7 +1518,6 @@ mod tests {
aacs,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1702,7 +1680,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1819,7 +1796,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -1883,7 +1859,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2004,7 +1979,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2064,7 +2038,6 @@ mod tests {
keys,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2164,7 +2137,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2209,7 +2181,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
@@ -2267,7 +2238,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
crate::disc::DiscFormat::Dvd,
false,
None,
);
@@ -2277,36 +2247,6 @@ 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.
@@ -2318,7 +2258,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
crate::disc::DiscFormat::Dvd,
true, // raw
None,
);
@@ -2532,7 +2471,6 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
crate::disc::DiscFormat::Dvd,
false,
None,
)
@@ -2654,7 +2592,6 @@ mod tests {
},
3,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
)
+1 -30
View File
@@ -129,15 +129,6 @@ 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`).
@@ -160,12 +151,6 @@ 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,
@@ -366,7 +351,6 @@ pub fn mux_stream(
path,
title,
format,
disc_format,
keys,
key_fetch,
} => {
@@ -398,7 +382,6 @@ pub fn mux_stream(
keys,
opts.batch_sectors,
format,
disc_format,
opts.raw,
Some(halt.clone()),
Some(reader_event_fn(events.clone())),
@@ -413,7 +396,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, disc_format, mut keys, playlist, source) = {
let (mut title, format, mut keys, playlist, source) = {
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(),
})?;
@@ -441,15 +424,9 @@ 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,
@@ -491,7 +468,6 @@ pub fn mux_stream(
keys,
opts.batch_sectors,
format,
disc_format,
opts.raw,
Some(halt.clone()),
)?;
@@ -512,7 +488,6 @@ pub fn mux_stream(
mut reader,
title,
format,
disc_format,
mut keys,
key_map,
} => {
@@ -571,7 +546,6 @@ pub fn mux_stream(
keys,
opts.batch_sectors,
format,
disc_format,
opts.raw,
Some(halt.clone()),
)?;
@@ -1657,7 +1631,6 @@ mod tests {
path: &iso_path,
title,
format: crate::disc::ContentFormat::BdTs,
disc_format: crate::disc::DiscFormat::BluRay,
keys: DecryptKeys::None,
key_fetch: None,
},
@@ -1769,7 +1742,6 @@ mod tests {
reader,
title,
format: crate::disc::ContentFormat::BdTs,
disc_format: crate::disc::DiscFormat::BluRay,
keys: DecryptKeys::None,
key_map: Some(map),
},
@@ -1892,7 +1864,6 @@ 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
},
+5 -26
View File
@@ -613,12 +613,6 @@ 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
@@ -650,7 +644,6 @@ where
effective_keys,
ISO_MUX_BATCH_SECTORS,
format,
disc_format,
opts.raw,
None,
None,
@@ -2116,11 +2109,6 @@ 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.
@@ -2140,7 +2128,6 @@ 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>,
@@ -2148,20 +2135,17 @@ 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 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.
// 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.
crate::css::resolve_dvd_title_key(
&mut reader,
&extents,
&mut keys,
batch_sectors,
format,
disc_format,
raw,
halt.as_ref(),
)?;
@@ -3007,7 +2991,6 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
None,
@@ -3050,7 +3033,6 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
None,
@@ -3148,7 +3130,6 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
None,
@@ -3188,7 +3169,6 @@ mod tests {
DecryptKeys::None,
0,
ContentFormat::BdTs,
crate::disc::DiscFormat::BluRay,
false,
None,
None,
@@ -3231,7 +3211,6 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::MpegPs,
crate::disc::DiscFormat::Dvd,
false,
None,
None,