CSS DVD: resolve the per-title key at read time, drop the scan-time crack
Every DVD read path — the file-backed mux highway (build_iso_pipeline) and the live-drive single-pass DiscStream — now resolves the per-VTS CSS title key through one shared step, css::resolve_dvd_title_key, cracked keylessly in playback order from the title's own extents. Removes the earlier design that reused a single scan-time key (meaningless for a per-VTS scheme) and muxed a detection-miss disc's scrambled sectors as garbage. - Disc::scan no longer cracks a key up front; it does only the CSS bus-auth read-unlock, hoisted before the UDF prefetch so scrambled small/menu VOBs no longer cost a rejected read each (CSS-DVD scan ~25s -> ~6s). - An uncrackable title hard-fails (E7023) instead of passing ciphertext as plaintext; --raw skips the crack entirely; a Stop mid-crack surfaces Halted. - DiscStream::new is now fallible and threads raw + halt. - Fix a stale codec-parser doc claim (TrueHD/FLAC/MP2/AAC do gate via DropTally).
This commit is contained in:
+11
-5
@@ -4,14 +4,20 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- CSS DVDs whose main title opens with a long clear run no longer mux to garbage.
|
- CSS DVDs no longer mux to garbage. Every DVD read path — the file-backed mux
|
||||||
The key crack scanned the largest cell first and gave up in its clear prefix, so
|
highway (`build_iso_pipeline`) and the live-drive single-pass `DiscStream` —
|
||||||
the scrambled feature was muxed as plaintext at exit 0. The per-title key is now
|
now resolves the per-VTS title key at read time through one shared step
|
||||||
reused from the scan when it covers the title's VTS, else cracked from the
|
(`resolve_dvd_title_key`), cracked keylessly in playback order from the title's
|
||||||
title's extents in playback order; an uncrackable title hard-fails (E7023).
|
own extents. An uncrackable title hard-fails (E7023) instead of passing
|
||||||
|
scrambled sectors through as plaintext; `--raw` skips the crack entirely; a
|
||||||
|
user Stop mid-crack surfaces as `Halted`.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- DVD scan no longer cracks a title key up front (the key is per-VTS, so a single
|
||||||
|
disc key was meaningless). Scan does only the CSS bus-auth read-unlock — hoisted
|
||||||
|
before the UDF prefetch so scrambled small/menu VOBs no longer cost a rejected
|
||||||
|
read each. Cuts a CSS-DVD scan from ~25s to ~6s.
|
||||||
- Unlocker report: the DVD entry is renamed `CSS` → `DVD`.
|
- Unlocker report: the DVD entry is renamed `CSS` → `DVD`.
|
||||||
|
|
||||||
## [1.5.1] — 2026-07-20
|
## [1.5.1] — 2026-07-20
|
||||||
|
|||||||
+258
@@ -136,6 +136,65 @@ pub fn crack_key_outcome(
|
|||||||
crack_key_scan(reader, extents, batch_sectors, halt, true)
|
crack_key_scan(reader, extents, batch_sectors, halt, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a DVD title's CSS descramble key from the reader when the caller
|
||||||
|
/// supplied none — the SINGLE place every DVD read path obtains a title key, so
|
||||||
|
/// the file-backed mux highway ([`crate::build_iso_pipeline`]) and the
|
||||||
|
/// live-drive single-pass [`crate::DiscStream`] descramble a DVD identically
|
||||||
|
/// ("reading is reading"). CSS keys are per-VTS and crackable from the scrambled
|
||||||
|
/// data itself, so a `None`/MPEG-PS title cracks its own key here, in playback
|
||||||
|
/// order over `extents`. Everything else is left untouched:
|
||||||
|
/// - AACS keys (HD-DVD `.evo` is also MPEG-PS but arrives as `Aacs`) — no CSS.
|
||||||
|
/// - a title that already carries a key — nothing to resolve.
|
||||||
|
/// - 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.
|
||||||
|
pub(crate) fn resolve_dvd_title_key(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
extents: &[Extent],
|
||||||
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
|
batch_sectors: u16,
|
||||||
|
format: crate::disc::ContentFormat,
|
||||||
|
raw: bool,
|
||||||
|
halt: Option<&crate::halt::Halt>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
// `--raw` = deliberate ciphertext passthrough: never crack or descramble, and
|
||||||
|
// never hard-fail on scrambled-uncrackable — the user asked for the scrambled
|
||||||
|
// bytes. (In raw mode the caller hands us `None` on purpose; without this
|
||||||
|
// guard we'd install a real key and silently DECRYPT, or abort a raw mux.)
|
||||||
|
if raw {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if matches!(keys, crate::decrypt::DecryptKeys::None)
|
||||||
|
&& format == crate::disc::ContentFormat::MpegPs
|
||||||
|
{
|
||||||
|
// `halt` threads the caller's cancellation token so /api/stop can
|
||||||
|
// interrupt a long crack scan (the old scan-time crack honored it too).
|
||||||
|
let outcome = crack_key_outcome(reader, extents, batch_sectors, halt);
|
||||||
|
// A cancelled crack breaks out early, so its outcome is a TRUNCATED scan
|
||||||
|
// — not a real verdict. Interpreting it would either hard-fail a good disc
|
||||||
|
// as `ScrambledUncracked` (quarantining staging on a Stop) or, worse,
|
||||||
|
// read a half-scanned title as `Unencrypted` and mux scrambled bytes as
|
||||||
|
// plaintext. Surface the cancellation as `Halted` so the caller takes its
|
||||||
|
// graceful-stop path instead of trusting the partial outcome.
|
||||||
|
if halt.map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||||
|
return Err(crate::error::Error::Halted.into());
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
CrackOutcome::Cracked(state) => {
|
||||||
|
*keys = crate::decrypt::DecryptKeys::Css {
|
||||||
|
title_key: state.title_key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
CrackOutcome::ScrambledUncracked => {
|
||||||
|
return Err(crate::error::Error::CssKeyMissing.into());
|
||||||
|
}
|
||||||
|
CrackOutcome::Unencrypted => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
|
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
|
||||||
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
|
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
|
||||||
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
|
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
|
||||||
@@ -893,6 +952,205 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read
|
||||||
|
/// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass)
|
||||||
|
/// call, so these pin its full contract at the shared boundary.
|
||||||
|
///
|
||||||
|
/// Crack path: a `None`-keyed MPEG-PS title with a crackable scrambled sector
|
||||||
|
/// installs a `Css` key that round-trips the sector.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_cracks_none_mpegps() {
|
||||||
|
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let crackable = crackable_sector(&title_key, &seed, 8);
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.crackable = Some((1003, crackable));
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 50,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("crackable title resolves");
|
||||||
|
match keys {
|
||||||
|
crate::decrypt::DecryptKeys::Css { title_key: got } => {
|
||||||
|
assert_eq!(got, title_key, "installed key must be the cracked key")
|
||||||
|
}
|
||||||
|
_ => panic!("expected Css key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard-fail path: a scrambled-but-uncrackable `None`-keyed MPEG-PS title must
|
||||||
|
/// return `CssKeyMissing`, never leave `keys` as `None` (which would mux
|
||||||
|
/// scrambled bytes as plaintext — the 328k-decode-error corruption).
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_scrambled_uncrackable_hard_fails() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.lock_all = true; // every read CSS-locked → ScrambledUncracked
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
let err = resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect_err("scrambled-uncrackable must hard-fail");
|
||||||
|
// The Error::CssKeyMissing flattens into io::Error carrying its E-code
|
||||||
|
// (7023) in the message — assert that specific code survived.
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains(&format!("E{}", crate::error::E_CSS_KEY_MISSING)),
|
||||||
|
"must surface CssKeyMissing (E{}), got: {err}",
|
||||||
|
crate::error::E_CSS_KEY_MISSING
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||||
|
"keys must stay None on hard-fail (never a scrambled-passthrough key)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `raw` is deliberate ciphertext passthrough: even a scrambled-uncrackable
|
||||||
|
/// title must return `Ok` and leave `keys` untouched (`None`) — no crack, no
|
||||||
|
/// hard-fail. This is the `--raw` guarantee.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_raw_skips_crack_and_never_fails() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.lock_all = true;
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
true, // raw
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("raw must never hard-fail");
|
||||||
|
assert!(
|
||||||
|
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||||
|
"raw must leave keys None (no descramble)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
src.reads.borrow().is_empty(),
|
||||||
|
"raw must not read any sector for a crack"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AACS gate: an MPEG-PS title carrying `Aacs` keys (HD-DVD `.evo`) must be
|
||||||
|
/// left untouched — resolve only fires on `None` keys, never overwriting a
|
||||||
|
/// real key set or cracking AACS ciphertext as CSS.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_leaves_aacs_untouched() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.lock_all = true; // would hard-fail IF it ran the crack
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, [0u8; 16])],
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::MpegPs,
|
||||||
|
};
|
||||||
|
resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("AACS title must be left untouched, not cracked");
|
||||||
|
assert!(
|
||||||
|
matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }),
|
||||||
|
"Aacs keys must survive unchanged"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
src.reads.borrow().is_empty(),
|
||||||
|
"must not read for a crack when keys are already Aacs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear DVD: a `None`-keyed MPEG-PS title with no scrambled sector stays
|
||||||
|
/// `None` (a mux no-op) and returns `Ok` — genuinely-unencrypted DVDs pass.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_clear_dvd_stays_none() {
|
||||||
|
let mut src = MockSource::new(0x00); // all-clear sectors
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("clear DVD passes");
|
||||||
|
assert!(
|
||||||
|
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||||
|
"a clear DVD must keep None keys"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cancelled crack (user Stop mid-scan) must surface as `Halted`, NOT be
|
||||||
|
/// misread from the truncated scan as `Unencrypted` (→ scrambled passthrough,
|
||||||
|
/// corruption) or `ScrambledUncracked` (→ CssKeyMissing, which quarantines a
|
||||||
|
/// good disc). This pins the halt-outcome fix.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_halt_surfaces_as_halted_not_a_verdict() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.lock_all = true; // without the halt guard this would be ScrambledUncracked
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let halt = crate::halt::Halt::new();
|
||||||
|
halt.cancel(); // Stop already pressed
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
let err = resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
Some(&halt),
|
||||||
|
)
|
||||||
|
.expect_err("a cancelled crack must return an error");
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains(&format!("E{}", crate::error::E_HALTED)),
|
||||||
|
"cancelled crack must surface Halted (E{}), got: {err}",
|
||||||
|
crate::error::E_HALTED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
|
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
|
||||||
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
|
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
|
||||||
/// real encrypted-but-unauthenticated disc image does) must produce the exact
|
/// real encrypted-but-unauthenticated disc image does) must produce the exact
|
||||||
|
|||||||
+51
-119
@@ -1611,86 +1611,20 @@ impl Disc {
|
|||||||
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
|
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
|
||||||
session.set_speed(0xFFFF);
|
session.set_speed(0xFFFF);
|
||||||
|
|
||||||
// Read UDF filesystem with buffered sector reader
|
// CSS bus-auth unlock — run BEFORE any scrambled-sector read.
|
||||||
tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem");
|
// On a CSS-enforcing drive (e.g. the BU40N) the UDF metadata prefetch
|
||||||
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
// below reaches small/menu VOB extents that are themselves CSS-scrambled;
|
||||||
tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read");
|
// without the bus-auth handshake first, each of those reads is rejected
|
||||||
|
// with sense 05/6F/03 ("read of scrambled sector without authentication")
|
||||||
// Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv).
|
// after a full drive round-trip — ~13s of pure waste on a real disc, and
|
||||||
// Without this, each read_file() triggers individual SCSI commands at 500ms each.
|
// the prefetch caches nothing. The handshake needs no title info (it takes
|
||||||
if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) {
|
// no extents), is self-guarding to DVD media, and is non-fatal on failure,
|
||||||
buffered.prefetch_ranges(&ranges);
|
// so run it as soon as the drive has classified the disc as a DVD. The
|
||||||
}
|
// per-VTS title key is NOT recovered here (or anywhere in scan) — it is
|
||||||
|
// cracked keylessly at read/mux time via `css::resolve_dvd_title_key`;
|
||||||
tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams");
|
// this only unlocks the drive's read gating so those reads can happen.
|
||||||
let mut disc = Self::scan_with(
|
if session.disc_is_dvd() {
|
||||||
&mut buffered,
|
tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)");
|
||||||
capacity,
|
|
||||||
handshake,
|
|
||||||
handshake_error,
|
|
||||||
opts,
|
|
||||||
udf_fs,
|
|
||||||
)?;
|
|
||||||
tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed");
|
|
||||||
|
|
||||||
// CSS key extraction for DVDs (bus auth → disc key → title key).
|
|
||||||
// Must be a single auth session — can't call authenticate() separately.
|
|
||||||
// Route through the DRM dispatcher: probe a title sector, detect
|
|
||||||
// CSS if scrambled, then load via the SCSI auth path.
|
|
||||||
// We already know this is a DVD (MPEG-PS program stream), so drive the
|
|
||||||
// CSS handshake DIRECTLY off the main title's first content sector. We
|
|
||||||
// must NOT first read a scrambled sector to "detect" CSS: a drive that
|
|
||||||
// enforces CSS (e.g. the BU40N) rejects an UNauthenticated read of a
|
|
||||||
// scrambled sector with sense 05/6F/03 ("read of scrambled sector
|
|
||||||
// without authentication"), so a detect-then-auth ordering dead-locks —
|
|
||||||
// detection needs the read, the read needs auth, auth needs detection.
|
|
||||||
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD
|
|
||||||
// the disc-key read fails, `resolve` returns None, and the disc is left
|
|
||||||
// in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
|
|
||||||
// `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
|
|
||||||
// not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
|
|
||||||
// through the AACS path above). BD/UHD are MPEG-TS and never reach here.
|
|
||||||
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
|
|
||||||
// CSS title keys are per-VTS, and ONLY the scrambled movie content
|
|
||||||
// carries a non-zero key. Menu / VMG / logo cells (often the
|
|
||||||
// low-LBA first extent) return a ZERO title key over REPORT KEY —
|
|
||||||
// accepting that would leave the whole feature un-descrambled
|
|
||||||
// (raw scrambled bytes passed through as "clear"). So build
|
|
||||||
// candidate LBAs from the MAIN feature (largest title), LARGEST
|
|
||||||
// extent first (the movie body is the biggest scrambled chunk),
|
|
||||||
// and accept the first auth that yields a NON-ZERO title key. A
|
|
||||||
// genuinely unencrypted DVD returns zero for every candidate →
|
|
||||||
// disc stays in the clear. This block is DVD-only (MPEG-PS);
|
|
||||||
// BD/UHD (MPEG-TS) used the AACS handshake above and never reach here.
|
|
||||||
// Main feature = the largest title; its extents, largest (the movie
|
|
||||||
// body) first — that's where the scrambled content with a recoverable
|
|
||||||
// title key lives.
|
|
||||||
let main_extents = match disc
|
|
||||||
.titles
|
|
||||||
.iter()
|
|
||||||
.filter(|t| !t.extents.is_empty())
|
|
||||||
.max_by_key(|t| t.extents.iter().map(|e| e.sector_count as u64).sum::<u64>())
|
|
||||||
{
|
|
||||||
Some(t) => {
|
|
||||||
let mut v = t.extents.clone();
|
|
||||||
v.sort_by(|a, b| b.sector_count.cmp(&a.sector_count));
|
|
||||||
v
|
|
||||||
}
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
tracing::info!(target: "freemkv::scan", extents = main_extents.len(), "phase: CSS — main feature located");
|
|
||||||
if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) {
|
|
||||||
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
|
|
||||||
// Unlock the drive's CSS read gating through the uniform
|
|
||||||
// unlocker dispatch: the CSS unlocker matches DiscKind::Css and
|
|
||||||
// runs the bus-auth handshake (self-guarding to DVD media). A
|
|
||||||
// CSS-enforcing drive (the BU40N) refuses to return scrambled
|
|
||||||
// sectors until that handshake has run; we run it purely for that
|
|
||||||
// unlock and IGNORE any key (the descramble key is recovered
|
|
||||||
// keylessly from the scrambled movie data via the known-plaintext
|
|
||||||
// attack — no player keys, no disc-key crack, no REPORT-KEY title
|
|
||||||
// key). Any failure is non-fatal: continue to the crack, which
|
|
||||||
// simply finds nothing if the drive kept the sectors gated.
|
|
||||||
let drive_id = session.drive_id.clone();
|
let drive_id = session.drive_id.clone();
|
||||||
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
|
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
|
||||||
session.scsi_mut(),
|
session.scsi_mut(),
|
||||||
@@ -1705,47 +1639,45 @@ impl Disc {
|
|||||||
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Size the crack's batch reads to THIS drive's per-command max
|
|
||||||
// (DVD ≈ 16; the USB bridge may be lower) — an over-large
|
|
||||||
// READ(10) fails outright and would scan nothing.
|
|
||||||
let crack_batch = detect_max_batch_sectors(session.device_path());
|
|
||||||
tracing::info!(target: "freemkv::scan", crack_batch, "phase: CSS — known-plaintext crack");
|
|
||||||
let crack_t0 = std::time::Instant::now();
|
|
||||||
let crack_result = crate::css::crack_key_outcome(
|
|
||||||
session,
|
|
||||||
&main_extents,
|
|
||||||
crack_batch,
|
|
||||||
opts.halt.as_ref(),
|
|
||||||
);
|
|
||||||
tracing::info!(
|
|
||||||
target: "freemkv::scan",
|
|
||||||
elapsed_ms = crack_t0.elapsed().as_millis() as u64,
|
|
||||||
outcome = ?crack_result,
|
|
||||||
"phase: CSS — crack done"
|
|
||||||
);
|
|
||||||
match crack_result {
|
|
||||||
crate::css::CrackOutcome::Cracked(state) => {
|
|
||||||
tracing::debug!(target: "freemkv::disc", "dvd css: title key recovered via known-plaintext crack");
|
|
||||||
disc.css = Some(state);
|
|
||||||
disc.encrypted = true;
|
|
||||||
}
|
|
||||||
crate::css::CrackOutcome::ScrambledUncracked => {
|
|
||||||
// Scrambled sectors WERE seen but no key could be
|
|
||||||
// recovered — the content is encrypted-but-uncrackable.
|
|
||||||
// Record a hard error so callers fail loudly instead of
|
|
||||||
// muxing scrambled MPEG as plaintext garbage at exit 0.
|
|
||||||
tracing::warn!(target: "freemkv::disc", "dvd css: scrambled sectors seen but no title key cracked");
|
|
||||||
disc.encrypted = true;
|
|
||||||
disc.css_error = Some(crate::error::Error::CssKeyMissing);
|
|
||||||
}
|
|
||||||
crate::css::CrackOutcome::Unencrypted => {
|
|
||||||
tracing::debug!(target: "freemkv::disc", "dvd css: no scrambled sector seen (genuinely unencrypted)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(target: "freemkv::scan", css = disc.css.is_some(), "phase: scan complete");
|
// Read UDF filesystem with buffered sector reader
|
||||||
|
tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem");
|
||||||
|
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
||||||
|
tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read");
|
||||||
|
|
||||||
|
// Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv).
|
||||||
|
// Without this, each read_file() triggers individual SCSI commands at 500ms each.
|
||||||
|
if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) {
|
||||||
|
buffered.prefetch_ranges(&ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams");
|
||||||
|
let disc = Self::scan_with(
|
||||||
|
&mut buffered,
|
||||||
|
capacity,
|
||||||
|
handshake,
|
||||||
|
handshake_error,
|
||||||
|
opts,
|
||||||
|
udf_fs,
|
||||||
|
)?;
|
||||||
|
tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed");
|
||||||
|
|
||||||
|
// No CSS key recovery at scan time. DVD CSS keys are per-VTS/per-title,
|
||||||
|
// and the descramble path re-cracks each title's key keylessly from that
|
||||||
|
// title's own extents at the moment its sectors are read/decrypted — so a
|
||||||
|
// single up-front "disc key" is meaningless (it's not valid for the other
|
||||||
|
// titles and every title re-derives its key at read time anyway). The
|
||||||
|
// scan's only CSS responsibility is the bus-auth unlock above, which opens
|
||||||
|
// the drive's read gating so the sweep can read scrambled sectors at all.
|
||||||
|
// Detection is likewise moot: encrypted or not, a DVD muxes identically —
|
||||||
|
// the read-time descrambler cracks a title key if the sectors are
|
||||||
|
// scrambled and is a no-op if they are clear.
|
||||||
|
|
||||||
|
// `disc.css` is intentionally never set at scan time now (per-title CSS
|
||||||
|
// keys are cracked at read/mux time), so log the format the scan actually
|
||||||
|
// determined rather than a key state that is always `None` here.
|
||||||
|
tracing::info!(target: "freemkv::scan", format = ?disc.format, titles = disc.titles.len(), "phase: scan complete");
|
||||||
Ok(disc)
|
Ok(disc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,10 +178,9 @@ impl CodecParser for PassthroughParser {
|
|||||||
/// until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the
|
/// until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the
|
||||||
/// ResyncGate) and lets the decoder conceal — a fundamentally different model
|
/// ResyncGate) and lets the decoder conceal — a fundamentally different model
|
||||||
/// than per-frame audio dropping.
|
/// than per-frame audio dropping.
|
||||||
/// - TrueHD/MLP and the rare passthrough audio codecs (FLAC/MP2/AAC) do not yet
|
/// - TrueHD/MLP, FLAC, MP2/MP3 and AAC-ADTS also gate undecodable frames via a
|
||||||
/// gate: MLP carries inter-AU restart state so a safe drop must land on a
|
/// `DropTally` (poison/drop-forward for MLP's inter-AU restart state on a
|
||||||
/// major-sync boundary, and the passthrough codecs are essentially never seen
|
/// major-sync boundary; CRC/sync-verdict drops for the passthrough codecs).
|
||||||
/// on optical media.
|
|
||||||
///
|
///
|
||||||
/// Create the appropriate parser for a codec, with optional codec private data.
|
/// Create the appropriate parser for a codec, with optional codec private data.
|
||||||
///
|
///
|
||||||
|
|||||||
+138
-12
@@ -209,14 +209,34 @@ impl DiscStream {
|
|||||||
/// The caller opens the source, scans for titles/keys, and passes them in.
|
/// The caller opens the source, scans for titles/keys, and passes them in.
|
||||||
/// The stream handles demuxing, decryption, and codec parsing internally.
|
/// The stream handles demuxing, decryption, and codec parsing internally.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
reader: Box<dyn SectorSource>,
|
mut reader: Box<dyn SectorSource>,
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
mut decrypt_keys: crate::decrypt::DecryptKeys,
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
content_format: crate::disc::ContentFormat,
|
content_format: crate::disc::ContentFormat,
|
||||||
) -> Self {
|
raw: bool,
|
||||||
|
halt: Option<Halt>,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
let mut title = title;
|
let mut title = title;
|
||||||
let extents = title.extents.clone();
|
let extents = title.extents.clone();
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
crate::css::resolve_dvd_title_key(
|
||||||
|
&mut *reader,
|
||||||
|
&extents,
|
||||||
|
&mut decrypt_keys,
|
||||||
|
batch_sectors,
|
||||||
|
content_format,
|
||||||
|
raw,
|
||||||
|
halt.as_ref(),
|
||||||
|
)?;
|
||||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||||
|
|
||||||
// Debug log reader type at construction — critical for diagnosing mux
|
// Debug log reader type at construction — critical for diagnosing mux
|
||||||
@@ -301,7 +321,7 @@ impl DiscStream {
|
|||||||
.map(|_| super::resync::ResyncGate::new())
|
.map(|_| super::resync::ResyncGate::new())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self {
|
Ok(Self {
|
||||||
reader,
|
reader,
|
||||||
title,
|
title,
|
||||||
decrypt_keys,
|
decrypt_keys,
|
||||||
@@ -315,7 +335,7 @@ impl DiscStream {
|
|||||||
errors: 0,
|
errors: 0,
|
||||||
lost_bytes: 0,
|
lost_bytes: 0,
|
||||||
skip_errors: false,
|
skip_errors: false,
|
||||||
halt: None,
|
halt,
|
||||||
event_fn: None,
|
event_fn: None,
|
||||||
eof: false,
|
eof: false,
|
||||||
dropped_nav_packets: 0,
|
dropped_nav_packets: 0,
|
||||||
@@ -330,7 +350,7 @@ impl DiscStream {
|
|||||||
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
||||||
resync,
|
resync,
|
||||||
is_video,
|
is_video,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set event handler for sector-level events (binary search, skip, recover).
|
/// Set event handler for sector-level events (binary search, skip, recover).
|
||||||
@@ -1094,7 +1114,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let mut src: Box<dyn Stream> = Box::new(stream);
|
let mut src: Box<dyn Stream> = Box::new(stream);
|
||||||
|
|
||||||
@@ -1129,7 +1152,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
crate::disc::ContentFormat::BdTs,
|
crate::disc::ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
|
.unwrap()
|
||||||
.with_halt(halt.clone());
|
.with_halt(halt.clone());
|
||||||
assert!(!stream.is_halted());
|
assert!(!stream.is_halted());
|
||||||
halt.cancel();
|
halt.cancel();
|
||||||
@@ -1161,7 +1187,10 @@ mod tests {
|
|||||||
aacs,
|
aacs,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
|
.unwrap()
|
||||||
.with_key_map(std::sync::Arc::new(map));
|
.with_key_map(std::sync::Arc::new(map));
|
||||||
let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum();
|
let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1320,7 +1349,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
|
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
|
||||||
// would return Err — so reaching EOF cleanly proves recovery worked.
|
// would return Err — so reaching EOF cleanly proves recovery worked.
|
||||||
stream.skip_errors = false;
|
stream.skip_errors = false;
|
||||||
@@ -1433,7 +1465,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
|
|
||||||
// Drive fill_extents across batches: the good leading sectors mux fine,
|
// Drive fill_extents across batches: the good leading sectors mux fine,
|
||||||
@@ -1493,7 +1528,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
|
|
||||||
let res = stream.fill_extents();
|
let res = stream.fill_extents();
|
||||||
@@ -1539,7 +1577,16 @@ mod tests {
|
|||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
format: crate::disc::ContentFormat::BdTs,
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
let mut stream = DiscStream::new(
|
||||||
|
Box::new(reader),
|
||||||
|
title,
|
||||||
|
keys,
|
||||||
|
8,
|
||||||
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stream.unit_align, ALIGN as u16,
|
stream.unit_align, ALIGN as u16,
|
||||||
@@ -1635,7 +1682,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
|
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
|
||||||
|
|
||||||
@@ -1676,7 +1726,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
crate::disc::ContentFormat::BdTs,
|
crate::disc::ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
|
.unwrap()
|
||||||
.with_halt(Halt::from_arc(arc.clone()));
|
.with_halt(Halt::from_arc(arc.clone()));
|
||||||
assert!(!stream.is_halted());
|
assert!(!stream.is_halted());
|
||||||
arc.store(true, std::sync::atomic::Ordering::Relaxed);
|
arc.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
@@ -1685,4 +1738,77 @@ mod tests {
|
|||||||
"with_halt(Halt::from_arc) must observe Arc-side flips"
|
"with_halt(Halt::from_arc) must observe Arc-side flips"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every read fails CSS-locked (`05/6F/03`) — a scrambled DVD whose title key
|
||||||
|
/// can't be cracked. Drives `resolve_dvd_title_key` to `ScrambledUncracked`.
|
||||||
|
struct LockedReader;
|
||||||
|
impl crate::sector::SectorSource for LockedReader {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
lba: u32,
|
||||||
|
_count: u16,
|
||||||
|
_buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> crate::error::Result<usize> {
|
||||||
|
Err(crate::error::Error::DiscRead {
|
||||||
|
sector: lba as u64,
|
||||||
|
status: Some(2),
|
||||||
|
sense: Some(crate::scsi::ScsiSense {
|
||||||
|
sense_key: 0x05,
|
||||||
|
asc: 0x6F,
|
||||||
|
ascq: 0x03,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mpegps_title(sector_count: u32) -> DiscTitle {
|
||||||
|
let mut t = synthetic_title(sector_count);
|
||||||
|
t.content_format = ContentFormat::MpegPs;
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PARITY with `build_iso_pipeline_dvd_none_keys_scrambled_hard_fails`: the
|
||||||
|
/// live-drive single-pass constructor must ALSO hard-fail (not build a
|
||||||
|
/// scrambled-passthrough stream) for a `None`-keyed scrambled MPEG-PS DVD —
|
||||||
|
/// the exact 328k-decode-error corruption path, on the single-pass side.
|
||||||
|
#[test]
|
||||||
|
fn disc_stream_new_dvd_none_scrambled_hard_fails() {
|
||||||
|
let res = DiscStream::new(
|
||||||
|
Box::new(LockedReader),
|
||||||
|
mpegps_title(8),
|
||||||
|
crate::decrypt::DecryptKeys::None,
|
||||||
|
8,
|
||||||
|
ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"single-pass DiscStream must hard-fail on a scrambled, keyless CSS DVD"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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.
|
||||||
|
#[test]
|
||||||
|
fn disc_stream_new_raw_bypasses_css_crack() {
|
||||||
|
let res = DiscStream::new(
|
||||||
|
Box::new(LockedReader),
|
||||||
|
mpegps_title(8),
|
||||||
|
crate::decrypt::DecryptKeys::None,
|
||||||
|
8,
|
||||||
|
ContentFormat::MpegPs,
|
||||||
|
true, // raw
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
res.is_ok(),
|
||||||
|
"raw single-pass must construct without cracking, even on scrambled-uncrackable input"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-29
@@ -403,38 +403,29 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
}
|
}
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
// Per-title key resolution. For a multi-VTS CSS DVD the scan's
|
// Per-title key resolution. DVD CSS is resolved at exactly ONE site —
|
||||||
// single cracked key only descrambles its own VTS; re-crack from
|
// `build_iso_pipeline`'s per-title crack (below), which decrypts a
|
||||||
// the chosen title's extents if it lives elsewhere. A fresh reader
|
// crackable title, passes a genuinely-clear one through, and
|
||||||
// avoids disturbing the mux reader below. 64 sectors is a
|
// hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do
|
||||||
// file-safe batch for an ISO. AACS / single-VTS paths are
|
// NOT pre-crack here: pass `None` and let the pipeline own it.
|
||||||
// unchanged (decrypt_keys_for_title short-circuits to decrypt_keys).
|
// Pre-cracking would re-open the ISO and re-scan every clear title
|
||||||
//
|
// (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway).
|
||||||
// Only a DVD needs this fresh reader (its per-title crack reads the
|
// AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw`
|
||||||
// title's sectors); AACS / unencrypted resolve their keys from
|
// (any format) is deliberate ciphertext passthrough — also `None`.
|
||||||
// `decrypt_keys()` with NO read, so we must not open — and fail on —
|
let is_dvd = disc.format == crate::disc::DiscFormat::Dvd;
|
||||||
// a probe handle for them (v1.5.1 tolerated an open blip on non-DVDs).
|
let (keys, title_is_clear) = if opts.raw || is_dvd {
|
||||||
// For a DVD the reader IS required, so a failed open is PROPAGATED as
|
(crate::decrypt::DecryptKeys::None, false)
|
||||||
// a real, loud, retryable I/O error — never guessed into a
|
|
||||||
// `title_is_clear` verdict: guessing `true` would mux a
|
|
||||||
// detection-miss scrambled DVD keyless (silent garbage); guessing
|
|
||||||
// `false` would falsely hard-fail an unencrypted DVD.
|
|
||||||
let (keys, title_is_clear) = if disc.format == crate::disc::DiscFormat::Dvd {
|
|
||||||
let mut crack_reader = crate::io::file_sector_source::FileSectorSource::open(path)
|
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
|
||||||
disc.decrypt_keys_for_title(idx, &mut crack_reader, 64)
|
|
||||||
} else {
|
} else {
|
||||||
(disc.decrypt_keys(), false)
|
(disc.decrypt_keys(), false)
|
||||||
};
|
};
|
||||||
// Per-title decrypt gate (parallel to the disc-wide gate above): on
|
// Decrypt gate for the AACS / non-DVD path: a None key means no usable
|
||||||
// a multi-VTS CSS disc, the per-title re-crack may return `None` when
|
// disc key, which would mux scrambled ciphertext verbatim — fail loudly
|
||||||
// the chosen title's VTS could not be re-cracked. Muxing that would
|
// (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its
|
||||||
// emit scrambled ciphertext verbatim, so fail loudly here — EXCEPT
|
// CSS hard-fail), and `--raw` passes.
|
||||||
// when the title proved genuinely clear (`title_is_clear`), an
|
if !is_dvd {
|
||||||
// unencrypted stub on an otherwise-CSS disc that needs no key. That
|
|
||||||
// case must NOT raise a false E7023.
|
|
||||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
.map_err(|e| -> io::Error { e.into() })?;
|
||||||
|
}
|
||||||
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
|
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
|
||||||
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
|
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
|
||||||
// the key-fetch closure and can actually attempt resolution. (An older
|
// the key-fetch closure and can actually attempt resolution. (An older
|
||||||
@@ -497,6 +488,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
effective_keys,
|
effective_keys,
|
||||||
ISO_MUX_BATCH_SECTORS,
|
ISO_MUX_BATCH_SECTORS,
|
||||||
format,
|
format,
|
||||||
|
opts.raw,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
fetch,
|
fetch,
|
||||||
@@ -1132,13 +1124,17 @@ pub fn resolve_mux_key_map(
|
|||||||
/// - `batch_sectors`: read batch size in logical (2048-byte) sectors — a
|
/// - `batch_sectors`: read batch size in logical (2048-byte) sectors — a
|
||||||
/// throughput/latency tuning knob, not a correctness parameter.
|
/// throughput/latency tuning knob, not a correctness parameter.
|
||||||
/// - `format`: container format (`BdTs` → TS demuxer, `MpegPs` → PS demuxer).
|
/// - `format`: container format (`BdTs` → TS demuxer, `MpegPs` → PS demuxer).
|
||||||
|
/// - `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.
|
||||||
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
||||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
/// pipeline stops at the next boundary (and the CSS crack surfaces `Halted`).
|
||||||
|
/// `None` disables cancellation.
|
||||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||||
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
|
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
|
||||||
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
|
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
|
||||||
/// callback: the map decides the key for every LBA before the read loop starts.
|
/// callback: the map decides the key for every LBA before the read loop starts.
|
||||||
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
// Nine reader/title/keys/tuning/callback params is inherent to the mux entry
|
||||||
// point; grouping them into a struct would only move the same fields around.
|
// point; grouping them into a struct would only move the same fields around.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||||
@@ -1147,11 +1143,27 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
mut keys: crate::decrypt::DecryptKeys,
|
mut keys: crate::decrypt::DecryptKeys,
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
format: ContentFormat,
|
format: ContentFormat,
|
||||||
|
raw: bool,
|
||||||
halt: Option<crate::halt::Halt>,
|
halt: Option<crate::halt::Halt>,
|
||||||
event_fn: Option<crate::sector::prefetched::EventFn>,
|
event_fn: Option<crate::sector::prefetched::EventFn>,
|
||||||
fetch: Option<crate::sector::KeyFetch>,
|
fetch: Option<crate::sector::KeyFetch>,
|
||||||
) -> io::Result<PipelinedPesStream> {
|
) -> io::Result<PipelinedPesStream> {
|
||||||
let extents = title.extents.clone();
|
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.
|
||||||
|
crate::css::resolve_dvd_title_key(
|
||||||
|
&mut reader,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
batch_sectors,
|
||||||
|
format,
|
||||||
|
raw,
|
||||||
|
halt.as_ref(),
|
||||||
|
)?;
|
||||||
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
|
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
|
||||||
// units, so the producer must hand the decrypt step 3-sector-aligned batches.
|
// units, so the producer must hand the decrypt step 3-sector-aligned batches.
|
||||||
// CSS (DVD) and unencrypted content decrypt per 2048-byte sector — forcing
|
// CSS (DVD) and unencrypted content decrypt per 2048-byte sector — forcing
|
||||||
@@ -1714,6 +1726,7 @@ mod tests {
|
|||||||
DecryptKeys::None,
|
DecryptKeys::None,
|
||||||
8192,
|
8192,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -1755,6 +1768,7 @@ mod tests {
|
|||||||
DecryptKeys::None,
|
DecryptKeys::None,
|
||||||
8192,
|
8192,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -1807,10 +1821,56 @@ mod tests {
|
|||||||
DecryptKeys::None,
|
DecryptKeys::None,
|
||||||
0,
|
0,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
assert!(res.is_err(), "zero batch_sectors must be rejected");
|
assert!(res.is_err(), "zero batch_sectors must be rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// REGRESSION (autorip production corruption): `build_iso_pipeline` for a DVD
|
||||||
|
/// (MPEG-PS) with `None` keys — what autorip's mux passes on a detection-miss
|
||||||
|
/// DVD (`disc.decrypt_keys()` == None) — must resolve the CSS key from the
|
||||||
|
/// reader itself. A scrambled-but-uncrackable title must HARD-FAIL, never
|
||||||
|
/// build a passthrough pipeline that muxes the scrambled sectors as corrupt
|
||||||
|
/// video. Before this fix, autorip handed None straight through and the mux
|
||||||
|
/// wrote garbage at exit 0.
|
||||||
|
#[test]
|
||||||
|
fn build_iso_pipeline_dvd_none_keys_scrambled_hard_fails() {
|
||||||
|
// One CSS-scrambled, crib-less (uncrackable) MPEG-PS sector.
|
||||||
|
let key = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let mut sec = vec![0u8; 2048];
|
||||||
|
sec[0..4].copy_from_slice(&crate::css::PACK_START);
|
||||||
|
for (i, b) in sec.iter_mut().enumerate().take(0x80).skip(4) {
|
||||||
|
*b = (i as u8).wrapping_mul(7).wrapping_add(1); // non-repeating → no crib
|
||||||
|
}
|
||||||
|
sec[0x14] = 0x10; // scramble flag
|
||||||
|
for (i, b) in sec.iter_mut().enumerate().skip(0x80) {
|
||||||
|
*b = (i as u8) ^ 0x3C;
|
||||||
|
}
|
||||||
|
crate::css::lfsr::scramble_sector(&key, &mut sec);
|
||||||
|
|
||||||
|
let mut title = aac_audio_title(0x1100);
|
||||||
|
title.extents = vec![Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 1,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let res = build_iso_pipeline(
|
||||||
|
MemSource { data: sec },
|
||||||
|
title,
|
||||||
|
DecryptKeys::None,
|
||||||
|
8192,
|
||||||
|
ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"a scrambled DVD title with no key must hard-fail, not build a scrambled-passthrough pipeline"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
|
|||||||
DecryptKeys::None,
|
DecryptKeys::None,
|
||||||
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
|
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
|
||||||
ContentFormat::MpegPs,
|
ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -136,7 +136,16 @@ fn test_bytes_read_emitted_during_disc_copy() {
|
|||||||
let title = synthetic_title(64);
|
let title = synthetic_title(64);
|
||||||
let keys = libfreemkv::DecryptKeys::None;
|
let keys = libfreemkv::DecryptKeys::None;
|
||||||
|
|
||||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
let mut stream = DiscStream::new(
|
||||||
|
Box::new(reader),
|
||||||
|
title,
|
||||||
|
keys,
|
||||||
|
60,
|
||||||
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let count = Arc::new(AtomicU64::new(0));
|
let count = Arc::new(AtomicU64::new(0));
|
||||||
let count_cb = count.clone();
|
let count_cb = count.clone();
|
||||||
@@ -286,7 +295,16 @@ fn test_drop_impls_do_not_panic_or_block() {
|
|||||||
let reader = ZeroSectorReader::new(64);
|
let reader = ZeroSectorReader::new(64);
|
||||||
let title = synthetic_title(64);
|
let title = synthetic_title(64);
|
||||||
let keys = libfreemkv::DecryptKeys::None;
|
let keys = libfreemkv::DecryptKeys::None;
|
||||||
let stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
let stream = DiscStream::new(
|
||||||
|
Box::new(reader),
|
||||||
|
title,
|
||||||
|
keys,
|
||||||
|
60,
|
||||||
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Drop on a worker thread; main thread enforces the timeout.
|
// Drop on a worker thread; main thread enforces the timeout.
|
||||||
let handle = std::thread::spawn(move || {
|
let handle = std::thread::spawn(move || {
|
||||||
|
|||||||
Reference in New Issue
Block a user