Files
libfreemkv/src/disc/extract.rs
T
Matthew Jackson 0e8c31a9c3 Round 3: fix three defects introduced by the round-2 fixes
Auditing my own fixes found all three. None were in the original code.

The VTS crack sort was byte-wise case-SENSITIVE while the filters that select
those files (vts_group_of / is_title_vob) are case-insensitive. On a
case-sensitive volume a set holding vts_01_1.vob beside VTS_01_2.VOB sorted
part 2 first, because V (0x56) precedes v (0x76) — reintroducing exactly the
budget-exhaustion the ordering exists to prevent. Now sorted on the same
uppercase normalisation the filters apply.

Refusing a name that round-trips to empty aborted the WHOLE plan. A sidecar
folder named with a single emoji made a backup un-rippable that 1.6.0 handled
fine, and reported it as a collision with a file that does not exist. It is now
skipped with a warning: the entry is unaddressable either way, but one
irrelevant file should not cost the user their rip.

The mtime check now applies only to files whose CONTENT the plan read — the
IFOs, whose bytes 0xC0/0xC4 place every VOB. Everything else is planned from
size alone, which is already checked, so comparing mtime there bought nothing
and risked a real false positive: disc backups commonly live on exFAT/FAT32,
which stores local time, so a long rip spanning a DST transition would see a
whole-hour shift on an untouched multi-gigabyte VOB and abort hours in.
2026-08-06 08:25:54 -07:00

2235 lines
91 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `Disc::extract_tree` — decrypted file-tree extraction (`dir://`).
//!
//! Sibling of the disc→ISO sector dump (the sweep/patch recovery passes, which
//! now live in the `freemkv-engine` crate),
//! specialized to write **per file** rather than a whole image, applying
//! decryption on the way out, and **without** any multipass / recovery
//! orchestration. 1-shot, decrypt-only.
//!
//! The filesystem enumeration and decryption are entirely reused:
//! [`udf::read_filesystem`] yields the recursive [`UdfFs`] tree (BD and DVD
//! alike — DVD-Video is a UDF bridge volume), and
//! [`DecryptingSectorSource`](crate::sector::DecryptingSectorSource) applies
//! AACS / CSS in-place. This module is the focused per-file producer: tree
//! walk, host-path mapping + per-component sanitization, per-VTS CSS key
//! grouping, decrypt-and-stream-to-disk, `AACS/` strip, sparse-gap handling,
//! truncate-to-size, and `.partial` + rename. No new error codes — it reuses
//! the existing `Error` set (`error.rs` is numeric-only, no English).
use super::Disc;
use crate::decrypt::DecryptKeys;
use crate::error::{Error, Result};
use crate::sector::{DecryptingSectorSource, SectorSource};
use crate::udf::{self, DirEntry, UdfFs};
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
/// multiples of this so the decrypt step always sees whole units.
const AACS_UNIT_SECTORS: u32 = 3;
/// Read batch in sectors for content streaming (a throughput knob, not a
/// correctness one). A multiple of 3 so AACS units stay whole.
const READ_BATCH_SECTORS: u32 = 1536; // 3 MiB, multiple of 3
/// Bounded per-extent retries on a read that fails before a recorded hole.
const READ_RETRIES: u32 = 3;
/// Options for [`Disc::extract_tree`].
#[derive(Default)]
pub struct ExtractOptions<'a> {
/// Overwrite into a non-empty destination directory. Without it a
/// non-empty target is refused (mixing two discs' trees).
pub force: bool,
/// Optional progress sink. `report` returning `false` requests an early
/// stop (the run finalizes whatever completed; in-flight files stay
/// `.partial`).
pub progress: Option<&'a dyn crate::progress::Progress>,
/// Cooperative cancel token. When cancelled (e.g. the CLI bridges its
/// SIGINT flag here), the run stops at the next file / batch boundary and
/// the in-flight file is left as `.partial` — never a half-written file
/// that looks complete. `None` disables cancellation.
pub halt: Option<crate::halt::Halt>,
}
impl ExtractOptions<'_> {
/// Whether the caller asked to stop (halt cancelled or a progress sink
/// returned `false` on its last report).
fn cancelled(&self, progress_continue: bool) -> bool {
!progress_continue || self.halt.as_ref().is_some_and(|h| h.is_cancelled())
}
}
/// Per-file extraction outcome.
#[derive(Debug, Clone)]
pub struct FileResult {
/// Host-relative path (the mirrored disc path, sanitized).
pub path: PathBuf,
/// Bytes written that decrypted cleanly.
pub bytes_good: u64,
/// Bytes lost — unreadable sectors AND undecryptable units both land here
/// (extract fails a bad decrypt loud, so it is zero-filled like a bad sector).
pub bytes_unreadable: u64,
/// True when the file was fully written (renamed from `.partial`).
pub complete: bool,
}
/// Aggregate result of an [`extract_tree`](Disc::extract_tree) run.
#[derive(Debug, Clone, Default)]
pub struct ExtractResult {
/// Per-file results, in extraction order.
pub files: Vec<FileResult>,
/// Aggregate good bytes across all files.
pub bytes_good: u64,
/// Aggregate lost bytes — bad sectors AND undecryptable units (one bucket).
pub bytes_unreadable: u64,
/// True when every file completed and no loss was recorded.
pub complete: bool,
/// True when the run stopped early on an interrupt / progress halt.
pub halted: bool,
}
impl ExtractResult {
/// Total bytes lost. A non-zero value means the extraction is holed; the CLI
/// exits non-zero so a script can re-run through the `iso://` multipass path.
pub fn bytes_lost(&self) -> u64 {
self.bytes_unreadable
}
}
/// One file scheduled for extraction, resolved against the raw reader in the
/// structure phase (before the reader is moved into the decrypting decorator).
struct PlannedFile {
/// Host-relative path (sanitized, collision-checked).
host_rel: PathBuf,
/// Disc path components (for VTS grouping / diagnostics).
disc_name: String,
/// Declared file size in bytes (trim target).
size: u64,
/// Inline (ICB-embedded) data, if any. When `Some`, `extents` is empty.
inline: Option<Vec<u8>>,
/// Absolute disc extents `(abs_lba, byte_len)`.
extents: Vec<(u32, u32)>,
}
impl Disc {
/// Extract this disc's **decrypted file tree** to `dest`. 1-shot,
/// decrypt-only, no recovery loop.
///
/// `reader` is consumed for content reads (it is moved into a
/// [`DecryptingSectorSource`]); the structure + extent metadata are read
/// first with the raw reader. `dest` receives the tree STRAIGHT IN (no
/// auto-named subfolder). The caller must have run the pre-flight decrypt
/// gate ([`ensure_decryptable`](Disc::ensure_decryptable)); this method
/// resolves per-VTS CSS keys itself for DVD.
///
/// Bad sectors become recorded zero-filled holes in the affected file
/// (the run does not abort); undecryptable units are counted as loss.
/// Files are written `<name>.partial` and renamed on success, so an
/// interrupted run never leaves a half-written file that looks complete.
pub fn extract_tree(
&self,
reader: &mut dyn SectorSource,
dest: &Path,
opts: &ExtractOptions,
) -> Result<ExtractResult> {
// ── Output dir policy (pre-flight, before any read) ──────────────
std::fs::create_dir_all(dest).map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
if !opts.force && dir_is_non_empty(dest) {
return Err(Error::DirNotEmpty);
}
// ── Phase 1: read the FS structure + all file extents (raw) ──────
let fs = udf::read_filesystem(reader)?;
let mut planned: Vec<PlannedFile> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
std::collections::HashMap::new();
plan_tree(
reader,
&fs,
&fs.root,
Path::new(""),
"",
true,
&mut planned,
&mut dirs,
&mut seen_hosts,
)?;
// Free-space pre-check: refuse up front if the tree won't fit, before
// writing a single file (best-effort; only enforced where the platform
// exposes free space).
let required: u64 = planned
.iter()
.map(|p| p.size)
.fold(0u64, |a, b| a.saturating_add(b));
if let Some(available) = available_space(dest)
&& available < required
{
return Err(Error::DirInsufficientSpace {
required,
available,
});
}
// Create directories up-front so a leaf write never races a missing
// parent. The root itself already exists (create_dir_all above).
for d in &dirs {
let abs = dest.join(d);
std::fs::create_dir_all(&abs).map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
}
// Per-VTS CSS key map (DVD only): "VTS_xx" -> DecryptKeys. Built lazily
// when a scrambled VOB group needs it. AACS / None discs keep the
// disc-wide keys for every file.
let mut base_keys = self.decrypt_keys();
// AACS key map for the extract, chosen by CPS-unit count:
//
// * SINGLE CPS (the overwhelming majority, incl. every single-key UHD): one
// Unit Key opens EVERY encrypted unit on the disc — content in a parsed
// title AND an orphan clip that no playlist references. A blanket key-0
// map over the whole LBA space is exact and covers orphans; clear
// filesystem/nav (encrypted-flag off) passes through untouched.
//
// * MULTI-CPS: each clip is protected by a different Unit Key, so a blanket
// key-0 map would mis-decrypt every secondary-CPS file into garbage
// (silently, since Phase::All is trust-only). Build the EXACT per-CPS
// content map instead (each title's extents → the CPS key that opens a
// real sample from it), up front before the decorator takes the reader. A
// content unit whose key the pool lacks fails loud at resolve (extract has
// no CPS/forensic fetch source), never emits a wrong-key garble.
//
// KNOWN LIMITATION (by design): an orphan encrypted clip on a multi-CPS
// disc — referenced by no playlist, so in no title extent — is in no range
// and passes through as ciphertext. There is no correct key to apply (its
// CPS unit is unknown without a playlist reference), and blind trial-decrypt
// is exactly what this keymap-only model removes. Single-CPS is unaffected
// (the blanket key-0 map above covers orphans).
let key_map =
match &base_keys {
DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => {
Some(std::sync::Arc::new(
crate::decrypt::AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]),
))
}
DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
self.resolve_content_key_map(reader, &mut base_keys, None, opts.halt.as_ref())?,
)),
_ => None,
};
// ── Phase 2: stream each file through the decrypting decorator ────
// The decorator owns its inner source for its lifetime. We hand it a
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
if let Some(map) = key_map {
dec = dec.with_key_map(map);
}
let mut result = ExtractResult::default();
let total_bytes = required;
let mut done_bytes: u64 = 0;
// CSS per-VTS key cache; only consulted for CSS discs.
let is_css = matches!(base_keys, DecryptKeys::Css { .. });
let mut vts_keys: std::collections::HashMap<String, DecryptKeys> =
std::collections::HashMap::new();
for pf in &planned {
if opts.cancelled(true) {
result.halted = true;
break;
}
// Resolve the key for this file. CSS title VOBs need a per-VTS key;
// clear nav (.IFO/.BUP/menu VOB) descrambles as a no-op with any
// key, so the disc-wide key is fine for them too.
if is_css {
if let Some(vts) = vts_group_of(&pf.disc_name) {
let key = match vts_keys.get(&vts) {
Some(k) => k.clone(),
None => {
let k = self.resolve_vts_key(&vts, &planned, &mut dec, &base_keys);
vts_keys.insert(vts.clone(), k.clone());
k
}
};
dec.set_keys(key);
} else {
dec.set_keys(base_keys.clone());
}
}
// A unit that fails to decrypt fails the read loud (extract runs
// non-tolerate), so extract_one_file already zero-filled it and
// counted it in bytes_unreadable — one 'lost' bucket covers both
// media damage and decrypt failure.
let (fr, halted) =
extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?;
result.bytes_good = result.bytes_good.saturating_add(fr.bytes_good);
result.bytes_unreadable = result.bytes_unreadable.saturating_add(fr.bytes_unreadable);
result.files.push(fr);
if halted {
result.halted = true;
break;
}
}
result.complete = !result.halted
&& result.bytes_unreadable == 0
&& result.files.iter().all(|f| f.complete);
Ok(result)
}
/// Resolve the CSS title key for a VTS group by cracking from its title
/// VOB extents (keyless Stevenson attack). The disc-wide key is reused when
/// it already covers this VTS (single-VTS discs, or this VTS's span). The
/// reader is borrowed from the decrypting decorator (its inner source).
fn resolve_vts_key<S: SectorSource>(
&self,
vts: &str,
planned: &[PlannedFile],
dec: &mut DecryptingSectorSource<S>,
base_keys: &DecryptKeys,
) -> DecryptKeys {
// Gather the title VOB extents for this VTS (VTS_xx_1.VOB .. _9.VOB;
// VTS_xx_0.VOB is the menu and is clear, so excluded from the crack).
// Gather this VTS's title VOBs BY NAME, ascending.
//
// `planned` comes from walking the UDF directory, which yields File
// Identifier Descriptors in on-disc order — authoring order, with no
// guarantee it matches playback order. Relying on it would leave the
// crack starting wherever the disc's directory happened to list first.
//
// DVD-Video numbers a title set's VOBs in playback order by spec
// (VTS_xx_1.VOB .. VTS_xx_9.VOB, single digit), so ascending name IS
// playback order and it is deterministic regardless of directory
// layout. That matters because ORDER decides correctness here: see the
// note below on the shared crack budget.
let mut files: Vec<&PlannedFile> = planned
.iter()
.filter(|pf| {
vts_group_of(&pf.disc_name).as_deref() == Some(vts) && is_title_vob(&pf.disc_name)
})
.collect();
// Case-INSENSITIVE, matching `vts_group_of`/`is_title_vob`, which
// selected these files case-insensitively. A byte-wise sort put
// 'V' (0x56) before 'v' (0x76), so a set holding `vts_01_1.vob`
// alongside `VTS_01_2.VOB` — reachable on a case-sensitive volume —
// started the crack at part 2 and could exhaust the budget in a clear
// run, which is the whole failure this ordering exists to prevent.
files.sort_by_key(|f| f.disc_name.to_ascii_uppercase());
let mut extents: Vec<crate::disc::Extent> = Vec::new();
for pf in files {
for &(abs_lba, byte_len) in &pf.extents {
extents.push(crate::disc::Extent {
start_lba: abs_lba,
sector_count: (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32,
});
}
}
if extents.is_empty() {
return base_keys.clone();
}
// PLAYBACK ORDER — do NOT sort. This is the 1.5.1 garbage bug, and it
// grew back here in a new code path: the comment this replaced said it
// "matched the scan heuristic", but that heuristic WAS the bug and was
// already fixed in `Disc::decrypt_keys_for_title`, which documents the
// rule ("PLAYBACK ORDER, never largest-cell-first") and pins it with
// `decrypt_keys_for_title_scans_playback_order_not_largest_first`.
//
// Why order decides correctness: a CSS DVD's biggest cell opens with a
// long CLEAR run, and `crack_key`'s sector budget is shared across the
// whole extent list. Starting at the largest cell can exhaust the budget
// without ever MEETING a scrambled sector — and CSS recovers the title
// key from scrambled data itself, so the scan has to reach some. The
// crack then returns None, this function falls back to `base_keys`, and
// every VOB in the VTS is descrambled with the wrong key: corrupt PES
// behind an intact header, written out as a complete extract at exit 0.
//
// The files above are ordered by name and each file's own extents stay
// in file order, which together is playback order for a DVD title set.
// Crack against the raw (still-scrambled) inner reader, NOT the
// decrypting view — `crack_key` runs the descrambler itself.
match crate::css::crack_key(dec.inner_mut(), &extents, 64) {
Some(state) => DecryptKeys::Css {
title_key: state.title_key,
},
None => base_keys.clone(),
}
}
}
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
/// inner source for its lifetime while the caller keeps the underlying
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
/// generic over `S`, so it does NOT require a `'static` boxed inner — unlike
/// the mux highway, which takes the reader by value). The decorator is dropped
/// before `extract_tree` returns, so the borrow never escapes.
struct Borrowed<'a>(&'a mut dyn SectorSource);
impl SectorSource for Borrowed<'_> {
fn capacity_sectors(&self) -> u32 {
self.0.capacity_sectors()
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
self.0.read_sectors(lba, count, buf, recovery)
}
fn set_speed(&mut self, kbs: u16) {
self.0.set_speed(kbs)
}
fn set_unit_base(&mut self, lba: u32) {
self.0.set_unit_base(lba)
}
}
/// Recursively plan the host tree: collect directories to create and files to
/// extract, sanitizing each component and detecting host-path collisions.
/// Skips the top-level `AACS/` and `CERTIFICATE/` directories (§7).
#[allow(clippy::too_many_arguments)]
fn plan_tree(
reader: &mut dyn SectorSource,
fs: &UdfFs,
dir: &DirEntry,
host_rel: &Path,
disc_path: &str,
is_root: bool,
files: &mut Vec<PlannedFile>,
dirs: &mut Vec<PathBuf>,
seen_hosts: &mut std::collections::HashMap<PathBuf, String>,
) -> Result<()> {
for entry in &dir.entries {
if entry.name.is_empty() {
// The "parent" FID (".") has an empty name — skip.
continue;
}
// Strip AACS / CERTIFICATE at the top level only (a deeper dir of the
// same name, if it ever existed, is content).
if is_root
&& (entry.name.eq_ignore_ascii_case("AACS")
|| entry.name.eq_ignore_ascii_case("CERTIFICATE"))
{
continue;
}
let safe = sanitize_component(&entry.name)?;
let child_rel = host_rel.join(&safe);
let child_disc = format!("{disc_path}/{}", entry.name);
// Collision: two distinct disc paths → same host path.
if let Some(prev) = seen_hosts.insert(child_rel.clone(), child_disc.clone())
&& prev != child_disc
{
return Err(Error::DirNameCollision {
host: child_rel.to_string_lossy().into_owned(),
});
}
if entry.is_dir {
dirs.push(child_rel.clone());
plan_tree(
reader,
fs,
entry,
&child_rel,
&child_disc,
false,
files,
dirs,
seen_hosts,
)?;
} else {
let inline = fs.inline_data_at(reader, entry.meta_lba)?;
let extents = if inline.is_some() {
Vec::new()
} else {
fs.extents_abs_at(reader, entry.meta_lba)?
};
files.push(PlannedFile {
host_rel: child_rel,
disc_name: entry.name.clone(),
size: entry.size,
inline,
extents,
});
}
}
Ok(())
}
/// Extract a single planned file: open `<host>.partial`, stream its extents
/// through the decrypting decorator (bad sectors → recorded zero-filled
/// holes), truncate to the declared size, then rename to the final name.
///
/// Returns `(FileResult, halted)`. When `halted` is true the file was left as
/// `.partial` (incomplete) because the caller cancelled mid-file.
fn extract_one_file<S: SectorSource>(
dec: &mut DecryptingSectorSource<S>,
dest: &Path,
pf: &PlannedFile,
total_bytes: u64,
done_bytes: &mut u64,
opts: &ExtractOptions,
) -> Result<(FileResult, bool)> {
let final_path = dest.join(&pf.host_rel);
let partial_path = with_partial_suffix(&final_path);
let file =
crate::io::WritebackFile::create_with_size_hint(&partial_path, pf.size).map_err(|e| {
Error::DirWriteFailed {
errno: e.raw_os_error(),
}
})?;
let mut writer = file;
let mut fr = FileResult {
path: pf.host_rel.clone(),
bytes_good: 0,
bytes_unreadable: 0,
complete: false,
};
// Inline (ICB-embedded) file: data already in hand, no decrypt path (nav
// files are clear). Write verbatim, trimmed to size.
if let Some(bytes) = &pf.inline {
let n = (pf.size as usize).min(bytes.len());
write_all(&mut writer, &bytes[..n], &partial_path)?;
fr.bytes_good = n as u64;
finalize_file(writer, &partial_path, pf.size, &final_path)?;
fr.complete = true;
*done_bytes = done_bytes.saturating_add(pf.size);
report(opts, *done_bytes, total_bytes);
return Ok((fr, false));
}
let mut written: u64 = 0;
let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_BYTES];
'extents: for &(abs_lba, byte_len) in &pf.extents {
if written >= pf.size {
break;
}
// Anchor AACS unit alignment at THIS extent's start (clip-anchored
// gate, not absolute LBA 0 and NOT the file's first extent). A file
// may span multiple extents (fragmented / Long-AD / continuation ICB
// allocation); each extent's start LBA is arbitrary and generally not
// a multiple of 3 sectors from the previous extent. The decrypt-on-
// read gate (`is_unit_aligned(lba, unit_base)`) measures every read's
// LBA relative to `unit_base`, so the base MUST be re-anchored per
// extent — otherwise the first read of every later extent fails the
// gate and the whole extent is zero-filled as a (false) hole. Matches
// the per-extent re-anchoring in the mux read paths
// (`mux/disc.rs`, `sector/prefetched.rs`). No-op for CSS / None.
dec.set_unit_base(abs_lba);
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
let mut sector_off: u32 = 0;
while sector_off < sectors {
// AACS: read whole units (see `whole_unit_batch`). The tail batch may
// be a 12 sector partial unit, which `decrypt_sectors` handles via
// its trailing-partial contract: a clear partial stays clear (AACS
// leaves the final short unit unencrypted on disc), a scrambled
// partial fails loud as DecryptFailed.
let batch = whole_unit_batch(sectors - sector_off);
let lba = abs_lba + sector_off;
let want = batch as usize * SECTOR_BYTES;
let read_ok = read_batch(dec, lba, batch, &mut buf[..want]);
let chunk_bytes = want as u64;
// Clip the chunk to the remaining file size on the final extent.
let remaining = pf.size.saturating_sub(written);
let usable = chunk_bytes.min(remaining) as usize;
if read_ok {
write_all(&mut writer, &buf[..usable], &partial_path)?;
fr.bytes_good = fr.bytes_good.saturating_add(usable as u64);
} else {
// Bad sector(s): zero-fill this byte range, record the hole,
// keep going (no abort, no sweep-skip).
for b in buf[..usable].iter_mut() {
*b = 0;
}
write_all(&mut writer, &buf[..usable], &partial_path)?;
fr.bytes_unreadable = fr.bytes_unreadable.saturating_add(usable as u64);
}
written = written.saturating_add(usable as u64);
*done_bytes = done_bytes.saturating_add(usable as u64);
let cont = report(opts, *done_bytes, total_bytes);
sector_off += batch;
if opts.cancelled(cont) {
// Leave the `.partial`; do NOT rename. The aggregate run
// records the halt and this file stays incomplete.
return Ok((fr, true));
}
if written >= pf.size {
break 'extents;
}
}
}
// Pad with a zero hole if the extents under-covered the declared size
// (sparse / allocated-not-recorded). The size hint already set the file
// length target; explicit truncate guarantees it.
finalize_file(writer, &partial_path, pf.size, &final_path)?;
fr.complete = true;
Ok((fr, false))
}
/// Size the next FILE-ANCHORED content read in whole AACS units. `remaining` is
/// the sectors left in the current extent; the batch is capped at
/// [`READ_BATCH_SECTORS`] and rounded DOWN to a whole number of 3-sector units
/// UNLESS it is the extent's final (possibly short) tail — the tail always
/// begins on a unit boundary, so a 12 sector partial there is handled by
/// `decrypt_sectors`' trailing-partial contract. Shared by `extract_one_file`
/// (write) and `verify_one_clip` (dead-range) so this rounding rule lives in
/// exactly one place.
fn whole_unit_batch(remaining: u32) -> u32 {
let mut batch = remaining.min(READ_BATCH_SECTORS);
if batch >= AACS_UNIT_SECTORS && batch < remaining {
batch -= batch % AACS_UNIT_SECTORS;
}
batch
}
/// Read one batch through the decrypting decorator with bounded retries.
/// Returns `true` on success, `false` once retries are exhausted (the caller
/// then records a hole). A `DecryptFailed` (unit-alignment / no-key) is NOT
/// retried — it would never succeed; treat it as a content read failure so the
/// range becomes a recorded hole rather than aborting the whole run.
fn read_batch<S: SectorSource>(
dec: &mut DecryptingSectorSource<S>,
lba: u32,
count: u32,
buf: &mut [u8],
) -> bool {
for attempt in 0..=READ_RETRIES {
match dec.read_sectors(lba, count as u16, buf, true) {
Ok(_) => return true,
Err(Error::DecryptFailed) => return false,
Err(_) if attempt < READ_RETRIES => continue,
Err(_) => return false,
}
}
false
}
fn write_all(writer: &mut crate::io::WritebackFile, data: &[u8], path: &Path) -> Result<()> {
writer.write_all(data).map_err(|e| {
let _ = std::fs::remove_file(path);
Error::DirWriteFailed {
errno: e.raw_os_error(),
}
})
}
/// Flush, sync, set the final length, and rename `.partial` → final.
fn finalize_file(
mut writer: crate::io::WritebackFile,
partial: &Path,
size: u64,
final_path: &Path,
) -> Result<()> {
writer.sync_all().map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
drop(writer);
// Set the exact declared length (covers both an over-read final sector and
// an under-covered sparse tail).
let f = std::fs::OpenOptions::new()
.write(true)
.open(partial)
.map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
f.set_len(size).map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
// `set_len` is a separate kernel op on this second handle (opened solely to
// truncate); the content fsync above was on the now-dropped writer handle.
// Without an fsync here, a crash between `set_len` and the rename can leave
// the file at its pre-truncation length — e.g. a sparse/over-read tail keeps
// its oversized form. Sync the new metadata (length) before publishing.
f.sync_all().map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
drop(f);
std::fs::rename(partial, final_path).map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
// Durably commit the new dirent: on POSIX filesystems a crash right after a
// rename can lose the directory entry even though the rename returned. Best
// effort (swallowed on failure); no-op on Windows. Matches `write_atomic`.
if let Some(dir) = final_path.parent() {
crate::io::fsync::dir(dir);
}
Ok(())
}
/// Emit a progress report. Returns `true` to continue, `false` if the sink
/// requested an early stop (or there is no sink — always continue).
fn report(opts: &ExtractOptions, done: u64, total: u64) -> bool {
match opts.progress {
Some(p) => {
let pp = crate::progress::PassProgress {
kind: crate::progress::PassKind::Mux,
work_done: done,
work_total: total,
bytes_good_total: done,
bytes_unreadable_total: 0,
bytes_pending_total: 0,
bytes_retryable_total: 0,
bytes_total_disc: total,
disc_duration_secs: None,
bytes_bad_in_main_title: 0,
main_title_duration_secs: None,
main_title_size_bytes: None,
located: Default::default(),
};
p.report(&pp)
}
None => true,
}
}
/// Append `.partial` to a path's filename.
fn with_partial_suffix(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".partial");
path.with_file_name(name)
}
/// Available free bytes on the filesystem holding `dir`, or `None` when the
/// platform doesn't expose it (the free-space gate is then skipped).
#[cfg(unix)]
fn available_space(dir: &Path) -> Option<u64> {
use std::os::unix::ffi::OsStrExt;
let cpath = std::ffi::CString::new(dir.as_os_str().as_bytes()).ok()?;
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statvfs(cpath.as_ptr(), &mut st) };
if rc != 0 {
return None;
}
// `statvfs` field integer widths differ by platform (e.g. macOS `u64`,
// Linux glibc `u64`/`c_ulong`); cast both to `u64` for the product. The
// cast is a no-op where the field is already `u64` — allow the lint so the
// same source stays portable.
#[allow(clippy::unnecessary_cast, clippy::useless_conversion)]
let avail = (st.f_bavail as u64).saturating_mul(st.f_frsize as u64);
Some(avail)
}
/// Windows has no `statvfs`. This returned `None` unconditionally, which does
/// not merely skip a test — it skipped the free-space GATE, so a Windows user
/// extracting a disc to a full volume got a confusing failure part-way through
/// instead of a clear refusal up front. libfreemkv ships a Windows GUI, so that
/// is the platform where the friendly error matters most.
///
/// `GetDiskFreeSpaceExW` is declared directly against kernel32, matching how
/// `scsi::windows` already reaches the Win32 API rather than pulling in a
/// binding crate for one call.
#[cfg(windows)]
fn available_space(dir: &Path) -> Option<u64> {
use std::os::windows::ffi::OsStrExt;
unsafe extern "system" {
fn GetDiskFreeSpaceExW(
lpDirectoryName: *const u16,
lpFreeBytesAvailableToCaller: *mut u64,
lpTotalNumberOfBytes: *mut u64,
lpTotalNumberOfFreeBytes: *mut u64,
) -> i32;
}
// Wide, NUL-terminated. An interior NUL cannot reach the API, so reject it
// rather than silently truncating the path and measuring the wrong volume.
let mut wide: Vec<u16> = dir.as_os_str().encode_wide().collect();
if wide.contains(&0) {
return None;
}
wide.push(0);
let mut avail: u64 = 0;
// FreeBytesAvailableToCaller, not TotalNumberOfFreeBytes: it accounts for
// per-user quotas, which is what "can I actually write this much" means and
// what `statvfs`'s `f_bavail` gives on the unix side.
let ok = unsafe {
GetDiskFreeSpaceExW(
wide.as_ptr(),
&mut avail,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if ok == 0 { None } else { Some(avail) }
}
/// Neither unix nor Windows: no way to ask, so the gate is skipped.
#[cfg(not(any(unix, windows)))]
fn available_space(_dir: &Path) -> Option<u64> {
None
}
/// Whether a directory exists and contains any entry.
fn dir_is_non_empty(dir: &Path) -> bool {
std::fs::read_dir(dir)
.map(|mut it| it.next().is_some())
.unwrap_or(false)
}
/// Sanitize ONE disc-path component for the host filesystem. Rejects `..`,
/// host-illegal characters, and control bytes; strips a trailing dot/space
/// (Windows). A name whose base matches a Windows reserved device name (`NUL`,
/// `CON`, `COM1`..) is **substituted** (prefixed with `_`) rather than rejected
/// — a Linux-authored disc may legally carry such a file and a single reserved
/// name must not abort the whole tree walk. An empty result after stripping is
/// an error.
fn sanitize_component(name: &str) -> Result<String> {
if name == ".." || name == "." {
return Err(Error::DirNameCollision {
host: name.to_string(),
});
}
let mut out = String::with_capacity(name.len());
for ch in name.chars() {
match ch {
'\0' | '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*' => {
return Err(Error::DirNameCollision {
host: name.to_string(),
});
}
c if (c as u32) < 0x20 => {
return Err(Error::DirNameCollision {
host: name.to_string(),
});
}
c => out.push(c),
}
}
// Trailing dot / space are illegal on Windows.
let trimmed = out.trim_end_matches([' ', '.']);
if trimmed.is_empty() {
return Err(Error::DirNameCollision {
host: name.to_string(),
});
}
// Windows reserved device names (case-insensitive, base name before any
// extension). `NUL` silently discards all writes on Windows, so a disc file
// literally named `NUL.cfg` (legal on UDF/Linux) must NOT pass through
// verbatim — but it also must not abort the whole extraction. Substitute by
// prefixing `_`, which can never itself collide with another reserved name
// and is host-legal everywhere. The substituted name still flows through the
// caller's host-path collision check.
let base = trimmed.split('.').next().unwrap_or(trimmed);
if is_windows_reserved(base) {
return Ok(format!("_{trimmed}"));
}
Ok(trimmed.to_string())
}
/// Whether `base` (the name component before any extension) matches a Windows
/// reserved device name. These are reserved by the OS regardless of extension
/// and silently alias a device (e.g. `NUL` discards writes). Case-insensitive.
fn is_windows_reserved(base: &str) -> bool {
const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", "CLOCK$"];
if RESERVED.iter().any(|r| base.eq_ignore_ascii_case(r)) {
return true;
}
let up = base.to_ascii_uppercase();
for prefix in ["COM", "LPT"] {
if let Some(rest) = up.strip_prefix(prefix)
&& rest.len() == 1
&& matches!(rest.as_bytes()[0], b'1'..=b'9')
{
return true;
}
}
false
}
/// DVD VTS group key for a `VTS_xx_*` file name, else `None`. e.g.
/// `VTS_01_1.VOB` → `Some("VTS_01")`. Case-insensitive on the prefix.
fn vts_group_of(name: &str) -> Option<String> {
let up = name.to_ascii_uppercase();
let rest = up.strip_prefix("VTS_")?;
// rest like "01_1.VOB" — take the 2-digit group number.
let group = rest.split('_').next()?;
if group.len() == 2 && group.bytes().all(|b| b.is_ascii_digit()) {
Some(format!("VTS_{group}"))
} else {
None
}
}
/// True for a CSS-scrambled title VOB (`VTS_xx_1.VOB`..`_9.VOB`). The menu
/// VOB `VTS_xx_0.VOB` and the `.IFO`/`.BUP` are clear, so they are excluded
/// from the per-VTS key crack.
fn is_title_vob(name: &str) -> bool {
let up = name.to_ascii_uppercase();
if !up.ends_with(".VOB") {
return false;
}
// VTS_xx_y.VOB → y is the part number; 0 = menu (clear), 1..9 = title.
let stem = up.trim_end_matches(".VOB");
match stem.rsplit_once('_') {
Some((_, part)) => part.len() == 1 && matches!(part.as_bytes()[0], b'1'..=b'9'),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::css::lfsr;
use std::collections::HashMap;
// ── Self-contained in-memory UDF fixture toolkit ──────────────────────
// Modeled on the bluray.rs / dvd.rs test fixtures: a `MemDisc` SectorSource
// backed by an explicit absolute-LBA map, plus recursive UDF builders.
// PART_START == metadata_start so file/ICB LBAs share one address space
// (single partition map). Encrypted content is faked at the buffer level
// (CSS via `lfsr::scramble_sector`); AACS-content tests use clear bytes
// through `DecryptKeys::None`, since per-unit AACS decrypt is already
// exhaustively tested in `decrypt.rs` / `decrypting.rs`.
const PART_START: u32 = 2000;
struct MemDisc {
sectors: HashMap<u32, [u8; 2048]>,
/// Absolute LBAs that fail to read (bad-sector fixture → DiscRead).
bad: std::collections::HashSet<u32>,
/// Absolute LBAs whose read fails to DECRYPT (no/wrong key fixture →
/// DecryptFailed), exercising the undecryptable-unit loss path.
decrypt_fail: std::collections::HashSet<u32>,
}
impl MemDisc {
fn new() -> Self {
Self {
sectors: HashMap::new(),
bad: std::collections::HashSet::new(),
decrypt_fail: std::collections::HashSet::new(),
}
}
fn put(&mut self, lba: u32, data: [u8; 2048]) {
self.sectors.insert(lba, data);
}
fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
for (i, chunk) in bytes.chunks(2048).enumerate() {
let mut s = [0u8; 2048];
s[..chunk.len()].copy_from_slice(chunk);
self.put(lba + i as u32, s);
}
}
}
impl SectorSource for MemDisc {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let need = count as usize * 2048;
for i in 0..count as u32 {
if self.bad.contains(&(lba + i)) {
return Err(Error::DiscRead {
sector: (lba + i) as u64,
status: None,
sense: None,
});
}
if self.decrypt_fail.contains(&(lba + i)) {
return Err(Error::DecryptFailed);
}
}
for i in 0..count as u32 {
let off = i as usize * 2048;
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
buf[off..off + 2048].copy_from_slice(&s);
}
Ok(need)
}
}
struct FileSpec {
name: String,
icb_lba: u32,
data_lba: u32,
size: u32,
long_ad: bool,
contents: Vec<u8>,
}
struct DirSpec {
name: String,
icb_lba: u32,
dir_data_lba: u32,
files: Vec<FileSpec>,
subdirs: Vec<DirSpec>,
}
fn file(name: &str, icb_lba: u32, data_lba: u32, contents: Vec<u8>, long_ad: bool) -> FileSpec {
FileSpec {
name: name.to_string(),
icb_lba,
data_lba,
size: contents.len() as u32,
long_ad,
contents,
}
}
fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
if long_ad {
s[34..36].copy_from_slice(&1u16.to_le_bytes()); // Long AD
}
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
let ad_size: u32 = if long_ad { 16 } else { 8 };
s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
s
}
fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] {
build_file_icb(dir_data_len, dir_data_lba, false)
}
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
let start = buf.len();
let name_field: Vec<u8> = if is_parent {
Vec::new()
} else {
let mut v = vec![0x08u8];
v.extend_from_slice(name.as_bytes());
v
};
let l_fi = name_field.len();
let mut fid = vec![0u8; 38];
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
let mut file_chars = 0u8;
if is_dir {
file_chars |= 0x02;
}
if is_parent {
file_chars |= 0x08;
}
fid[18] = file_chars;
fid[19] = l_fi as u8;
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
buf.extend_from_slice(&fid);
buf.extend_from_slice(&name_field);
let used = buf.len() - start;
buf.resize(start + ((used + 3) & !3), 0);
}
fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) {
let mut fids = Vec::new();
push_fid(&mut fids, "", dir.icb_lba, true, true);
for f in &dir.files {
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
disc.put(
PART_START + f.icb_lba,
build_file_icb(f.size, f.data_lba, f.long_ad),
);
if !f.contents.is_empty() {
disc.put_bytes(PART_START + f.data_lba, &f.contents);
}
}
for sub in &dir.subdirs {
push_fid(&mut fids, &sub.name, sub.icb_lba, true, false);
}
disc.put(
PART_START + dir.icb_lba,
build_dir_icb(dir.dir_data_lba, fids.len() as u32),
);
disc.put_bytes(PART_START + dir.dir_data_lba, &fids);
for sub in &dir.subdirs {
lay_dir(disc, sub);
}
}
fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
let mut avdp = [0u8; 2048];
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
disc.put(256, avdp);
let mut pd = [0u8; 2048];
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
disc.put(32, pd);
let mut lvd = [0u8; 2048];
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
disc.put(33, lvd);
let mut td = [0u8; 2048];
td[0..2].copy_from_slice(&8u16.to_le_bytes());
disc.put(34, td);
let mut fsd = [0u8; 2048];
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
disc.put(PART_START, fsd);
}
/// Lay a full root DirSpec and return a navigable disc.
fn build_disc(root: DirSpec) -> MemDisc {
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, root.icb_lba);
lay_dir(&mut disc, &root);
disc
}
/// A unique temp dir for one test's output, removed on drop.
struct TmpDir(PathBuf);
impl TmpDir {
fn new(tag: &str) -> Self {
let mut p = std::env::temp_dir();
let uniq = format!(
"freemkv_extract_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
p.push(uniq);
Self(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Build a file ICB with TWO Short ADs (a fragmented / multi-extent file).
/// Each AD records `sectors_each` sectors at its own partition-relative
/// `data_lba`. Used to exercise the per-extent AACS unit-base re-anchoring:
/// the two extents' absolute starts differ by a non-multiple-of-3 so a
/// single (first-extent) unit base mis-aligns the second extent.
fn build_two_extent_icb(sectors_each: u32, data_lba_a: u32, data_lba_b: u32) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
// ad_type 0 = Short AD (icb flags low 3 bits at offset 34).
s[34..36].copy_from_slice(&0u16.to_le_bytes());
let size = sectors_each * SECTOR_BYTES as u32 * 2;
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
s[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad = 2 Short ADs
let ext_len = sectors_each * SECTOR_BYTES as u32; // bytes, type-0 recorded
// AD #0
s[216..220].copy_from_slice(&(ext_len & 0x3FFF_FFFF).to_le_bytes());
s[220..224].copy_from_slice(&data_lba_a.to_le_bytes());
// AD #1
s[224..228].copy_from_slice(&(ext_len & 0x3FFF_FFFF).to_le_bytes());
s[228..232].copy_from_slice(&data_lba_b.to_le_bytes());
s
}
/// Like [`build_two_extent_icb`] but the two extents may have DIFFERENT
/// sector counts — used to exercise the within-extent batch-size
/// arithmetic (`sectors - sector_off`) with a first extent long enough to
/// force a second while-loop iteration.
fn build_two_extent_icb_sized(
sectors_a: u32,
data_lba_a: u32,
sectors_b: u32,
data_lba_b: u32,
) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
s[34..36].copy_from_slice(&0u16.to_le_bytes()); // Short AD
let size = (sectors_a as u64 + sectors_b as u64) * SECTOR_BYTES as u64;
s[56..64].copy_from_slice(&size.to_le_bytes()); // info_length
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
s[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad = 2 Short ADs
let len_a = sectors_a * SECTOR_BYTES as u32;
let len_b = sectors_b * SECTOR_BYTES as u32;
s[216..220].copy_from_slice(&(len_a & 0x3FFF_FFFF).to_le_bytes());
s[220..224].copy_from_slice(&data_lba_a.to_le_bytes());
s[224..228].copy_from_slice(&(len_b & 0x3FFF_FFFF).to_le_bytes());
s[228..232].copy_from_slice(&data_lba_b.to_le_bytes());
s
}
/// Encrypt the clear unit from `clear_aacs_unit(tag)` under `unit_key` so
/// `aacs::content::decrypt_unit` recovers it cleanly (zero decrypt loss).
/// `tag` distinguishes two units' payloads.
fn encrypt_aacs_unit(unit_key: &[u8; 16], tag: u8) -> Vec<u8> {
let mut unit = clear_aacs_unit(tag);
assert!(
crate::aacs::content::encrypt_unit(&mut unit, unit_key),
"a full-length unit must encrypt"
);
unit
}
/// The plaintext that `encrypt_aacs_unit(_, tag)` decrypts back to.
fn clear_aacs_unit(tag: u8) -> Vec<u8> {
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
if off + 1 < unit.len() {
unit[off + 1] = tag;
}
off += 192;
}
// decrypt preserves the plaintext header, so the recovered unit carries
// the CPI bits the encrypt fixture set — the expected plaintext must too.
unit[0] |= 0xC0;
unit
}
/// A `Disc` carrying an AACS unit key so `decrypt_keys()` returns
/// `DecryptKeys::Aacs` (engaging the unit-alignment gate). The content laid
/// down by the fixture is genuinely encrypted under the key, so a clean
/// per-extent decrypt records zero loss — letting the test isolate the GATE
/// (alignment) from a false decrypt-loss tally.
fn aacs_disc() -> Disc {
let mut d = clear_disc();
d.encrypted = true;
d.aacs = Some(crate::disc::AacsState {
version: 1,
bus_encryption: false,
mkb_version: None,
disc_hash: String::new(),
key_source: crate::disc::KeyOrigin::ExternalUk,
vuk: None,
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
});
d
}
/// A `Disc` with no cipher state (clear content → `DecryptKeys::None`).
fn clear_disc() -> Disc {
Disc {
volume_id: "TEST".into(),
meta_title: Some("TEST".into()),
format: crate::disc::DiscFormat::BluRay,
capacity_sectors: 100_000,
capacity_bytes: 100_000 * 2048,
layers: 1,
titles: Vec::new(),
region: crate::disc::DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: crate::disc::ContentFormat::BdTs,
}
}
fn read_out(dir: &Path, rel: &str) -> Option<Vec<u8>> {
std::fs::read(dir.join(rel)).ok()
}
// ── Tests ─────────────────────────────────────────────────────────────
/// BDMV extraction: STREAM/*.m2ts written decrypted (here clear via
/// None keys), nav (index.bdmv / MovieObject.bdmv / PLAYLIST / CLIPINF)
/// verbatim, and the top-level AACS/ directory stripped entirely.
#[test]
fn bdmv_extracts_streams_and_nav_and_strips_aacs() {
let m2ts = vec![0xABu8; 3 * 2048]; // one AACS unit's worth
let index = b"INDEX-NAV".to_vec();
let movieobj = b"MOVIEOBJECT-NAV".to_vec();
let mpls = b"MPLS-PLAYLIST".to_vec();
let clpi = b"CLPI-CLIPINF".to_vec();
let aacs_inf = b"AACS-KEY-FILE".to_vec();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("index.bdmv", 30, 31, index.clone(), false),
file("MovieObject.bdmv", 32, 33, movieobj.clone(), false),
],
subdirs: vec![
DirSpec {
name: "STREAM".to_string(),
icb_lba: 40,
dir_data_lba: 41,
files: vec![file("00001.m2ts", 42, 5000, m2ts.clone(), true)],
subdirs: vec![],
},
DirSpec {
name: "PLAYLIST".to_string(),
icb_lba: 44,
dir_data_lba: 45,
files: vec![file("00000.mpls", 46, 47, mpls.clone(), false)],
subdirs: vec![],
},
DirSpec {
name: "CLIPINF".to_string(),
icb_lba: 48,
dir_data_lba: 49,
files: vec![file("00001.clpi", 50, 51, clpi.clone(), false)],
subdirs: vec![],
},
],
},
DirSpec {
name: "AACS".to_string(),
icb_lba: 60,
dir_data_lba: 61,
files: vec![file("Unit_Key_RO.inf", 62, 63, aacs_inf, false)],
subdirs: vec![],
},
],
};
let mut disc = build_disc(root);
let out = TmpDir::new("bdmv");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
assert_eq!(
read_out(out.path(), "BDMV/STREAM/00001.m2ts"),
Some(m2ts),
"m2ts content extracted intact"
);
assert_eq!(read_out(out.path(), "BDMV/index.bdmv"), Some(index));
assert_eq!(
read_out(out.path(), "BDMV/MovieObject.bdmv"),
Some(movieobj)
);
assert_eq!(read_out(out.path(), "BDMV/PLAYLIST/00000.mpls"), Some(mpls));
assert_eq!(read_out(out.path(), "BDMV/CLIPINF/00001.clpi"), Some(clpi));
// AACS/ stripped: neither the dir nor its file exists.
assert!(!out.path().join("AACS").exists(), "AACS/ must be stripped");
assert!(res.complete, "clean extraction is complete");
assert_eq!(res.bytes_lost(), 0);
}
/// VIDEO_TS extraction writes VOBs + IFO/BUP. Here the content is clear
/// (None keys), proving the tree walk + per-file write for the DVD layout;
/// CSS descramble correctness is tested separately below.
#[test]
fn video_ts_extracts_vobs_and_ifo() {
let ifo = b"VIDEO_TS.IFO".to_vec();
let vob = vec![0x5Au8; 2 * 2048];
let bup = b"VIDEO_TS.BUP".to_vec();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "VIDEO_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("VIDEO_TS.IFO", 30, 31, ifo.clone(), false),
file("VTS_01_1.VOB", 32, 5000, vob.clone(), false),
file("VIDEO_TS.BUP", 34, 35, bup.clone(), false),
],
subdirs: vec![],
}],
};
let mut disc = build_disc(root);
let out = TmpDir::new("videots");
let mut d = clear_disc();
d.content_format = crate::disc::ContentFormat::MpegPs;
let res = d
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
assert_eq!(read_out(out.path(), "VIDEO_TS/VIDEO_TS.IFO"), Some(ifo));
assert_eq!(read_out(out.path(), "VIDEO_TS/VTS_01_1.VOB"), Some(vob));
assert_eq!(read_out(out.path(), "VIDEO_TS/VIDEO_TS.BUP"), Some(bup));
assert!(res.complete);
}
/// A CSS-scrambled title VOB is descrambled on extraction: the producer
/// cracks the per-VTS key (keyless Stevenson) and the output VOB is plain.
#[test]
fn css_title_vob_is_descrambled() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
// Build a scrambled sector with a crackable repeating crib (period 8),
// mirroring css::mod tests' `crackable_sector`.
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let mut plain = vec![0u8; 2048];
// Pack start code: a real scrambled sector is an MPEG-2 PS pack, and
// the descrambler requires it before trusting byte 0x14.
plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
plain[0x14] = 0x10; // scramble flag
let pat: Vec<u8> = (0..8)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
.collect();
for (i, b) in plain.iter_mut().enumerate().skip(0x59) {
*b = pat[i % 8];
}
plain[0x54..0x59].copy_from_slice(&seed);
let mut scrambled = plain.clone();
lfsr::scramble_sector(&title_key, &mut scrambled);
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "VIDEO_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![file("VTS_01_1.VOB", 30, 5000, scrambled, false)],
subdirs: vec![],
}],
};
let mut disc = build_disc(root);
let out = TmpDir::new("css");
// A CSS disc with a (provenance-unknown) cracked key; the producer
// re-cracks per VTS, so the disc-wide key value is irrelevant here.
let mut d = clear_disc();
d.content_format = crate::disc::ContentFormat::MpegPs;
d.css = Some(crate::css::CssState {
title_key,
crack_span: None,
});
let res = d
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
let got = read_out(out.path(), "VIDEO_TS/VTS_01_1.VOB").expect("vob");
// Descrambled output matches the plaintext, with the scramble flag
// cleared by the descrambler.
let mut expect = plain.clone();
expect[0x14] = 0x00;
assert_eq!(got, expect, "VOB descrambled to plaintext");
assert!(res.complete);
}
/// A bad sector inside a file becomes a recorded zero-filled hole; the run
/// does not abort, the file is still written, and loss is accounted.
#[test]
fn bad_sector_holes_file_and_accounts_loss() {
let good = vec![0x77u8; 4 * 2048];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00001.m2ts", 24, 5000, good.clone(), true)],
subdirs: vec![],
}],
}],
};
let mut disc = build_disc(root);
// Mark the whole 4-sector extent bad so a batch read fails. Abs LBAs
// are PART_START + data_lba (5000) .. +3.
for i in 0..4u32 {
disc.bad.insert(PART_START + 5000 + i);
}
let out = TmpDir::new("badsector");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract does not abort on bad sectors");
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
assert_eq!(
got.len(),
good.len(),
"holed file still sized to declared size"
);
assert!(got.iter().all(|&b| b == 0), "bad range zero-filled");
assert!(!res.complete, "lossy extraction is not complete");
assert_eq!(res.bytes_unreadable, good.len() as u64);
assert_eq!(res.files.len(), 1);
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
}
/// An UNDECRYPTABLE unit (DecryptFailed — wrong/missing key) is zero-filled
/// and counted as loss through the public API exactly like a bad sector:
/// the recovery-seam consolidation folded the old bytes_undecryptable bucket
/// into bytes_unreadable, and the run must still report complete == false and
/// bytes_lost() > 0 (this gates the CLI exit code / multipass re-run).
#[test]
fn undecryptable_unit_holes_file_and_accounts_loss() {
let good = vec![0x55u8; 4 * 2048];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00001.m2ts", 24, 5000, good.clone(), true)],
subdirs: vec![],
}],
}],
};
let mut disc = build_disc(root);
// The whole extent fails to decrypt (no/wrong key) rather than to read.
for i in 0..4u32 {
disc.decrypt_fail.insert(PART_START + 5000 + i);
}
let out = TmpDir::new("decryptfail");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract does not abort on an undecryptable unit");
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
assert_eq!(
got.len(),
good.len(),
"holed file still sized to declared size"
);
assert!(
got.iter().all(|&b| b == 0),
"undecryptable range zero-filled"
);
assert!(
!res.complete,
"an undecryptable unit makes the rip incomplete"
);
assert!(
res.bytes_lost() > 0,
"decrypt loss counted, not reported clean"
);
assert_eq!(res.bytes_unreadable, good.len() as u64);
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
}
/// Path sanitization rejects a host-illegal component in a disc file name.
#[test]
fn sanitize_rejects_illegal_component() {
assert!(sanitize_component("good_name.m2ts").is_ok());
assert!(sanitize_component("..").is_err());
assert!(sanitize_component("a/b").is_err());
assert!(sanitize_component("a:b").is_err());
assert!(sanitize_component("a*b").is_err());
// Windows reserved device names are substituted (prefixed `_`), not
// rejected — a single such file must not abort the whole tree walk.
assert_eq!(sanitize_component("CON").unwrap(), "_CON");
assert_eq!(sanitize_component("com1").unwrap(), "_com1");
assert_eq!(sanitize_component("LPT9").unwrap(), "_LPT9");
// Reserved base with an extension is still substituted (the device name
// aliases regardless of extension on Windows).
assert_eq!(sanitize_component("NUL.cfg").unwrap(), "_NUL.cfg");
assert_eq!(sanitize_component("conin$").unwrap(), "_conin$");
// A non-reserved lookalike is untouched.
assert_eq!(sanitize_component("COM10").unwrap(), "COM10");
assert_eq!(sanitize_component("CONSOLE").unwrap(), "CONSOLE");
// A trailing dot/space is stripped, not rejected outright.
assert_eq!(sanitize_component("name. ").unwrap(), "name");
// ...unless stripping empties it.
assert!(sanitize_component(". ").is_err());
}
/// Two distinct disc paths that sanitize to the same host path are a hard
/// error (collision), never a silent overwrite.
#[test]
fn name_collision_is_error() {
// Two files in the same dir whose names both reduce to "movie" after
// the trailing-dot/space strip ("movie" and "movie.").
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![
file("movie", 30, 31, b"a".to_vec(), false),
file("movie.", 32, 33, b"b".to_vec(), false),
],
subdirs: vec![],
};
let mut disc = build_disc(root);
let out = TmpDir::new("collision");
let err = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect_err("collision must error");
assert!(matches!(err, Error::DirNameCollision { .. }));
}
/// A non-empty target dir is refused without `--force`, and accepted with.
#[test]
fn non_empty_target_requires_force() {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("a.bin", 30, 31, b"hello".to_vec(), false)],
subdirs: vec![],
};
let out = TmpDir::new("nonempty");
std::fs::create_dir_all(out.path()).unwrap();
std::fs::write(out.path().join("preexisting.txt"), b"x").unwrap();
let mut disc = build_disc(root);
let err = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect_err("non-empty dir without --force must error");
assert!(matches!(err, Error::DirNotEmpty));
// With --force it proceeds.
let mut disc2 = build_disc(DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("a.bin", 30, 31, b"hello".to_vec(), false)],
subdirs: vec![],
});
let opts = ExtractOptions {
force: true,
..Default::default()
};
let res = clear_disc()
.extract_tree(&mut disc2, out.path(), &opts)
.expect("force proceeds");
assert_eq!(read_out(out.path(), "a.bin"), Some(b"hello".to_vec()));
assert!(res.complete);
}
/// VTS grouping + title-VOB classification used for per-VTS CSS keys.
#[test]
fn vts_grouping_and_title_vob_classification() {
assert_eq!(vts_group_of("VTS_01_1.VOB").as_deref(), Some("VTS_01"));
assert_eq!(vts_group_of("VTS_12_0.VOB").as_deref(), Some("VTS_12"));
assert_eq!(vts_group_of("VIDEO_TS.IFO"), None);
assert!(is_title_vob("VTS_01_1.VOB"));
assert!(is_title_vob("VTS_01_9.VOB"));
assert!(
!is_title_vob("VTS_01_0.VOB"),
"menu VOB is clear, not title"
);
assert!(!is_title_vob("VTS_01_1.IFO"));
}
/// Regression (rc.6 audit, finding #449): a MULTI-EXTENT AACS file must
/// re-anchor the unit-alignment base PER extent, not once at the first
/// extent. The two extents here start at absolute LBAs whose difference is
/// NOT a multiple of 3 (PART_START+5000 vs PART_START+5004 → Δ4 sectors).
/// With a single first-extent unit base the second extent's first read is
/// off the unit grid (offset 4, 4 % 3 == 1) → `is_unit_aligned` fails →
/// `DecryptFailed` → the whole extent becomes a zero-filled (false) hole.
/// With per-extent anchoring both extents read clean: bytes_unreadable == 0
/// and the file content survives. The fixture content is clear (no TS
/// syncs) so the decrypt step restores each unit verbatim — this isolates
/// the GATE from the cipher math.
#[test]
fn multi_extent_aacs_anchors_unit_base_per_extent() {
const SECTORS_EACH: u32 = 3; // one AACS unit per extent
const DATA_A: u32 = 5000; // abs PART_START+5000 (≡ 7000)
const DATA_B: u32 = 5004; // abs PART_START+5004 — Δ4 (not mult of 3)
let key = [0u8; 16];
// Each extent is exactly one encrypted AACS unit (distinct payloads).
let ext_a = encrypt_aacs_unit(&key, 0xA1);
let ext_b = encrypt_aacs_unit(&key, 0xB2);
// Expected plaintext after a correct per-extent decrypt.
let mut expect = clear_aacs_unit(0xA1);
expect.extend_from_slice(&clear_aacs_unit(0xB2));
// Lay the disc by hand: root dir with one BDMV/STREAM/00001.m2ts whose
// ICB carries two Short ADs.
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
// root → BDMV → STREAM → 00001.m2ts
let mut stream_fids = Vec::new();
push_fid(&mut stream_fids, "", 40, true, true);
push_fid(&mut stream_fids, "00001.m2ts", 42, false, false);
disc.put(
PART_START + 42,
build_two_extent_icb(SECTORS_EACH, DATA_A, DATA_B),
);
disc.put_bytes(PART_START + DATA_A, &ext_a);
disc.put_bytes(PART_START + DATA_B, &ext_b);
disc.put(PART_START + 40, build_dir_icb(41, stream_fids.len() as u32));
disc.put_bytes(PART_START + 41, &stream_fids);
let mut bdmv_fids = Vec::new();
push_fid(&mut bdmv_fids, "", 20, true, true);
push_fid(&mut bdmv_fids, "STREAM", 40, true, false);
disc.put(PART_START + 20, build_dir_icb(21, bdmv_fids.len() as u32));
disc.put_bytes(PART_START + 21, &bdmv_fids);
let mut root_fids = Vec::new();
push_fid(&mut root_fids, "", 10, true, true);
push_fid(&mut root_fids, "BDMV", 20, true, false);
disc.put(PART_START + 10, build_dir_icb(11, root_fids.len() as u32));
disc.put_bytes(PART_START + 11, &root_fids);
let out = TmpDir::new("multiextent_aacs");
let res = aacs_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
assert_eq!(
got, expect,
"both extents extract verbatim — the second extent is NOT a hole"
);
assert_eq!(
res.bytes_unreadable, 0,
"per-extent unit base must keep the second extent off the hole path"
);
assert!(
res.complete,
"a clean multi-extent AACS file extracts complete"
);
}
/// Focused alignment-computation check underpinning the per-extent fix:
/// when each extent anchors its OWN start as the unit base, the extent's
/// own batch starts are always unit-aligned; anchoring a later extent
/// against the FIRST extent's base mis-aligns whenever the extents' starts
/// differ by a non-multiple of 3 sectors. This is the exact arithmetic the
/// decrypt-on-read gate (`aacs::content::is_unit_aligned`) performs.
#[test]
fn per_extent_base_is_aligned_first_extent_base_is_not() {
use crate::aacs::content::is_unit_aligned;
let ext_a_start = 7000u32; // first extent abs LBA
let ext_b_start = 7004u32; // second extent abs LBA (Δ4 — not mult of 3)
// Per-extent base: extent B's first read anchors on B's start → aligned.
assert!(
is_unit_aligned(ext_b_start, ext_b_start),
"per-extent base keeps the extent's own first read aligned"
);
// Stale (first-extent) base: extent B's first read measured against A's
// start is OFF the unit grid → the gate would (wrongly) reject it.
assert!(
!is_unit_aligned(ext_b_start, ext_a_start),
"first-extent base mis-aligns a Δ-non-multiple-of-3 later extent"
);
// Sanity: a later extent whose Δ from the first IS a multiple of 3 would
// have masked the bug — that's why the regression fixture uses Δ4.
let ext_c_start = ext_a_start + 6; // Δ6 == 2 units
assert!(
is_unit_aligned(ext_c_start, ext_a_start),
"a Δ-multiple-of-3 extent happens to stay aligned even on a stale base"
);
}
/// An inline (ICB-embedded) file extracts from its embedded bytes.
#[test]
fn inline_file_extracts() {
// Build an ICB whose flags select AD type 3 (embedded) with the data
// stored inline after the ADs field.
let payload = b"INLINE-NAV-DATA".to_vec();
let mut disc = MemDisc::new();
let mut root_fids = Vec::new();
push_fid(&mut root_fids, "", 10, true, true);
push_fid(&mut root_fids, "tiny.inf", 30, false, false);
// Inline ICB (tag 266): flags low 3 bits = 3, l_ad = payload len, the
// data living at offset 216.
let mut icb = [0u8; 2048];
icb[0..2].copy_from_slice(&266u16.to_le_bytes());
icb[34..36].copy_from_slice(&3u16.to_le_bytes()); // embedded
icb[56..64].copy_from_slice(&(payload.len() as u64).to_le_bytes());
icb[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
icb[212..216].copy_from_slice(&(payload.len() as u32).to_le_bytes()); // l_ad
icb[216..216 + payload.len()].copy_from_slice(&payload);
disc.put(PART_START + 30, icb);
disc.put(PART_START + 10, build_dir_icb(11, root_fids.len() as u32));
disc.put_bytes(PART_START + 11, &root_fids);
build_udf_skeleton(&mut disc, 10);
let out = TmpDir::new("inline");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
assert_eq!(read_out(out.path(), "tiny.inf"), Some(payload));
assert!(res.complete);
}
// ── Mutation-triage additions ───────────────────────────────────────────
/// `cancelled` must stop on EITHER signal alone (a disjunction) — a
/// progress sink asking to stop must cancel even with no halt token, and a
/// cancelled halt token must cancel even when progress says continue.
#[test]
fn cancelled_stops_on_either_signal_alone() {
let opts_no_halt = ExtractOptions::default();
assert!(
opts_no_halt.cancelled(false),
"a progress sink asking to stop must cancel even with no halt token"
);
assert!(
!opts_no_halt.cancelled(true),
"neither signal firing must not cancel"
);
let halt = crate::halt::Halt::new();
halt.cancel();
let opts_halted = ExtractOptions {
halt: Some(halt),
..Default::default()
};
assert!(
opts_halted.cancelled(true),
"a cancelled halt token must cancel even when progress says continue"
);
}
/// The free-space pre-check must fire whenever the disc's declared total
/// exceeds real available space. An absurdly large declared size (an
/// exabyte) trips it regardless of the actual free space on whatever
/// machine runs the test.
#[test]
fn insufficient_space_errors_on_absurdly_large_required() {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("huge.bin", 30, 31, Vec::new(), false)],
subdirs: vec![],
};
let mut disc = build_disc(root);
// Overwrite the declared size (info_length) with an absurd value; the
// extent length stays 0 so no real content is ever read (the space
// gate runs before Phase 2 touches content).
let mut icb = build_file_icb(0, 31, false);
let huge: u64 = 1u64 << 60; // ~1 exabyte -- no real disk has this free
icb[56..64].copy_from_slice(&huge.to_le_bytes());
disc.put(PART_START + 30, icb);
let out = TmpDir::new("insufficient_space");
let err = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect_err("an absurdly large declared size must trip the space gate");
assert!(matches!(err, Error::DirInsufficientSpace { .. }));
}
/// Regression: the per-VTS key crack in `resolve_vts_key` must gather ONLY
/// this VTS's own title-VOB extents. Two VTS groups here carry DISTINCT
/// scrambling keys; if the group filter (`vts_group_of(..) == Some(vts) &&
/// is_title_vob(..)`) is loosened (`!=`, or `&&` -> `||`), one VTS's
/// resolve gathers the OTHER VTS's extents, cracks the wrong key, and that
/// VTS's own content silently fails to descramble to the expected
/// plaintext.
#[test]
fn css_two_vts_groups_do_not_cross_contaminate_keys() {
fn scrambled_vob(title_key: [u8; 5], marker: u8) -> (Vec<u8>, Vec<u8>) {
let seed = [0x11u8, 0x22, 0x33, 0x44, marker];
let mut plain = vec![0u8; 2048];
// The crack scan's hardened `is_scrambled_pack` gate requires the
// MPEG-PS pack-start signature before it will even ATTEMPT a
// Stevenson crack (see `css::is_scrambled_pack`) -- without it,
// `resolve_vts_key` silently falls back to `base_keys` for BOTH
// VTS groups regardless of which extents were gathered, masking
// this exact regression.
plain[0x00..0x04].copy_from_slice(&crate::css::PACK_START);
// Pack start code: a real scrambled sector is an MPEG-2 PS pack, and
// the descrambler requires it before trusting byte 0x14.
plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
plain[0x14] = 0x10; // scramble flag
let pat: Vec<u8> = (0..8)
.map(|k| (0xA0u8.wrapping_add(k as u8) ^ marker) ^ 0x5A)
.collect();
for (i, b) in plain.iter_mut().enumerate().skip(0x59) {
*b = pat[i % 8];
}
plain[0x54..0x59].copy_from_slice(&seed);
let mut scrambled = plain.clone();
lfsr::scramble_sector(&title_key, &mut scrambled);
(plain, scrambled)
}
let key_1 = [0x10u8, 0x20, 0x30, 0x40, 0x50];
let key_2 = [0x90u8, 0x80, 0x70, 0x60, 0x51];
let (plain_1, scrambled_1) = scrambled_vob(key_1, 0x01);
let (plain_2, scrambled_2) = scrambled_vob(key_2, 0x02);
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "VIDEO_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("VTS_01_1.VOB", 30, 5000, scrambled_1.clone(), false),
file("VTS_02_1.VOB", 32, 6000, scrambled_2.clone(), false),
],
subdirs: vec![],
}],
};
let mut disc = build_disc(root);
let out = TmpDir::new("css_two_vts");
let mut d = clear_disc();
d.content_format = crate::disc::ContentFormat::MpegPs;
// Disc-wide key deliberately matches NEITHER VTS's real key: if the
// per-VTS group filter is broken and a crack attempt fails (or is
// skipped), falling back to this key must still mismatch, so the
// fallback path can never accidentally mask a broken filter.
d.css = Some(crate::css::CssState {
title_key: [0xFFu8; 5],
crack_span: None,
});
let res = d
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
let got_1 = read_out(out.path(), "VIDEO_TS/VTS_01_1.VOB").expect("vts01 vob");
let got_2 = read_out(out.path(), "VIDEO_TS/VTS_02_1.VOB").expect("vts02 vob");
let mut expect_1 = plain_1.clone();
expect_1[0x14] = 0x00;
let mut expect_2 = plain_2.clone();
expect_2[0x14] = 0x00;
assert_eq!(
got_1, expect_1,
"VTS_01 must descramble under its OWN cracked key, not VTS_02's"
);
assert_eq!(
got_2, expect_2,
"VTS_02 must descramble under its OWN cracked key, not VTS_01's"
);
assert!(res.complete);
}
/// `Borrowed` is a thin forwarding wrapper the decrypting decorator uses
/// to avoid taking ownership of the caller's reader -- every
/// `SectorSource` method must forward to the wrapped `&mut dyn
/// SectorSource` verbatim. Calling directly on a concrete `Borrowed`
/// value (not through `&mut dyn SectorSource`) exercises the actual
/// forwarding body via static dispatch, not vtable dispatch through a
/// trait object.
#[test]
fn borrowed_forwards_every_sector_source_call() {
struct Recorder {
capacity: u32,
last_speed: Option<u16>,
last_unit_base: Option<u32>,
}
impl SectorSource for Recorder {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
Ok(0)
}
fn set_speed(&mut self, kbs: u16) {
self.last_speed = Some(kbs);
}
fn set_unit_base(&mut self, lba: u32) {
self.last_unit_base = Some(lba);
}
}
let mut inner = Recorder {
capacity: 42,
last_speed: None,
last_unit_base: None,
};
{
let mut b = Borrowed(&mut inner);
assert_eq!(b.capacity_sectors(), 42, "capacity_sectors must forward");
b.set_speed(7200);
b.set_unit_base(1234);
}
assert_eq!(inner.last_speed, Some(7200), "set_speed must forward");
assert_eq!(
inner.last_unit_base,
Some(1234),
"set_unit_base must forward"
);
}
/// Regression: within ONE extent, a batch's "sectors remaining IN THIS
/// EXTENT" must be computed as `sectors - sector_off`, not `sectors +
/// sector_off`. The latter inflates without bound as the loop advances,
/// letting a later batch's read run PAST this extent's true end into
/// whatever content sits at the following LBAs (an unrelated disc region
/// in this fixture) and get written into the file as if it were this
/// extent's own data -- untrusted-disc content bleed across extent
/// boundaries.
#[test]
fn extent_second_batch_stays_within_its_own_bounds() {
// Extent A: 1600 sectors of pattern 'A' -- just over
// READ_BATCH_SECTORS (1536), forcing a second while-loop iteration
// with sector_off > 0.
const SECTORS_A: u32 = 1600;
const LBA_A: u32 = 5000;
// The disc region immediately following extent A's true end. Must
// NEVER be read as part of extent A: sized to cover a full erroneous
// READ_BATCH_SECTORS second batch starting right after extent A's
// real tail.
const LBA_FILLER: u32 = LBA_A + SECTORS_A;
const SECTORS_FILLER: u32 = 1472;
// Extent B: the file's real second extent, at a completely different
// LBA, same size as the filler region so the two are exact substitutes
// if the arithmetic bug reads the wrong one.
const SECTORS_B: u32 = SECTORS_FILLER;
const LBA_B: u32 = 90_000;
let a = vec![0xAAu8; SECTORS_A as usize * SECTOR_BYTES];
let filler = vec![0xCCu8; SECTORS_FILLER as usize * SECTOR_BYTES];
let b = vec![0xBBu8; SECTORS_B as usize * SECTOR_BYTES];
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
disc.put_bytes(PART_START + LBA_A, &a);
disc.put_bytes(PART_START + LBA_FILLER, &filler);
disc.put_bytes(PART_START + LBA_B, &b);
disc.put(
PART_START + 30,
build_two_extent_icb_sized(SECTORS_A, LBA_A, SECTORS_B, LBA_B),
);
let mut root_fids = Vec::new();
push_fid(&mut root_fids, "", 10, true, true);
push_fid(&mut root_fids, "big.bin", 30, false, false);
disc.put(PART_START + 10, build_dir_icb(11, root_fids.len() as u32));
disc.put_bytes(PART_START + 11, &root_fids);
let out = TmpDir::new("extent_bounds");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
let got = read_out(out.path(), "big.bin").expect("file written");
let mut expect = a.clone();
expect.extend_from_slice(&b);
assert_eq!(
got.len(),
expect.len(),
"file size matches the two extents' declared total"
);
assert_eq!(
got, expect,
"extent A's tail batch must not read past its own declared length \
into the following disc region (pattern 'C' must never appear)"
);
assert!(res.complete);
assert_eq!(res.bytes_unreadable, 0);
}
/// Pure-function coverage of `whole_unit_batch`'s cap: a batch that is
/// NOT the extent's final chunk (there is more remaining after it) is
/// capped at `READ_BATCH_SECTORS`, which is itself an exact multiple of
/// `AACS_UNIT_SECTORS` (1536 = 512 * 3) -- so the result is already
/// unit-aligned.
#[test]
fn whole_unit_batch_caps_mid_stream_batches() {
assert_eq!(whole_unit_batch(2000), READ_BATCH_SECTORS);
assert_eq!(whole_unit_batch(READ_BATCH_SECTORS + 1), READ_BATCH_SECTORS);
assert_eq!(whole_unit_batch(READ_BATCH_SECTORS + 2), READ_BATCH_SECTORS);
}
/// Pure-function coverage of `whole_unit_batch`'s tail contract: the
/// FINAL chunk of an extent (`batch == remaining`, i.e. `remaining <=
/// READ_BATCH_SECTORS`) must NOT be rounded down to a unit boundary, even
/// when it is not itself a multiple of 3 -- `decrypt_sectors`'s
/// trailing-partial contract handles a non-multiple tail specially, and
/// rounding it here would silently drop sectors from the read.
#[test]
fn whole_unit_batch_true_tail_is_never_rounded() {
assert_eq!(whole_unit_batch(5), 5);
assert_eq!(whole_unit_batch(1535), 1535);
assert_eq!(whole_unit_batch(2), 2);
assert_eq!(whole_unit_batch(1), 1);
assert_eq!(whole_unit_batch(READ_BATCH_SECTORS), READ_BATCH_SECTORS);
}
/// `read_batch` must retry a non-decrypt read failure up to
/// `READ_RETRIES` times and succeed if a later attempt does -- a
/// transient failure must not be treated as permanent on the very first
/// try.
#[test]
fn read_batch_retries_transient_failures_and_succeeds() {
struct FlakySource {
fail_first: u32,
calls: u32,
}
impl SectorSource for FlakySource {
fn capacity_sectors(&self) -> u32 {
100_000
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.calls += 1;
if self.calls <= self.fail_first {
return Err(Error::DiscRead {
sector: 0,
status: None,
sense: None,
});
}
let need = count as usize * SECTOR_BYTES;
buf[..need].fill(0x11);
Ok(need)
}
}
// Fails exactly READ_RETRIES times (attempts 0..READ_RETRIES all
// error), then succeeds on the FINAL attempt (attempt ==
// READ_RETRIES) -- the last chance the retry budget allows.
let src = FlakySource {
fail_first: READ_RETRIES,
calls: 0,
};
let mut dec = DecryptingSectorSource::new(src, DecryptKeys::None);
let mut buf = vec![0u8; 2 * SECTOR_BYTES];
let ok = read_batch(&mut dec, 0, 2, &mut buf);
assert!(
ok,
"a failure that clears up within the retry budget must succeed, not hole"
);
assert_eq!(dec.inner().calls, READ_RETRIES + 1);
}
/// `report` must reflect the sink's verdict -- a sink asking to stop must
/// actually halt the run mid-file, not be swallowed.
#[test]
fn progress_sink_stop_halts_run_mid_file() {
struct StopImmediately;
impl crate::progress::Progress for StopImmediately {
fn report(&self, _p: &crate::progress::PassProgress) -> bool {
false
}
}
let good = vec![0x66u8; 4 * 2048];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00001.m2ts", 24, 5000, good, true)],
subdirs: vec![],
}],
}],
};
let mut disc = build_disc(root);
let out = TmpDir::new("progress_stop");
let sink = StopImmediately;
let opts = ExtractOptions {
progress: Some(&sink),
..Default::default()
};
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &opts)
.expect("extract does not error on a progress halt");
assert!(res.halted, "a sink returning false must halt the run");
assert!(
!res.files[0].complete,
"the in-flight file must be left incomplete, not finalized"
);
assert!(
read_out(out.path(), "BDMV/STREAM/00001.m2ts").is_none(),
"an incomplete file must not be renamed to its final name"
);
}
/// `available_space` must return the real free-space figure (`Some`) for
/// an existing directory on a platform that exposes it -- the free-space
/// pre-check in `extract_tree` silently no-ops whenever this returns
/// `None`, so a `statvfs` SUCCESS (`rc == 0`) must never be read as
/// "unavailable".
#[cfg(unix)]
#[test]
fn available_space_reports_free_bytes_on_a_real_dir() {
let out = TmpDir::new("available_space");
std::fs::create_dir_all(out.path()).unwrap();
assert!(
available_space(out.path()).is_some(),
"a real, existing directory must report Some(_) free bytes"
);
}
/// A raw control byte (below 0x20, distinct from the separately-rejected
/// NUL) in a disc-authored name must be rejected, not passed through into
/// the host filename.
#[test]
fn sanitize_rejects_control_bytes() {
assert!(
sanitize_component("a\u{1}b").is_err(),
"0x01 must be rejected"
);
assert!(
sanitize_component("a\nb").is_err(),
"0x0A (newline) must be rejected"
);
assert!(
sanitize_component("a\u{1f}b").is_err(),
"0x1F must be rejected"
);
// 0x20 (space) is NOT a control byte -- allowed mid-name (only
// trimmed if trailing).
assert!(sanitize_component("a b").is_ok());
}
/// The VTS group number must be EXACTLY 2 ASCII digits -- neither a
/// non-numeric group nor a wrong-length one is a valid `VTS_xx` group.
#[test]
fn vts_group_of_requires_exactly_two_digits() {
assert_eq!(
vts_group_of("VTS_AB_1.VOB"),
None,
"letters are not a group number"
);
assert_eq!(
vts_group_of("VTS_123_1.VOB"),
None,
"a 3-digit group is not a valid 2-digit VTS number"
);
assert_eq!(
vts_group_of("VTS_1_1.VOB"),
None,
"a 1-digit group is not valid"
);
assert_eq!(vts_group_of("VTS_01_1.VOB").as_deref(), Some("VTS_01"));
}
}