Files
libfreemkv/src/disc/mod.rs
T
Matthew Jackson 37832ac2dd hex: canonical hex->integer parsers + public strip_hex_prefix
Adds parse_hex_u16/u32/u8 and exposes strip_hex_prefix so callers stop hand-rolling
from_str_radix(trim_start_matches("0x")) — a case-sensitive strip that this module
exists to prevent. disc::aacs_disc_hash now uses strip_hex_prefix.
2026-07-17 21:25:59 -07:00

6843 lines
286 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 structure -- scan titles, streams, and sector ranges from a Blu-ray disc.
//!
//! This is the high-level API for disc content. The CLI calls this,
//! never parses MPLS/CLPI/UDF directly.
//!
//! Usage:
//! let disc = Disc::scan(&mut session)?;
//! for title in disc.titles() { ... }
//! for stream in title.streams() { ... }
mod bluray;
mod dvd;
pub(crate) mod dvd_audio_probe;
mod encrypt;
mod extract;
mod hddvd;
pub mod mapfile;
mod patch;
pub mod read_error;
mod section_recover;
mod sweep;
use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result};
use crate::sector::SectorSource;
use crate::udf;
use encrypt::HandshakeResult;
// Re-export label classification enums alongside AudioStream / SubtitleStream
// so the public surface keeps the structured metadata together. Callers map
// these to display text in their own locale.
pub use crate::labels::{LabelPurpose, LabelQualifier};
pub use extract::{ExtractOptions, ExtractResult, FileResult};
// ─── Public types ───────────────────────────────────────────────────────────
/// A scanned Blu-ray disc.
#[derive(Debug)]
pub struct Disc {
/// UDF Volume Identifier from Primary Volume Descriptor (always present)
pub volume_id: String,
/// Disc title from META/DL/bdmt_eng.xml (None if disc has no metadata)
pub meta_title: Option<String>,
/// Disc format (BD, UHD, DVD)
pub format: DiscFormat,
/// Disc capacity in sectors
pub capacity_sectors: u32,
/// Disc capacity in bytes
pub capacity_bytes: u64,
/// Number of layers (1 = single, 2 = dual)
pub layers: u8,
/// Titles sorted by duration (longest first), then playlist name
pub titles: Vec<DiscTitle>,
/// Disc region
pub region: DiscRegion,
/// AACS state -- None if disc is unencrypted or keys unavailable
pub aacs: Option<AacsState>,
/// CSS state -- None if not a CSS-encrypted DVD
pub css: Option<crate::css::CssState>,
/// Whether this disc requires decryption (AACS or CSS)
pub encrypted: bool,
/// AACS resolution error when `encrypted` is true and `aacs` is None.
/// Lets callers distinguish "no KEYDB found", "KEYDB failed to parse",
/// "disc hash not in KEYDB", etc. None when AACS resolution wasn't
/// attempted (unencrypted disc) or succeeded.
pub aacs_error: Option<crate::error::Error>,
/// CSS crack failure: `Some(Error::CssKeyMissing)` when the scan SAW
/// scrambled sectors but could NOT recover a title key (the
/// known-plaintext attack found no crackable crib, or the scrambled
/// region was unreadable). `css` is `None` in that case — but the disc is
/// genuinely encrypted, so callers MUST surface this hard error rather
/// than treat `css.is_none()` as "unencrypted" and mux scrambled MPEG as
/// plaintext garbage. `None` when no scrambled sector was seen (genuinely
/// unencrypted) or a key was recovered (`css.is_some()`). The CSS analogue
/// of [`Self::aacs_error`].
pub css_error: Option<crate::error::Error>,
/// Content format (BD transport stream vs DVD program stream)
pub content_format: ContentFormat,
}
/// Content format — determines how sectors are interpreted downstream.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContentFormat {
/// Blu-ray BD Transport Stream (192-byte packets)
BdTs,
/// MPEG-2 Program Stream — DVD (`.vob`) and HD-DVD (`.evo`). For AACS content
/// this selects the PS-aware encrypted-flag / structural checks.
MpegPs,
}
/// Disc format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DiscFormat {
/// 4K UHD Blu-ray (HEVC 2160p)
Uhd,
/// UHD Blu-ray with AACS 2.1 FMTS content — the main feature is a `.fmts`
/// clip (M2TS transport stream plus interleaved forensic variant segments).
/// A BD-tree disc (enumerated by [`Disc::scan_bluray_titles`]); distinct
/// from [`DiscFormat::Uhd`] only in the container + AACS generation.
Fmts,
/// Standard Blu-ray (1080p/1080i)
BluRay,
/// HD-DVD — `HVDVD_TS/` tree with `.evo` (Enhanced VOB, MPEG program stream)
/// clips. A tree-level peer of DVD/BD, enumerated by its own scanner.
HdDvd,
/// DVD
Dvd,
/// Unknown
Unknown,
}
/// Disc playback region.
#[derive(Debug, Clone, PartialEq)]
pub enum DiscRegion {
/// Region-free (all UHD discs, some BD/DVD)
Free,
/// Blu-ray regions (A/B/C or combination)
BluRay(Vec<BdRegion>),
/// DVD regions (1-8 or combination)
Dvd(Vec<u8>),
}
/// Blu-ray region codes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BdRegion {
/// Region A/1 -- Americas, East Asia (Japan, Korea, Southeast Asia)
A,
/// Region B/2 -- Europe, Africa, Australia, Middle East
B,
/// Region C/3 -- Central/South Asia, China, Russia
C,
}
/// A title (one MPLS playlist).
#[derive(Debug, Clone)]
pub struct DiscTitle {
/// Playlist filename (e.g. "00800.mpls")
pub playlist: String,
/// Playlist number (e.g. 800)
pub playlist_id: u16,
/// Duration in seconds
pub duration_secs: f64,
/// Total size in bytes
pub size_bytes: u64,
/// Clip references in playback order
pub clips: Vec<Clip>,
/// All streams (video, audio, subtitle, etc.)
pub streams: Vec<Stream>,
/// Chapter points
pub chapters: Vec<Chapter>,
/// Sector extents for ripping (clip LBA ranges)
pub extents: Vec<Extent>,
/// Content format for this title
pub content_format: ContentFormat,
/// Codec initialization data per stream (SPS/PPS, etc).
/// Index matches `streams`. None for streams without codec init data.
pub codec_privates: Vec<Option<Vec<u8>>>,
}
/// A clip reference within a title.
#[derive(Debug, Clone)]
pub struct Clip {
/// Clip filename without extension (e.g. "00001")
pub clip_id: String,
/// In-time in 45kHz ticks
pub in_time: u32,
/// Out-time in 45kHz ticks
pub out_time: u32,
/// Duration in seconds
pub duration_secs: f64,
/// Source packet count (from CLPI, 0 if unavailable)
pub source_packets: u32,
}
/// A stream within a title.
#[derive(Debug, Clone)]
pub enum Stream {
Video(VideoStream),
Audio(AudioStream),
Subtitle(SubtitleStream),
}
/// A video stream.
#[derive(Debug, Clone)]
pub struct VideoStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec (HEVC, H.264, VC-1, MPEG-2)
pub codec: Codec,
/// Resolution
pub resolution: Resolution,
/// Frame rate
pub frame_rate: FrameRate,
/// HDR format
pub hdr: HdrFormat,
/// Color space
pub color_space: ColorSpace,
/// Intended display aspect ratio as `(num, den)` when the coded pixels are
/// **anamorphic** (display shape ≠ pixel grid) — e.g. DVD 720x576 shown as
/// 16:9 → `Some((16, 9))`. `None` means square pixels: the display aspect
/// equals the pixel dimensions (HD/UHD, BD). Consumed by the MKV muxer to
/// write DisplayWidth/DisplayHeight; passthrough muxers (TS/M2TS) ignore it
/// because the aspect already lives in the elementary stream.
pub display_aspect: Option<(u32, u32)>,
/// Whether this is a secondary stream (PiP, Dolby Vision EL)
pub secondary: bool,
/// Extra label (e.g. "Dolby Vision EL")
pub label: String,
/// CICP colour signalling (matrix, transfer, primaries, full_range) MEASURED
/// from the bitstream — HEVC/H.264 VUI `colour_description` or MPEG-2
/// `sequence_display_extension`. `Some(...)` takes precedence over the
/// coarse `color_space` enum (a playlist nibble / PAL-NTSC guess); `None`
/// means the bitstream did not state it, so the enum-derived triplet is used.
/// Codes are ITU-T H.273 (CICP); `range` is 1 = limited/TV, 2 = full.
pub measured_cicp: Option<MeasuredCicp>,
}
/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
/// view** — the paired substream of the AVC base view. Set by the BD scan
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
/// per-frame `BlockAdditional`). Single source of truth for the contract.
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";
impl VideoStream {
/// Whether this video stream is the MVC dependent (right-eye) view — the
/// 3D substream that the muxer merges into the base track as a per-frame
/// `BlockAdditional` rather than emitting as an independent track.
pub fn is_mvc_dependent(&self) -> bool {
self.label == MVC_DEPENDENT_LABEL
}
}
/// Measured CICP colour signalling read directly from a video elementary stream
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MeasuredCicp {
/// MatrixCoefficients (ITU-T H.273 Table 4).
pub matrix: u8,
/// TransferCharacteristics (ITU-T H.273 Table 3).
pub transfer: u8,
/// ColourPrimaries (ITU-T H.273 Table 2).
pub primaries: u8,
/// Range: 1 = limited (studio/TV), 2 = full. Matroska Colour/Range values.
pub range: u8,
}
/// An audio stream.
#[derive(Debug, Clone)]
pub struct AudioStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
pub codec: Codec,
/// Channel layout
pub channels: AudioChannels,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Sample rate
pub sample_rate: SampleRate,
/// Whether this is a secondary stream (commentary)
pub secondary: bool,
/// Stream purpose (commentary / descriptive / score / IME / normal).
/// Callers translate this to display text in their own locale.
pub purpose: LabelPurpose,
/// Codec / variant text (e.g. "Dolby TrueHD 5.1", "(US)").
/// NEVER contains English purpose words — see `purpose` for that.
pub label: String,
}
/// A subtitle stream.
#[derive(Debug, Clone)]
pub struct SubtitleStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec (PGS)
pub codec: Codec,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Whether this is a forced subtitle
pub forced: bool,
/// Subtitle qualifier (SDH / descriptive service / forced / none).
/// Callers translate this to display text in their own locale.
pub qualifier: LabelQualifier,
/// Pre-formatted codec private data (e.g. VobSub .idx palette header)
pub codec_data: Option<Vec<u8>>,
}
/// Video/audio codec.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Codec {
// Video
Hevc,
H264,
Vc1,
Mpeg2,
Mpeg1,
Av1,
// Audio
TrueHd,
DtsHdMa,
DtsHdHr,
Dts,
Ac3,
Ac3Plus,
Lpcm,
Aac,
Mp2,
Mp3,
Flac,
Opus,
// Subtitle
Pgs,
DvdSub,
Srt,
Ssa,
// Unknown
Unknown(u8),
}
/// Video resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
/// 480i (720x480 interlaced) — NTSC DVD
R480i,
/// 480p (720x480 progressive)
R480p,
/// 576i (720x576 interlaced) — PAL DVD
R576i,
/// 576p (720x576 progressive)
R576p,
/// 720p (1280x720 progressive) — some Blu-rays
R720p,
/// 1080i (1920x1080 interlaced) — broadcast, some BD
R1080i,
/// 1080p (1920x1080 progressive) — standard Blu-ray
R1080p,
/// 2160p (3840x2160 progressive) — 4K UHD Blu-ray
R2160p,
/// 4320p (7680x4320 progressive) — 8K, future-proof
R4320p,
/// Unknown resolution
Unknown,
}
/// Video frame rate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FrameRate {
/// 23.976 fps — film-based BD/UHD (NTSC pulldown)
F23_976,
/// 24.000 fps — true film rate
F24,
/// 25.000 fps — PAL standard
F25,
/// 29.970 fps — NTSC standard
F29_97,
/// 30.000 fps
F30,
/// 50.000 fps — PAL high frame rate
F50,
/// 59.940 fps — NTSC high frame rate
F59_94,
/// 60.000 fps
F60,
/// Unknown frame rate
Unknown,
}
/// Audio channel layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioChannels {
/// 1.0 mono
Mono,
/// 2.0 stereo
Stereo,
/// 2.1 (stereo + LFE)
Stereo21,
/// 4.0 quadraphonic
Quad,
/// 5.0 surround (no LFE)
Surround50,
/// 5.1 surround — standard BD/DVD surround
Surround51,
/// 6.1 surround (DTS-ES, Dolby EX)
Surround61,
/// 7.1 surround — UHD Atmos beds, DTS:X
Surround71,
/// Unknown channel layout
Unknown,
}
/// Audio sample rate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleRate {
/// 44.1 kHz — CD audio (rare on disc)
S44_1,
/// 48 kHz — standard BD/DVD/UHD audio
S48,
/// 88.2 kHz — 44.1 kHz-family high-res TrueHD (music BD)
S88_2,
/// 96 kHz — high-res BD audio
S96,
/// 176.4 kHz — 44.1 kHz-family high-res TrueHD (music BD)
S176_4,
/// 192 kHz — highest BD audio (LPCM)
S192,
/// 48/96 kHz combo (secondary audio resampled)
S48_96,
/// 48/192 kHz combo (secondary audio resampled)
S48_192,
/// Unknown sample rate
Unknown,
}
/// HDR format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HdrFormat {
Sdr,
Hdr10,
Hdr10Plus,
DolbyVision,
Hlg,
}
/// Color space.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColorSpace {
Bt709,
Bt2020,
/// SD PAL/576-line colorimetry (ITU-R BT.470 System B/G — primaries 5,
/// transfer 5, matrix 5). DVDs are SD, not HD: stamping BT.709 mis-tags
/// their colour.
Bt470bg,
/// SD NTSC/480-line colorimetry (SMPTE 170M / BT.601-525 — primaries 6,
/// transfer 6, matrix 6).
Smpte170m,
Unknown,
}
/// A chapter point within a title.
#[derive(Debug, Clone)]
pub struct Chapter {
/// Chapter start time in seconds
pub time_secs: f64,
/// Chapter name — a bare 1-based index ("1", "2", …). The library
/// emits no localized prose; consuming apps prepend any "Chapter "
/// prefix in the user's language.
pub name: String,
}
/// Default chapter name for the 0-based chapter index `i`: the bare
/// 1-based ordinal as a string. Keeps chapter labelling language-neutral
/// (apps localize) and gives BD and DVD a single source of truth.
pub(crate) fn chapter_name(i: usize) -> String {
(i + 1).to_string()
}
/// A contiguous range of sectors on disc.
#[derive(Debug, Clone, Copy)]
pub struct Extent {
pub start_lba: u32,
pub sector_count: u32,
}
/// Union a set of extents into sorted, merged, disjoint `(start_lba,
/// sector_count)` ranges — the pure, testable core of
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u32)> {
let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect();
ranges.sort_by_key(|r| r.0);
crate::udf::merge_ranges(&ranges)
}
/// Correct a title's TrueHD audio-stream metadata by probing the first
/// decrypted access units — channel count, real sample rate, and Atmos
/// detection in a single major-sync read. The MPLS descriptors declare the BASE
/// layout (often 5.1 / a container-guessed rate) even for a 7.1/Atmos TrueHD
/// track; the truth is in the MLP major sync. `reader` must yield DECRYPTED
/// sectors (the m2ts is AACS-encrypted, so this can only run at mux time, not
/// scan). Reads a bounded window of the title's first extent.
///
/// Corrections, each individually guarded so a malformed field never writes a
/// wrong header:
/// - **Channels**: from the presentation channel masks (as before).
/// - **Sample rate**: from the whitelisted rate nibble; left untouched on an
/// unknown rate or no major sync.
/// - **Atmos**: when a 4th substream is detected AND the stream still carries
/// the basic descriptor label, the label is promoted to the Atmos form;
/// richer editorial labels (e.g. an existing "Dolby Atmos") are left intact.
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
use crate::mux::codec::truehd::{
truehd_channels, truehd_sample_rate_hz, truehd_sync_info_from_stream,
};
let pids: Vec<u16> = title
.streams
.iter()
.filter_map(|s| match s {
Stream::Audio(a) if matches!(a.codec, Codec::TrueHd) => Some(a.pid),
_ => None,
})
.collect();
if pids.is_empty() {
return;
}
let Some(ext) = title.extents.first() else {
return;
};
// Bounded probe: up to 8 MiB from the start of the title — enough for the
// first interleaved TrueHD major sync of each stream.
const PROBE_SECTORS: u32 = 4096;
let n = ext.sector_count.min(PROBE_SECTORS) as u16;
if n == 0 {
return;
}
let mut buf = vec![0u8; n as usize * 2048];
// Anchor the AACS unit-alignment gate to the title's encrypted-region start
// before probing. Without this a `DecryptingSectorSource` falls back to an
// absolute `start_lba % 3` gate; a non-3-aligned `ext.start_lba` then trips
// DecryptFailed on the very first probe read, so the TrueHD channel count is
// never corrected and Atmos / 7.1 is silently understated as 5.1. No-op for
// CSS / unencrypted sources (set_unit_base default is a no-op).
reader.set_unit_base(ext.start_lba);
if reader
.read_sectors(ext.start_lba, n, &mut buf, true)
.is_err()
{
return;
}
let mut demux = crate::mux::ts::TsDemuxer::new(&pids);
let mut payloads: std::collections::HashMap<u16, Vec<u8>> = std::collections::HashMap::new();
for pes in demux.feed(&buf).into_iter().chain(demux.flush()) {
payloads
.entry(pes.pid)
.or_default()
.extend_from_slice(&pes.data);
}
for s in title.streams.iter_mut() {
let Stream::Audio(a) = s else { continue };
if !matches!(a.codec, Codec::TrueHd) {
continue;
}
let Some(payload) = payloads.get(&a.pid) else {
continue;
};
// One major-sync read yields channels, sample rate and the Atmos signal.
let Some(info) = truehd_sync_info_from_stream(payload) else {
continue;
};
// Whether the label is still the plain descriptor (no richer editorial
// label). Captured against the CURRENT channels before any correction so
// a label promotion only happens when nothing editorial is present.
let was_basic =
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
// (1) Channels — only when the major sync resolves a different layout.
if let Some(count) = truehd_channels(info.format_info) {
let new_ch = AudioChannels::from_count(count);
if new_ch != AudioChannels::Unknown && new_ch != a.channels {
a.channels = new_ch;
}
}
// (2) Sample rate — whitelisted rates only; an unknown nibble or a rate
// that maps to no enum variant leaves the container value untouched
// (never write a wrong SamplingFrequency).
if let Some(hz) = truehd_sample_rate_hz(info.format_info) {
let new_sr = SampleRate::from_hz(hz);
if new_sr != SampleRate::Unknown && new_sr != a.sample_rate {
a.sample_rate = new_sr;
}
}
// (3) Label — refresh to the corrected channels; promote to the Atmos
// form only when the stream carried the basic descriptor (no editorial
// Atmos already) AND a 4th substream was positively detected.
if was_basic {
a.label = if info.is_atmos == Some(true) {
crate::labels::generate_audio_label_atmos(&a.codec, &a.channels, a.secondary)
} else {
crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary)
};
}
}
}
/// Calculate how many bytes of bad/unreadable data fall within a title's extents.
/// `pub(crate)` so autorip can use it for main-movie lost_ms computation.
pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 {
if bad_ranges.is_empty() || title.extents.is_empty() {
return 0;
}
// Overlap each bad range against every extent individually. A single
// bounding box (first extent start → last extent end) would count
// bad sectors in inter-extent gaps (other titles' data, BDMV
// metadata) as bad bytes in this title, over-counting lost_ms for
// titles with non-contiguous clips.
let mut total: u64 = 0;
for ext in &title.extents {
let es = (ext.start_lba as u64) * 2048;
let ee = ((ext.start_lba as u64) + (ext.sector_count as u64)) * 2048;
for (pos, size) in bad_ranges {
let r_start = *pos;
let r_end = pos.saturating_add(*size);
let overlap_start = r_start.max(es);
let overlap_end = r_end.min(ee);
total = total.saturating_add(overlap_end.saturating_sub(overlap_start));
}
}
total
}
/// Byte offset of `lba` within `title`'s extents (concatenated in order), or
/// `None` if the LBA falls outside every extent. The title is a virtual
/// contiguous stream; this maps a disc LBA into that stream so a chapter/time
/// lookup can place it. (Moved from autorip — clients must not re-derive it.)
fn byte_offset_in_title(lba: u32, title: &DiscTitle) -> Option<u64> {
use crate::consts::SECTOR_BYTES_U64;
let mut cumulative = 0u64;
for ext in &title.extents {
if lba >= ext.start_lba && lba < ext.start_lba + ext.sector_count {
return Some(cumulative + (lba - ext.start_lba) as u64 * SECTOR_BYTES_U64);
}
cumulative += ext.sector_count as u64 * SECTOR_BYTES_U64;
}
None
}
/// The 1-based chapter index + movie-time offset a byte position within a title
/// falls in, or `None` if the title has no size/chapters. Pure helper for the
/// range→chapter/time annotation the progress drilldown ([`locate_ranges`])
/// renders — also used by autorip's done-card range annotation. (Formerly lived
/// in the removed standalone sector-verify module.)
pub fn chapter_at_offset(
chapters: &[Chapter],
byte_offset: u64,
duration_secs: f64,
total_bytes: u64,
) -> Option<(usize, f64)> {
if total_bytes == 0 || chapters.is_empty() {
return None;
}
let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
let mut chapter_idx = 0;
for (i, ch) in chapters.iter().enumerate() {
if ch.time_secs <= time_secs {
chapter_idx = i;
} else {
break;
}
}
Some((chapter_idx + 1, time_secs))
}
/// The 1-based chapter + movie-time offset an LBA falls in, or `(None, None)`
/// if it isn't inside the title.
fn range_chapter(lba: u32, title: &DiscTitle) -> (Option<u32>, Option<f64>) {
if let Some(byte_offset) = byte_offset_in_title(lba, title) {
if let Some((ch, t)) = chapter_at_offset(
&title.chapters,
byte_offset,
title.duration_secs,
title.size_bytes,
) {
return (Some(ch as u32), Some(t));
}
}
(None, None)
}
/// Annotate raw bad byte-ranges with chapter + movie time, producing the
/// rendered drilldown ([`crate::progress::LocatedProgress`]) a client draws.
/// `raw` is the mapfile's `(byte_pos, byte_len)` set for whichever statuses the
/// caller cares about (the live "Maybe" set during a patch, or terminal
/// `Unreadable` for the verdict). The list is sorted largest-movie-time first
/// and capped at 50; `truncated` reports the overflow. `bps` (title bytes/sec)
/// is derived from the title so callers don't thread it.
///
/// This is the single place range→chapter/time annotation happens; autorip used
/// to own it and read the mapfile to do so. Now the library computes it from
/// its in-memory mapfile + title, and the client renders the result verbatim.
pub fn locate_ranges(raw: &[(u64, u64)], title: &DiscTitle) -> crate::progress::LocatedProgress {
use crate::consts::{MILLIS_PER_SEC, SECTOR_BYTES_U64};
use crate::progress::{LocatedProgress, LocatedRange};
const MAX_LOCATED: usize = 50;
let bps = if title.duration_secs > 0.0 {
title.size_bytes as f64 / title.duration_secs
} else {
0.0
};
let num_ranges = raw.len() as u32;
let mut ranges: Vec<LocatedRange> = raw
.iter()
.map(|(pos, size)| {
let lba = pos / SECTOR_BYTES_U64;
let count = (size / SECTOR_BYTES_U64) as u32;
let duration_ms = if bps > 0.0 {
(*size as f64) / bps * MILLIS_PER_SEC
} else {
0.0
};
let (chapter, time_offset_secs) = range_chapter(lba as u32, title);
LocatedRange {
lba,
count,
duration_ms,
chapter,
time_offset_secs,
}
})
.collect();
ranges.sort_by(|a, b| {
b.duration_ms
.partial_cmp(&a.duration_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
let largest_gap_ms = ranges.first().map(|r| r.duration_ms).unwrap_or(0.0);
let truncated = ranges.len().saturating_sub(MAX_LOCATED) as u32;
ranges.truncate(MAX_LOCATED);
// At-risk movie time = duration of the ranges that intersect the title
// extents (the others are menus/extras → no movie impact).
let main_at_risk_ms = if bps > 0.0 {
bytes_bad_in_title(title, raw) as f64 * MILLIS_PER_SEC / bps
} else {
0.0
};
LocatedProgress {
ranges,
num_ranges,
truncated,
main_at_risk_ms,
largest_gap_ms,
}
}
/// One-shot progress snapshot built from a mapfile on disk plus the title. The
/// library reads + parses the mapfile HERE so a client (autorip) gets a
/// fully-rendered [`crate::progress::PassProgress`] without ever touching
/// mapfile internals — used for the pass-boundary paint (before the live
/// callback stream begins) and the terminal done-card verdict. Returns `None`
/// if the mapfile can't be read. `work_done`/`work_total` are `0`: this is a
/// point-in-time snapshot, not a per-pass progress tick.
pub fn progress_snapshot_from_mapfile(
mapfile_path: &std::path::Path,
title: Option<&DiscTitle>,
kind: crate::progress::PassKind,
bytes_total_disc: u64,
) -> Option<crate::progress::PassProgress> {
use mapfile::SectorStatus::{NonScraped, NonTrimmed, Unreadable};
let map = mapfile::Mapfile::load(mapfile_path).ok()?;
let stats = map.stats();
// MAYBE set = not-yet-good (NonTrimmed/NonScraped/Unreadable), excluding
// NonTried (the unread remainder) — same set the live patch emitter uses.
let maybe = map.ranges_with(&[NonTrimmed, NonScraped, Unreadable]);
let located = title.map(|t| locate_ranges(&maybe, t)).unwrap_or_default();
let main_bad = title.map(|t| bytes_bad_in_title(t, &maybe)).unwrap_or(0);
Some(crate::progress::PassProgress {
kind,
work_done: 0,
work_total: 0,
bytes_good_total: stats.bytes_good,
bytes_unreadable_total: stats.bytes_unreadable,
bytes_pending_total: stats.bytes_pending,
bytes_retryable_total: stats.bytes_retryable,
bytes_total_disc,
disc_duration_secs: title.map(|t| t.duration_secs),
bytes_bad_in_main_title: main_bad,
main_title_duration_secs: title.map(|t| t.duration_secs),
main_title_size_bytes: title.map(|t| t.size_bytes),
located,
})
}
// ─── Display helpers ────────────────────────────────────────────────────────
impl Codec {
/// Human-readable display name.
pub fn name(&self) -> &'static str {
for (_, name, v) in Self::ALL_CODECS {
if v == self {
return name;
}
}
"Unknown"
}
/// Compact identifier for serialization (lowercase, no spaces).
pub fn id(&self) -> &'static str {
for (id, _, v) in Self::ALL_CODECS {
if v == self {
return id;
}
}
"unknown"
}
const ALL_CODECS: &[(&'static str, &'static str, Codec)] = &[
("hevc", "HEVC", Codec::Hevc),
("h264", "H.264", Codec::H264),
("vc1", "VC-1", Codec::Vc1),
("mpeg2", "MPEG-2", Codec::Mpeg2),
("mpeg1", "MPEG-1", Codec::Mpeg1),
("av1", "AV1", Codec::Av1),
("truehd", "TrueHD", Codec::TrueHd),
("dtshd_ma", "DTS-HD MA", Codec::DtsHdMa),
("dtshd_hr", "DTS-HD HR", Codec::DtsHdHr),
("dts", "DTS", Codec::Dts),
("ac3", "AC-3", Codec::Ac3),
("eac3", "EAC-3", Codec::Ac3Plus),
("lpcm", "LPCM", Codec::Lpcm),
("aac", "AAC", Codec::Aac),
("mp2", "MP2", Codec::Mp2),
("mp3", "MP3", Codec::Mp3),
("flac", "FLAC", Codec::Flac),
("opus", "Opus", Codec::Opus),
("pgs", "PGS", Codec::Pgs),
("dvdsub", "DVD Subtitle", Codec::DvdSub),
("srt", "SRT", Codec::Srt),
("ssa", "SSA", Codec::Ssa),
];
pub(crate) fn from_coding_type(ct: u8) -> Self {
use crate::consts::coding_type as c;
match ct {
c::HEVC => Codec::Hevc,
// 0x1B base-view AVC and 0x20 MVC dependent-view (Blu-ray 3D right
// eye) are both H.264. Mapping 0x20 to video is what makes the PMT
// scan enumerate the dependent view as a second H.264 stream (its
// own PID in the SSIF) instead of dropping it — the basis of 3D.
c::H264 | c::H264_MVC => Codec::H264,
c::VC1 => Codec::Vc1,
c::MPEG2_VIDEO => Codec::Mpeg2,
c::TRUEHD => Codec::TrueHd,
c::DTS_HD_MA => Codec::DtsHdMa,
c::DTS_HD_HR => Codec::DtsHdHr,
c::DTS => Codec::Dts,
c::AC3 => Codec::Ac3,
c::AC3_PLUS | c::AC3_PLUS_SECONDARY => Codec::Ac3Plus,
c::LPCM => Codec::Lpcm,
// DTS_HD_MA (primary 0x86) / DTS_HD_SECONDARY (0xA2) are the
// DTS-HD MA lossless pair, parallel to AC3/AC3_PLUS_SECONDARY for
// AC-3. The secondary code is lossless MA, not lossy HR.
c::DTS_HD_SECONDARY => Codec::DtsHdMa,
// PG (0x90) = Presentation Graphics (subtitles). IG (0x91, menus)
// and TEXT_SUBTITLE (0x92) are distinct HDMV coding types and are
// NOT PG subtitle streams; only PG maps to Pgs. IG falls through to
// Unknown so the PMT/STN walker drops it rather than surfacing a
// bogus PGS subtitle track for a menu ES.
c::PG => Codec::Pgs,
ct => Codec::Unknown(ct),
}
}
/// Broad stream category for a codec. Used by demuxers to decide
/// whether a PMT/STN entry becomes a video, audio, or subtitle
/// `Stream` without duplicating per-codec knowledge.
pub fn kind(&self) -> CodecKind {
match self {
Codec::Hevc | Codec::H264 | Codec::Vc1 | Codec::Mpeg2 | Codec::Mpeg1 | Codec::Av1 => {
CodecKind::Video
}
Codec::TrueHd
| Codec::DtsHdMa
| Codec::DtsHdHr
| Codec::Dts
| Codec::Ac3
| Codec::Ac3Plus
| Codec::Lpcm
| Codec::Aac
| Codec::Mp2
| Codec::Mp3
| Codec::Flac
| Codec::Opus => CodecKind::Audio,
Codec::Pgs | Codec::DvdSub | Codec::Srt | Codec::Ssa => CodecKind::Subtitle,
Codec::Unknown(_) => CodecKind::Unknown,
}
}
}
/// Broad category of a [`Codec`] — video / audio / subtitle / unknown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodecKind {
Video,
Audio,
Subtitle,
Unknown,
}
impl std::fmt::Display for Codec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
impl Resolution {
/// Parse from MPLS video_format byte.
pub fn from_video_format(vf: u8) -> Self {
match vf {
1 => Resolution::R480i,
2 => Resolution::R576i,
3 => Resolution::R480p,
4 => Resolution::R1080i,
5 => Resolution::R720p,
6 => Resolution::R1080p,
7 => Resolution::R576p,
8 => Resolution::R2160p,
other => {
tracing::warn!(video_format = other, "unknown MPLS video_format byte");
Resolution::Unknown
}
}
}
/// Pixel dimensions (width, height).
pub fn pixels(&self) -> (u32, u32) {
match self {
Resolution::R480i | Resolution::R480p => (720, 480),
Resolution::R576i | Resolution::R576p => (720, 576),
Resolution::R720p => (1280, 720),
Resolution::R1080i | Resolution::R1080p => (1920, 1080),
Resolution::R2160p => (3840, 2160),
Resolution::R4320p => (7680, 4320),
Resolution::Unknown => (1920, 1080),
}
}
/// True if this is a UHD (4K+) resolution.
pub fn is_uhd(&self) -> bool {
matches!(self, Resolution::R2160p | Resolution::R4320p)
}
/// True if this is an interlaced resolution (the `R*i` variants).
pub fn is_interlaced(&self) -> bool {
matches!(
self,
Resolution::R480i | Resolution::R576i | Resolution::R1080i
)
}
/// True if this is an HD (720p+) resolution.
pub fn is_hd(&self) -> bool {
!matches!(
self,
Resolution::R480i
| Resolution::R480p
| Resolution::R576i
| Resolution::R576p
| Resolution::Unknown
)
}
/// True if this is an SD (480/576) resolution.
pub fn is_sd(&self) -> bool {
matches!(
self,
Resolution::R480i | Resolution::R480p | Resolution::R576i | Resolution::R576p
)
}
/// Parse from pixel height (e.g. from MKV track).
pub fn from_height(h: u32) -> Self {
match h {
0..=480 => Resolution::R480p,
481..=576 => Resolution::R576p,
577..=720 => Resolution::R720p,
721..=1080 => Resolution::R1080p,
1081..=2160 => Resolution::R2160p,
_ => Resolution::R4320p,
}
}
}
// Display for Resolution is generated by enum_str! macro
impl FrameRate {
/// Parse from MPLS video_rate byte.
pub fn from_video_rate(vr: u8) -> Self {
match vr {
1 => FrameRate::F23_976,
2 => FrameRate::F24,
3 => FrameRate::F25,
4 => FrameRate::F29_97,
5 => FrameRate::F30,
6 => FrameRate::F50,
7 => FrameRate::F59_94,
8 => FrameRate::F60,
other => {
tracing::warn!(video_rate = other, "unknown MPLS video_rate byte");
FrameRate::Unknown
}
}
}
/// Frame rate as (numerator, denominator) for precise representation.
pub fn as_fraction(&self) -> (u32, u32) {
match self {
FrameRate::F23_976 => (24000, 1001),
FrameRate::F24 => (24, 1),
FrameRate::F25 => (25, 1),
FrameRate::F29_97 => (30000, 1001),
FrameRate::F30 => (30, 1),
FrameRate::F50 => (50, 1),
FrameRate::F59_94 => (60000, 1001),
FrameRate::F60 => (60, 1),
FrameRate::Unknown => (0, 1),
}
}
}
// Display for FrameRate is generated by enum_str! macro
impl AudioChannels {
/// Parse from MPLS audio_format byte.
pub fn from_audio_format(af: u8) -> Self {
match af {
1 => AudioChannels::Mono,
3 => AudioChannels::Stereo,
6 => AudioChannels::Surround51,
12 => AudioChannels::Surround71,
other => {
tracing::warn!(audio_format = other, "unknown MPLS audio_format byte");
AudioChannels::Unknown
}
}
}
/// Channel count as a number.
pub fn count(&self) -> u8 {
match self {
AudioChannels::Mono => 1,
AudioChannels::Stereo => 2,
AudioChannels::Stereo21 => 3,
AudioChannels::Quad => 4,
AudioChannels::Surround50 => 5,
AudioChannels::Surround51 => 6,
AudioChannels::Surround61 => 7,
AudioChannels::Surround71 => 8,
AudioChannels::Unknown => 6,
}
}
/// Parse from channel count number.
pub fn from_count(n: u8) -> Self {
match n {
1 => AudioChannels::Mono,
2 => AudioChannels::Stereo,
3 => AudioChannels::Stereo21,
4 => AudioChannels::Quad,
5 => AudioChannels::Surround50,
6 => AudioChannels::Surround51,
7 => AudioChannels::Surround61,
8 => AudioChannels::Surround71,
_ => AudioChannels::Unknown,
}
}
}
// Display for AudioChannels is generated by enum_str! macro
impl SampleRate {
/// Parse from MPLS audio_rate byte.
pub fn from_audio_rate(ar: u8) -> Self {
match ar {
1 => SampleRate::S48,
4 => SampleRate::S96,
5 => SampleRate::S192,
12 => SampleRate::S48_192,
14 => SampleRate::S48_96,
other => {
tracing::warn!(audio_rate = other, "unknown MPLS audio_rate byte");
SampleRate::Unknown
}
}
}
/// Sample rate in Hz (primary rate for combo rates).
pub fn hz(&self) -> f64 {
match self {
SampleRate::S44_1 => 44100.0,
SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
SampleRate::S88_2 => 88200.0,
SampleRate::S96 => 96000.0,
SampleRate::S176_4 => 176400.0,
SampleRate::S192 => 192000.0,
SampleRate::Unknown => 48000.0,
}
}
/// Parse from Hz value.
pub fn from_hz(hz: u32) -> Self {
match hz {
44100 => SampleRate::S44_1,
48000 => SampleRate::S48,
88200 => SampleRate::S88_2,
96000 => SampleRate::S96,
176400 => SampleRate::S176_4,
192000 => SampleRate::S192,
_ => SampleRate::Unknown,
}
}
}
// Display for SampleRate is generated by enum_str! macro
impl HdrFormat {
pub fn name(&self) -> &'static str {
match self {
HdrFormat::Sdr => "SDR",
HdrFormat::Hdr10 => "HDR10",
HdrFormat::Hdr10Plus => "HDR10+",
HdrFormat::DolbyVision => "Dolby Vision",
HdrFormat::Hlg => "HLG",
}
}
const ALL_HDR: &[(&'static str, HdrFormat)] = &[
("sdr", HdrFormat::Sdr),
("hdr10", HdrFormat::Hdr10),
("hdr10+", HdrFormat::Hdr10Plus),
("dv", HdrFormat::DolbyVision),
("hlg", HdrFormat::Hlg),
];
/// Compact identifier for serialization.
pub fn id(&self) -> &'static str {
for (id, v) in Self::ALL_HDR {
if v == self {
return id;
}
}
"sdr"
}
}
impl std::fmt::Display for HdrFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
impl ColorSpace {
pub fn name(&self) -> &'static str {
match self {
ColorSpace::Bt709 => "BT.709",
ColorSpace::Bt2020 => "BT.2020",
ColorSpace::Bt470bg => "BT.470BG",
ColorSpace::Smpte170m => "SMPTE 170M",
ColorSpace::Unknown => "",
}
}
const ALL_CS: &[(&'static str, ColorSpace)] = &[
("bt709", ColorSpace::Bt709),
("bt2020", ColorSpace::Bt2020),
("bt470bg", ColorSpace::Bt470bg),
("smpte170m", ColorSpace::Smpte170m),
("unknown", ColorSpace::Unknown),
];
/// Compact identifier for serialization (round-trips via `FromStr`).
pub fn id(&self) -> &'static str {
for (id, v) in Self::ALL_CS {
if v == self {
return id;
}
}
"unknown"
}
}
impl std::fmt::Display for ColorSpace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
impl std::str::FromStr for ColorSpace {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
for (id, v) in ColorSpace::ALL_CS {
if *id == s {
return Ok(*v);
}
}
// Also accept display names (e.g. "BT.2020").
for (_id, v) in ColorSpace::ALL_CS {
if ColorSpace::name(v) == s {
return Ok(*v);
}
}
Ok(ColorSpace::Unknown)
}
}
// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
//
// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
// and id() all derive from this one table — no string appears twice.
macro_rules! enum_str {
($name:ident, $default:expr, [ $( ($s:expr, $v:expr) ),* $(,)? ]) => {
impl $name {
const ALL: &[(&'static str, $name)] = &[ $( ($s, $v), )* ];
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (s, v) in $name::ALL {
if v == self { return f.write_str(s); }
}
// The only variant not in ALL is the Unknown fallback (kept out
// of ALL so FromStr("unknown") round-trips to it via $default
// without ALL gaining a duplicate key). Display it visibly as
// "unknown" rather than an empty string, which produced blank
// metadata in labels and logs.
f.write_str("unknown")
}
}
impl std::str::FromStr for $name {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
for (k, v) in $name::ALL {
if *k == s { return Ok(*v); }
}
Ok($default)
}
}
};
}
enum_str!(
Resolution,
Resolution::Unknown,
[
("480i", Resolution::R480i),
("480p", Resolution::R480p),
("576i", Resolution::R576i),
("576p", Resolution::R576p),
("720p", Resolution::R720p),
("1080i", Resolution::R1080i),
("1080p", Resolution::R1080p),
("2160p", Resolution::R2160p),
("4320p", Resolution::R4320p),
]
);
enum_str!(
FrameRate,
FrameRate::Unknown,
[
("23.976", FrameRate::F23_976),
("24", FrameRate::F24),
("25", FrameRate::F25),
("29.97", FrameRate::F29_97),
("30", FrameRate::F30),
("50", FrameRate::F50),
("59.94", FrameRate::F59_94),
("60", FrameRate::F60),
]
);
enum_str!(
AudioChannels,
AudioChannels::Unknown,
[
("mono", AudioChannels::Mono),
("stereo", AudioChannels::Stereo),
("2.1", AudioChannels::Stereo21),
("4.0", AudioChannels::Quad),
("5.0", AudioChannels::Surround50),
("5.1", AudioChannels::Surround51),
("6.1", AudioChannels::Surround61),
("7.1", AudioChannels::Surround71),
]
);
enum_str!(
SampleRate,
SampleRate::Unknown,
[
("44.1kHz", SampleRate::S44_1),
("48kHz", SampleRate::S48),
("88.2kHz", SampleRate::S88_2),
("96kHz", SampleRate::S96),
("176.4kHz", SampleRate::S176_4),
("192kHz", SampleRate::S192),
("48/96kHz", SampleRate::S48_96),
("48/192kHz", SampleRate::S48_192),
]
);
impl std::str::FromStr for Codec {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
for (id, _, v) in Codec::ALL_CODECS {
if *id == s {
return Ok(*v);
}
}
Ok(Codec::Unknown(0))
}
}
impl std::str::FromStr for HdrFormat {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
for (id, v) in HdrFormat::ALL_HDR {
if *id == s {
return Ok(*v);
}
}
// Also accept display names
for (_id, v) in HdrFormat::ALL_HDR {
if HdrFormat::name(v) == s {
return Ok(*v);
}
}
// An unrecognised string is an error, not silently SDR. ("sdr"/"SDR"
// already matched above.) Callers that want SDR-on-unknown opt in
// explicitly with `.unwrap_or(HdrFormat::Sdr)` (e.g. mux/meta.rs).
Err(())
}
}
impl DiscTitle {
/// Empty DiscTitle with no streams.
pub fn empty() -> Self {
Self {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
/// Duration formatted as "Xh Ym"
pub fn duration_display(&self) -> String {
let hrs = (self.duration_secs / 3600.0) as u32;
let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
format!("{hrs}h {mins:02}m")
}
/// Size in GB
pub fn size_gb(&self) -> f64 {
self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
}
/// Total sectors across all extents
pub fn total_sectors(&self) -> u64 {
self.extents.iter().map(|e| e.sector_count as u64).sum()
}
}
// ─── Encryption ─────────────────────────────────────────────────────────────
/// AACS decryption state for a disc.
pub struct AacsState {
/// AACS version (1 or 2)
pub version: u8,
/// Whether bus encryption is enabled (always true for AACS 2.0 / UHD)
pub bus_encryption: bool,
/// MKB version from disc (e.g. 68, 77)
pub mkb_version: Option<u32>,
/// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
pub disc_hash: String,
/// How keys were resolved
pub key_source: KeyOrigin,
/// Volume Unique Key (16 bytes). `None` when keys were resolved
/// via the [`KeyOrigin::KeyDbUnitKeys`] path — that source delivers
/// pre-decrypted unit keys without a VUK to derive them from.
pub vuk: Option<[u8; 16]>,
/// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
pub read_data_key: Option<[u8; 16]>,
/// Volume ID (16 bytes) -- from SCSI handshake
pub volume_id: [u8; 16],
/// Raw `Unit_Key_RO.inf` bytes (encrypted unit keys + CPS map). Stashed at
/// scan so an external resolver (key-resolver) can derive the unit keys
/// from a VUK without re-reading the disc. Empty when not captured.
pub uk_ro: Vec<u8>,
/// Raw MKB bytes (`MKB_RO.inf`). Stashed at scan so an external resolver can
/// walk it (device/processing key → media key). Empty when not captured.
pub mkb: Vec<u8>,
}
// Redacting `Debug`: `AacsState` is crate-root re-exported and reachable via the
// public `Disc.aacs` field; it carries VUK / unit keys / read-data (bus) key /
// volume id / raw .inf + MKB. Print only non-secret shape; redact every
// key/secret field. Guarded by `aacs_state_and_key_debug_are_redacted`.
impl std::fmt::Debug for AacsState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AacsState")
.field("version", &self.version)
.field("bus_encryption", &self.bus_encryption)
.field("mkb_version", &self.mkb_version)
.field("disc_hash", &self.disc_hash)
.field("key_source", &self.key_source)
.field("vuk", &self.vuk.map(|_| "<redacted>"))
.field("unit_keys_len", &self.unit_keys.len())
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
.field("volume_id", &"<redacted>")
.field("uk_ro_len", &self.uk_ro.len())
.field("mkb_len", &self.mkb.len())
.finish()
}
}
/// How AACS keys were resolved. Variants are ordered root-of-trust →
/// per-disc-leaf, matching the resolver's path-try order: the resolver
/// attempts derivation from the strongest input it has first and falls
/// back toward pre-computed per-disc material.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeyOrigin {
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
/// No VUK present in the entry — `AacsState::vuk` is `None`.
KeyDbUnitKeys,
/// Unit key supplied directly by the caller (the external Unit Key path).
/// No keydb, no derivation — `AacsState::vuk` is `None`.
ExternalUk,
}
impl KeyOrigin {
pub fn name(&self) -> &'static str {
match self {
KeyOrigin::DeviceKey => "MKB + device key",
KeyOrigin::ProcessingKey => "MKB + processing key",
KeyOrigin::KeyDbDerived => "KEYDB (derived)",
KeyOrigin::KeyDb => "KEYDB",
KeyOrigin::KeyDbUnitKeys => "KEYDB (unit keys)",
KeyOrigin::ExternalUk => "external UK",
}
}
}
// ─── Disc scanning ──────────────────────────────────────────────────────────
/// AACS host credentials for the live-drive authenticated handshake.
///
/// Optional and source-agnostic: an ISO scan has no handshake at all, and a
/// live drive without supplied credentials simply skips cert auth. The
/// caller supplies the host cert(s) from wherever it likes — today the keydb's
/// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key
/// source: a locked drive needs the cert to unlock even when the decryption key
/// comes from an online service.
#[derive(Default, Clone)]
pub struct DriveCredentials {
/// Host certificate(s) + private key(s) for the SCSI AACS handshake.
pub host_certs: Vec<crate::aacs::types::HostCert>,
}
/// Options for disc scanning.
///
/// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key
/// out-of-band (a key source) and applies it via [`Disc::decrypt_with`]. The
/// only scan input is the optional drive credentials for the live-drive
/// authenticated handshake.
#[derive(Default)]
pub struct ScanOptions {
/// Host credentials for the live-drive AACS handshake. `None` for ISO
/// scans, or a live drive where cert auth should be skipped.
///
/// Host certs may ALSO be supplied through [`Self::key_sources`]: the
/// handshake unifies certs from both, so the app can pass its already-built
/// keysource layer rather than (or in addition to) pre-extracting certs into
/// `DriveCredentials`. Either route is keysource-served — certs are never
/// compiled into the library.
pub credentials: Option<DriveCredentials>,
/// The application's key-source layer. The handshake collects host certs
/// across these (via [`crate::KeySource::host_certs`]) for the OEM/AACS
/// cert-auth route, unioned with [`Self::credentials`]. Empty by default —
/// an ISO scan supplies none, and a live-drive caller that pre-extracted
/// certs into `credentials` may leave it empty too. The library still
/// resolves NO keys from these at scan time; they are consulted only for
/// their host certs here (key *resolution* stays out-of-band via
/// `Disc::decrypt_with`).
pub key_sources: Vec<Box<dyn crate::KeySource>>,
/// Optional cooperative-cancellation token. When set, long scan-time
/// loops (notably the CSS known-plaintext crack, which can scan up to
/// 50_000 sectors on a live DVD) poll it and bail out cleanly so a
/// scan-phase watchdog or operator Stop is never stuck behind a hang.
pub halt: Option<crate::halt::Halt>,
}
/// Quick disc identification — name, format, capacity. No title/stream parsing.
#[derive(Debug)]
pub struct DiscId {
/// UDF Volume Identifier (always present, e.g. "SAMPLE_FILM")
pub volume_id: String,
/// Disc title from META/DL/bdmt_eng.xml (e.g. "Sample Film")
pub meta_title: Option<String>,
/// Disc format (BD, UHD, DVD) — UHD vs BD requires full scan to confirm
pub format: DiscFormat,
/// Disc capacity in sectors
pub capacity_sectors: u32,
/// Whether AACS directory exists (disc is likely encrypted)
pub encrypted: bool,
/// Number of layers
pub layers: u8,
}
impl DiscId {
/// Best available name: meta_title, then formatted volume_id.
pub fn name(&self) -> &str {
self.meta_title.as_deref().unwrap_or(&self.volume_id)
}
}
impl Disc {
/// Fast disc identification — reads only UDF metadata for name and format.
/// No AACS handshake, no playlist parsing, no CLPI, no labels.
/// Typically completes in 2-3 seconds on USB drives.
pub fn identify(session: &mut Drive) -> Result<DiscId> {
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
// Authoritative here — the same MKB-driven detector the full scan uses
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
let layers = if capacity > 24_000_000 { 2 } else { 1 };
Ok(DiscId {
volume_id: udf_fs.volume_id,
meta_title,
format,
capacity_sectors: capacity,
encrypted,
layers,
})
}
/// Disc capacity in GB
pub fn capacity_gb(&self) -> f64 {
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
}
/// Read UDF filesystem and set up buffered reader with metadata prefetched.
/// Shared setup for both identify() and scan().
fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> {
let capacity = Self::read_capacity(session).unwrap_or_else(|e| {
// A READ CAPACITY failure (transient drive spin-up, SCSI error)
// must not be silently treated as a 0-sector disc: capacity=0
// skews the layer heuristic (always 1 layer) and title ordering.
// Recovery is unchanged (we still proceed with 0), but surface it.
tracing::warn!(
target: "freemkv::scan",
error = %e,
"READ CAPACITY failed; treating disc capacity as 0 sectors (layer count and title ordering may be wrong)"
);
0
});
let batch = detect_max_batch_sectors(session.device_path());
let mut buffered = udf::BufferedSectorReader::new(session, batch);
let udf_fs = udf::read_filesystem(&mut buffered)?;
buffered.prefetch(udf_fs.metadata_start(), udf_fs.metadata_sectors());
Ok((capacity, buffered, udf_fs))
}
/// Scan a disc — parse filesystem, playlists, streams, and set up
/// AACS decryption. This is the main entry point; after `scan()` the
/// Disc is ready (titles populated with streams, AACS inputs
/// captured, content readable and decryptable transparently).
///
/// One pipeline, one order:
/// 1. Read capacity + UDF filesystem
/// 2. AACS handshake + key resolution
/// 3. Parse playlists + streams
/// 4. Apply labels
///
/// The session must be open and unlocked (`Drive::open` handles this).
/// All disc reads use standard READ(10) via UDF — no vendor SCSI commands.
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
// AACS handshake (Blu-ray/UHD). Acquires the Volume ID via the
// cert-based mutual-auth handshake (the OEM route); drive unlock
// itself runs separately behind the pluggable `Unlocker` seam.
// AACS is Blu-ray/UHD only. A DVD uses CSS — skip the AACS handshake
// entirely (the drive already classified the disc as DVD at init), so a
// DVD never issues AACS OEM-VID / cert SCSI against the drive before the
// CSS bus-auth runs.
let (handshake, handshake_error) = if session.disc_is_dvd() {
(None, None)
} else {
tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
Self::do_handshake(session, opts)
};
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
// Request max read speed — removes riplock on DVD
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
session.set_speed(0xFFFF);
// 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 mut 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");
// 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 (_, css_unlock_res) = crate::unlock_bridge::run_bus(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Css,
&[],
);
if let Err(e) = css_unlock_res {
tracing::warn!(
target: "freemkv::scan",
outcome = ?e,
"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");
Ok(disc)
}
/// Scan a disc image (ISO or any SectorSource). No SCSI, no handshake.
/// AACS resolution uses KEYDB VUK lookup only.
pub fn scan_image(
reader: &mut dyn SectorSource,
capacity: u32,
opts: &ScanOptions,
) -> Result<Self> {
let udf_fs = udf::read_filesystem(reader)?;
let mut disc = Self::scan_with(reader, capacity, None, None, opts, udf_fs)?;
// CSS for a raw (still-scrambled) DVD image: recover the title key from
// the scrambled movie data itself (known-plaintext attack), same as the
// live-drive path — but with no SCSI auth/unlock (an image is already
// readable). This lets the CLI mux a RAW CSS ISO, not only a
// pre-decrypted one. A pre-decrypted image has its scramble flags clear,
// so `crack_key` finds no crackable sector and the disc stays in the
// clear. AACS images go through KEYDB VUK lookup, not here.
//
// Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
// `.evo` images are ALSO MPEG-PS but are AACS, not CSS — they must not
// enter the CSS crack path. A CSS DVD's IFO (which defines the titles
// this branch reads) is unscrambled, so `detect_format` reliably sets
// `Dvd` from the SD-resolution titles even on a still-scrambled image.
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
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(),
};
if !main_extents.is_empty() {
// Image reads aren't drive-batch-limited; use a generous batch.
match crate::css::crack_key_outcome(reader, &main_extents, 32, None) {
crate::css::CrackOutcome::Cracked(state) => {
tracing::info!(target: "freemkv::scan", "image css: title key recovered via known-plaintext crack");
disc.css = Some(state);
disc.encrypted = true;
}
crate::css::CrackOutcome::ScrambledUncracked => {
// Scrambled image data with no recoverable key — a hard
// failure, surfaced so the mux path doesn't pass scrambled
// MPEG through as plaintext (garbage at exit 0).
tracing::warn!(target: "freemkv::scan", "image css: scrambled sectors seen but no title key cracked");
disc.encrypted = true;
disc.css_error = Some(crate::error::Error::CssKeyMissing);
}
crate::css::CrackOutcome::Unencrypted => {}
}
}
}
Ok(disc)
}
/// Read a disc's AACS key-input files from a sector source: returns
/// `(Unit_Key_RO.inf, MKB)` raw bytes. Shared body for
/// [`Disc::read_aacs_inputs`] (ISO) and
/// [`Disc::read_aacs_inputs_from_drive`] (live drive).
///
/// Prefers MKB_RO, falls back to MKB_RW, then TRIMS to the real
/// record length. Both files are allocated to a fixed ~128 MiB and
/// zero-padded, so reading either ships up to ~124 MiB of nothing —
/// trim to the record stream so callers send/store a few MB, not
/// 128 MiB.
pub(crate) fn read_aacs_inputs_from_reader(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| {
udf_fs.read_file(reader, p)
})?;
let mkb = Self::read_mkb_content(reader, udf_fs)?;
let version = Self::read_aacs_version(reader, udf_fs);
Ok((inf, mkb, version))
}
/// AACS major version ([`crate::aacs::mkb::AACS_MAJOR_BD`] /
/// [`crate::aacs::mkb::AACS_MAJOR_UHD`]) from the content certificate. Drives the
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the
/// out-of-band key-fetch path parses `enc_title_keys` at the right stride (a
/// server VUK then derives the correct unit keys).
///
/// When no content certificate is readable/parseable, defaults to **UHD
/// (V20, 64-byte stride)** — the conservative choice the pre-1.2.0 fetch path
/// hardcoded — and logs it: a wrong stride here folds a server VUK against
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
/// not quietly pick the V10 stride for a UHD disc.
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| {
udf_fs.read_file(reader, p)
})
.ok()
.as_deref()
.and_then(crate::aacs::inf::parse_content_cert)
{
Some(c) => c.version.major(),
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_version",
"no readable AACS content certificate; defaulting to the V20/UHD \
Unit_Key_RO stride (a VUK-from-server path would otherwise mis-stride)"
);
crate::aacs::mkb::AACS_MAJOR_UHD
}
}
}
/// Read the AACS MKB's real record stream — NOT its zero padding.
///
/// `MKB_RO.inf` / `MKB_RW.inf` are allocated to a fixed ~128 MiB and
/// zero-padded; the actual record stream is a few MiB. We read a bounded
/// prefix, find the record-stream length via [`crate::aacs::mkb::mkb_content_len`]
/// and return exactly that, growing the prefix if the records run past it.
/// This avoids reading 100+ MiB of padding on every scan AND avoids the
/// `read_file` `MAX_FILE_BYTES` cap that (since 0.31.0) rejected the padded
/// 128 MiB MKB outright — which made `read_aacs_inputs` fail and autorip
/// report "could not read this disc's key files" without ever contacting
/// the keyserver.
fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> {
const START_BYTES: usize = 16 * 1024 * 1024;
const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES;
loop {
let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| {
udf_fs.read_file_prefix(reader, p, want)
})?;
let n = crate::aacs::mkb::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` =>
// the whole file is already read. Otherwise the records may run
// past the prefix — grow and retry, bounded by MAX_BYTES.
if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
return Ok(crate::aacs::mkb::trim_mkb(buf));
}
want = (want * 2).min(MAX_BYTES);
}
}
/// Read a disc's AACS key-input files from an ISO image: returns
/// `(Unit_Key_RO.inf, MKB, aacs_major_version)`. For callers that resolve a
/// Unit Key out-of-band: obtain the key however you like, then apply it via
/// [`Disc::decrypt_with`]. libfreemkv never makes a network call.
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>, u8)> {
// Preserve the underlying open error (`Error::IoError`, E5000, carrying
// the OS errno) instead of collapsing ENOENT/EPERM/etc. into
// `Error::AacsNoKeys` (E7000). A missing or unreadable ISO is an I/O
// fault, not a key-resolution failure; callers that dispatch on
// `.code()` must be able to tell the two apart.
let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)?;
let udf_fs = udf::read_filesystem(&mut reader)?;
Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
}
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The
/// out-of-band Unit Key path fetches the disc's key files from the drive,
/// resolves a key from them however it likes, then applies it via
/// [`Disc::decrypt_with`]. These files are plaintext UDF metadata — no
/// AACS handshake or keys are required to read them.
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>, u8)> {
let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
}
/// Core scan pipeline — works with any SectorSource.
///
/// `handshake_error` is plumbed from `do_handshake` so failures
/// (cert rejected, raw-read unsupported, VID read failed) are
/// preserved as `disc.aacs_error` for callers to render. When key
/// resolution succeeds despite the handshake failure (built-in
/// keys + disc-hash lookup hit) the error is dropped.
fn scan_with(
reader: &mut dyn SectorSource,
capacity: u32,
handshake: Option<HandshakeResult>,
handshake_error: Option<Error>,
_opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
let scan_with_t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
// 2. Resolve encryption (AACS, CSS, or none)
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
let (aacs, aacs_error) = if !encrypted {
(None, None)
} else {
// Lookup-free: capture the disc's AACS inputs (MKB, VID,
// Unit_Key_RO.inf) but resolve NO key. The caller resolves a Key
// from a key source and applies it via `Disc::decrypt_with`. The
// disc reports "encrypted, no keys" until then.
match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
Ok(state) => (Some(state), None),
// A handshake failure (no VID) is more actionable than the
// generic capture error, so surface it when present.
Err(e) => (None, Some(handshake_error.unwrap_or(e))),
}
};
// 3. Titles + container — dispatched by on-disc tree. HD-DVD and DVD are
// tree-level peers, each with its own enumerator; FMTS shares the BD
// tree (a `.fmts` stream variant). Disc FORMAT is a separate axis
// derived below from the AACS MKB generation, not the tree.
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
(
Self::scan_bluray_titles(reader, &udf_fs),
ContentFormat::BdTs,
)
} else if udf_fs.find_dir("/HVDVD_TS").is_some() {
(
Self::scan_hddvd_titles(reader, &udf_fs),
ContentFormat::MpegPs,
)
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
(
Self::scan_dvd_titles(reader, &udf_fs),
ContentFormat::MpegPs,
)
} else {
(Vec::new(), ContentFormat::BdTs)
};
// Title ordering: titles[0] should be the canonical main feature.
//
// Naive "longest duration first" misranks branching UHDs (see
// `canonical_title_order` for the full rationale). Sort the
// titles so the consumer-side `-t 1` / autorip's main-feature
// picker / `disc.titles.first()` all converge on the actual
// movie instead of the virtual play-all composite.
let capacity_bytes = capacity as u64 * 2048;
titles.sort_by(|a, b| Self::canonical_title_order(a, b, capacity_bytes));
// 4. Metadata + labels
let meta_title = Self::read_meta_title(reader, &udf_fs);
crate::labels::apply(reader, &udf_fs, &mut titles);
crate::labels::fill_defaults(&mut titles);
// 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD) and
// layers. Region coding is not yet decoded from the disc — every disc
// reports Region-free for now (correct for all UHD; a stub for
// region-locked BD/DVD until region detection is implemented).
let format = Self::detect_disc_format(reader, &udf_fs, &titles);
let layers = if capacity > 24_000_000 { 2 } else { 1 };
let region = DiscRegion::Free;
// 6. CSS detection for DVDs.
// Detection from a single probe sector would miss
// DVDs whose first sector is unscrambled, so the crack path
// scans extents internally and bottoms out at None on
// unencrypted media.
// CSS for a live-drive DVD is resolved by the drive-authentication
// path in `Disc::scan` (which has `&mut Drive`), AFTER this function
// returns. We deliberately do NOT run the reader-based crack path here:
// it is non-functional against this crate's descrambler (always returns
// None — see `css::crack_key`), and on a CSS-protected disc it would scan up
// to 50,000 scrambled sectors one-by-one, each rejected by the drive
// with sense 05/6F/03 ("read of scrambled sector without
// authentication") — roughly an hour of failing reads before the real
// auth path ever runs. Leave `css` unresolved here.
let css = None;
let encrypted = encrypted || css.is_some();
tracing::info!(
target: "freemkv::scan",
phase = "scan_with",
titles = titles.len(),
encrypted,
elapsed_ms = scan_with_t0.elapsed().as_millis() as u64,
"end"
);
let disc = Disc {
volume_id: udf_fs.volume_id.clone(),
meta_title,
format,
capacity_sectors: capacity,
capacity_bytes: capacity as u64 * 2048,
layers,
titles,
region,
aacs,
css,
encrypted,
aacs_error,
// CSS crack runs AFTER scan_with returns (in `scan` / `scan_image`),
// which set this when they observe scrambled-but-uncracked content.
css_error: None,
content_format,
};
// Structured scan diagnostic block (--log-level 3). Emits the
// per-title / per-stream / decision / AACS rows under the
// `freemkv::diag` target; a no-op unless that target is enabled.
// (DVD per-cell category rows are emitted earlier from the IFO scan,
// before the per-cell detail is lowered away.)
crate::diag::dump_disc(&disc);
Ok(disc)
}
// ── Internal helpers ────────────────────────────────────────────────────
/// Detect disc format from the main title's video streams.
/// Total ordering used to sort `Disc::titles` so `titles[0]` is the
/// canonical main feature.
///
/// **Why not just sort by duration descending?** Branching UHDs
/// (and some BD authoring) ship a "play-all" virtual playlist that
/// references the same source clips multiple times for seamless
/// alternate-angle / alternate-ending playback. Those playlists
/// report an inflated `duration_secs` (often 4+ hours) and an
/// inflated `size_bytes` greater than the disc's physical
/// capacity. Example seen in the wild — *The Amateur (2025)* UHD,
/// 58.5 GB BD-100 disc:
///
/// | Title | Playlist | Duration | Size | Clips |
/// |-------|--------------|----------|---------|-------|
/// | 1 | 00020.mpls | 4h 13m | 92.4 GB | 253 |
/// | 2 | 00800.mpls | 2h 02m | 57.2 GB | 1 |
///
/// Title 1's 92.4 GB cannot fit on a 58.5 GB disc unless the same
/// clip data is referenced multiple times — proof it's a virtual
/// composite. A duration-only sort would put it at `titles[0]`,
/// so `freemkv -t 1`, `disc.titles.first()`, and autorip's
/// main-feature picker all grab the 4-hour composite instead of
/// the 2-hour movie that actually matches TMDB.
///
/// **Sort priority (titles[0] = most likely main feature):**
/// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
/// composites. The capacity check is a hard "physically
/// possible data on this disc" gate.
/// 2. Among real titles, LARGEST physical size first — the main
/// feature is the biggest real title on the disc. (This replaced
/// the old clip-count ordering, which mis-ranked chapter-per-clip
/// discs like Fast & Furious.)
/// 3. Tiebreak on longer duration first.
///
/// **Effect on non-branching discs:** unchanged — the main movie
/// is already the longest 1-clip title.
/// **Effect on branching UHDs:** the virtual play-all playlist is
/// pushed to the back, the actual movie surfaces at index 0.
pub fn canonical_title_order(
a: &DiscTitle,
b: &DiscTitle,
capacity_bytes: u64,
) -> std::cmp::Ordering {
// A title bigger than the whole disc is a "play-all" composite artifact
// (its declared size double-counts clips shared with other playlists) —
// demote it below any real single title.
let a_oversize = a.size_bytes > capacity_bytes;
let b_oversize = b.size_bytes > capacity_bytes;
a_oversize
.cmp(&b_oversize)
// PRIMARY: largest physical size = the main feature. Robust where
// duration and clip-count are not — a decoy "play-all" playlist runs
// long (e.g. 1h31m) but is tiny (0.4 GB of reused/junk clips), and the
// real feature is often chaptered into MANY clips (one per chapter),
// which the old clip-count-ascending key wrongly demoted below 1-clip
// bonus reels. Validated across 23 UHD/BD discs — fixes F9 / Fast Five
// / Fast & Furious 6 / Furious 7 (feature was ranked ~#1336), no
// regressions on the 19 already correct.
.then_with(|| b.size_bytes.cmp(&a.size_bytes))
// Tiebreak for equal-size twins: longer duration, then richer audio —
// the same feature authored as sibling playlists (a full-audio main
// vs an audio-reduced twin, e.g. Fight Club's 00800 [DTS-HD MA + 13
// tracks] vs 00004 [stereo AC-3 only]). Prefer lossless-multichannel.
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
}
/// Audio-richness rank for `canonical_title_order`'s same-length tiebreak.
/// Higher is better: `(any lossless track, best channel count, audio count)`.
fn audio_richness(t: &DiscTitle) -> (u8, u8, usize) {
let mut lossless = 0u8;
let mut max_ch = 0u8;
let mut count = 0usize;
for s in &t.streams {
if let Stream::Audio(a) = s {
count += 1;
if matches!(
a.codec,
Codec::TrueHd | Codec::DtsHdMa | Codec::DtsHdHr | Codec::Lpcm | Codec::Flac
) {
lossless = 1;
}
let ch = match a.channels {
AudioChannels::Surround71 => 8,
AudioChannels::Surround61 => 7,
AudioChannels::Surround51 => 6,
AudioChannels::Surround50 => 5,
AudioChannels::Quad => 4,
AudioChannels::Stereo21 => 3,
AudioChannels::Stereo => 2,
AudioChannels::Mono => 1,
AudioChannels::Unknown => 0,
};
max_ch = max_ch.max(ch);
}
}
(lossless, max_ch, count)
}
/// The disc format, from the two on-disc axes:
/// * **tree** → HD-DVD (`HVDVD_TS/`) and DVD (`VIDEO_TS/`) are tree-level
/// peers with their own enumerators;
/// * **AACS MKB generation** → within the BD tree (`BDMV/`), the MKB Type
/// record decides BD (1.0) / UHD (2.0) / FMTS (2.1). This is the
/// authoritative, cheap signal (the Type record is the first bytes of
/// `MKB_RO.inf`) — reusing [`crate::aacs::mkb::mkb_type`] /
/// [`crate::aacs::mkb::MkbType::generation`], not a filesystem heuristic.
///
/// An unencrypted / MKB-less BD tree falls back to video resolution (still a
/// BD-tree disc, so never below [`DiscFormat::BluRay`]).
fn detect_disc_format(
reader: &mut dyn SectorSource,
udf_fs: &crate::udf::UdfFs,
titles: &[DiscTitle],
) -> DiscFormat {
use crate::aacs::mkb::{AacsVersion, mkb_type};
// Tree priority MUST match the title-scan dispatch (BDMV → HVDVD_TS →
// VIDEO_TS): otherwise a disc carrying two trees would be classified as
// one format but enumerated as another (e.g. BD titles tagged HdDvd).
if udf_fs.find_dir("/BDMV").is_some() {
// Only the Type-and-Version record (first record) is needed.
if let Ok(mkb) = udf_fs.read_file_prefix(reader, "/AACS/MKB_RO.inf", 64) {
match mkb_type(&mkb).map(|t| t.generation()) {
Some(AacsVersion::V21) => return DiscFormat::Fmts,
Some(AacsVersion::V20) => return DiscFormat::Uhd,
Some(AacsVersion::V10) => return DiscFormat::BluRay,
None => {}
}
}
// Unencrypted / unreadable MKB: refine by resolution, but a BD-tree
// disc is never below Blu-ray — only UHD can promote it. detect_format
// is a general resolution classifier that can return Dvd for an SD
// bonus/menu title, which must NOT tag a BDMV disc as DVD (that
// mis-sizes the ECC-block sweep). Clamp anything but UHD up to BluRay.
return match Self::detect_format(titles) {
DiscFormat::Uhd => DiscFormat::Uhd,
_ => DiscFormat::BluRay,
};
}
if udf_fs.find_dir("/HVDVD_TS").is_some() {
return DiscFormat::HdDvd;
}
if udf_fs.find_dir("/VIDEO_TS").is_some() {
return DiscFormat::Dvd;
}
DiscFormat::Unknown
}
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
for title in titles.iter().take(3) {
for stream in &title.streams {
if let Stream::Video(v) = stream {
if v.resolution.is_uhd() {
return DiscFormat::Uhd;
}
if v.resolution.is_hd() {
return DiscFormat::BluRay;
}
if v.resolution.is_sd() {
return DiscFormat::Dvd;
}
}
}
}
DiscFormat::Unknown
}
fn read_capacity(session: &mut Drive) -> Result<u32> {
let cdb = [
crate::scsi::SCSI_READ_CAPACITY,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
let mut buf = [0u8; 8];
session.scsi_execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)?;
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
// `last_lba + 1` = sector count. Guard the 0xFFFF_FFFF sentinel
// (capacity exceeds 32 bits) so it surfaces as an error instead of
// wrapping to 0 in release — mirrors the public `decode_read_capacity`.
lba.checked_add(1)
.ok_or(crate::error::Error::DiscCapacityOverflow)
}
}
/// A decryption key handed to libfreemkv by the caller.
///
/// libfreemkv is **lookup-free**: it never reads a keydb, never talks to a key
/// server, never searches paths. The application resolves a key from whatever
/// source it likes (a local keydb, an online key service, a mapfile cache) and
/// hands it in here; libfreemkv uses it to decrypt, deriving any remaining
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
///
/// `#[non_exhaustive]`: AACS is a derivation chain
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Each variant is an entry
/// point at one level of that chain; [`Disc::decrypt_with`] derives down from
/// it to the per-CPS unit keys. New levels can be added without breaking
/// callers.
#[derive(Clone)]
#[non_exhaustive]
pub enum Key {
/// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB
/// (subset-difference tree) to find the one that applies → media key →
/// VUK → unit keys. A source hands in its FULL device-key set, because
/// choosing which one applies *is* the MKB walk (derivation), and all
/// derivation lives here — never in a source.
Device(Vec<crate::aacs::types::DeviceKey>),
/// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
/// → media key → VUK → unit keys.
Processing(Vec<[u8; 16]>),
/// Media key candidate(s) (Km). A source hands its full pool because an MK
/// is MKB-scoped (shared across a pressing/MKB family) — picking the one
/// that applies is `km_verifies` against this disc's MKB, which is
/// derivation, so it lives here. libfreemkv verifies, then derives the VUK
/// via the Volume ID and the per-CPS-unit keys.
Media(Vec<[u8; 16]>),
/// Volume Unique Key (VK / VUK). libfreemkv decrypts `Unit_Key_RO.inf`
/// into the per-CPS-unit keys. NOT terminal — the chain continues to the
/// unit keys.
Volume([u8; 16]),
/// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
/// (keydb / key server) resolved these, or they were cached in the mapfile
/// at sweep; libfreemkv decrypts directly with no further derivation. This
/// is the terminal level every other variant derives down into.
Unit(Vec<(u32, [u8; 16])>),
}
// Redacting `Debug`: `Key` is crate-root re-exported and is the key-transport
// type crossing `Disc::decrypt_with`; every variant carries raw key material.
// Print only the variant name and count — never bytes. Guarded by
// `aacs_state_and_key_debug_are_redacted`.
impl std::fmt::Debug for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Key::Device(v) => write!(f, "Key::Device(<{} redacted>)", v.len()),
Key::Processing(v) => write!(f, "Key::Processing(<{} redacted>)", v.len()),
Key::Media(v) => write!(f, "Key::Media(<{} redacted>)", v.len()),
Key::Volume(_) => f.write_str("Key::Volume(<redacted>)"),
Key::Unit(v) => write!(f, "Key::Unit(<{} redacted>)", v.len()),
}
}
}
/// True if `unit_keys` covers EVERY supplied scrambled content `sample` — the
/// validation gate for [`Disc::decrypt_with`]. Conservative: a sample that is
/// not AACS-scrambled proves nothing, and with no scrambled sample at all there
/// is nothing to disprove against, so it returns `true` (accept).
///
/// It returns `false` when ANY scrambled sample cannot be restored to clear
/// MPEG-TS by ANY unit key in the set. That covers two distinct failure shapes:
/// 1. a wholly wrong key (a keydb VK that does not match this disc) — no
/// sample decrypts; and
/// 2. a *partially* applicable key set on a multi-CPS-unit disc — the resolved
/// keys cover CPS unit 0 but not CPS unit 1. Accepting on the first sample
/// that decrypts (the old behaviour) would commit such a set, after which
/// CPS-unit-1 sectors pass through as raw encrypted bytes into the ISO/MKV
/// with no error surfaced anywhere. Requiring every scrambled sample to
/// decrypt rejects the incomplete set so the caller falls through to the
/// next candidate (and ultimately surfaces a key error rather than silently
/// writing ciphertext).
///
/// Reuses the ecosystem's single `ts_sync_destroyed` predicate and the full
/// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>],
format: ContentFormat,
) -> bool {
use crate::aacs::content::{
ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, is_clean,
};
let scrambled: Vec<&[u8]> = samples
.iter()
.map(|s| s.as_slice())
.filter(|s| aacs_unit_needs_decrypt(s, format))
.collect();
if scrambled.is_empty() {
return true; // nothing to disprove against — accept
}
if unit_keys.is_empty() {
return false;
}
let mut probe = vec![0u8; ALIGNED_UNIT_LEN];
let total = (scrambled.len() as u64) * (unit_keys.len() as u64);
let mut tried = 0u64;
let mut hb = crate::progress::Heartbeat::new("scan_key_trial");
// Every scrambled sample must be covered by SOME unit key. A single sample
// that no key descrambles means the key set is incomplete (wrong key, or a
// CPS unit left uncovered) — reject so the wrong/partial set never commits.
for sample in scrambled {
let mut covered = false;
for (_, k) in unit_keys {
// Pure-CPU inner loop: only consult the clock every 256 trials.
hb.tick_cpu(tried, total);
tried += 1;
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
// bus layer (AACS 2.0) first, then the CPS unit key, then the structural
// proof — the composed form of the old `decrypt_unit_full`.
if let Some(rdk) = read_data_key {
decrypt_bus(&mut probe, rdk);
}
decrypt_unit(&mut probe, k);
if is_clean(&probe, format) {
covered = true;
break;
}
}
if !covered {
return false;
}
}
true
}
impl Disc {
/// Get the resolved decryption keys for this disc.
/// Used by disc-to-ISO and other full-disc operations.
pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
if let Some(ref aacs) = self.aacs {
// An AACS state with NO unit keys is "encrypted, no keys" — e.g.
// the VID-only state from out-of-band resolution before a Unit Key
// is supplied. Report None so callers treat it as missing keys
// (not a usable, empty key set).
if aacs.unit_keys.is_empty() {
return crate::decrypt::DecryptKeys::None;
}
crate::decrypt::DecryptKeys::Aacs {
unit_keys: aacs.unit_keys.clone(),
read_data_key: aacs.read_data_key,
format: self.content_format,
}
} else if let Some(ref css) = self.css {
crate::decrypt::DecryptKeys::Css {
title_key: css.title_key,
}
} else {
crate::decrypt::DecryptKeys::None
}
}
/// The disc's AACS-encrypted content as a sorted, merged, disjoint set of
/// `(start_lba, sector_count)` ranges — the union of every title's m2ts
/// stream extents.
///
/// This is the authoritative "which sectors are encrypted" map for a
/// whole-disc read. AACS only encrypts the m2ts AV streams, so a sector is
/// encrypted content **iff** it falls inside one of these ranges; everything
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
///
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
/// never consults [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) about
/// non-content bytes — filesystem data has no TS sync and would otherwise be
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
///
/// Empty when the disc has no parsed titles (CSS / unencrypted / unscanned);
/// callers treat an empty map as "no content gate" and fall back accordingly.
pub fn encrypted_content_ranges(&self) -> Vec<(u32, u32)> {
merged_extents(self.titles.iter().flat_map(|t| &t.extents))
}
/// The 40-hex AACS disc id (SHA1 of `Unit_Key_RO.inf`, no `0x` prefix), or
/// empty when this disc has no captured AACS state. Used to name the disc in
/// a [`Error::NoDiscKey`] so the application can tell the user which disc to
/// add to the keydb.
pub fn aacs_disc_hash(&self) -> String {
self.aacs
.as_ref()
.map(|a| crate::hex::strip_hex_prefix(&a.disc_hash).to_string())
.unwrap_or_default()
}
/// The unlocker matrix for this scanned disc on `drive`: each REGISTERED
/// unlocker's name + whether it actually **did work this rip** — i.e. ran and
/// accomplished its job, NOT merely "matched the disc kind". Registry-driven
/// names (no hardcoding) so the CLI and autorip render an identical, always-
/// current report; the per-unlocker runtime signal is computed here because
/// the library owns unlock semantics AND the disc/drive state.
///
/// The distinction matters: on a LibreDrive drive, LibreDrive's firmware route
/// removes the AACS bus and reads the VID, so the AACS host-cert unlocker
/// never runs — it "matched" (AACS disc) but did nothing. `did-work` reports
/// that honestly (`AACS: no`), and on a *stock* drive that fell back to the
/// cert route it reports `LibreDrive: no, AACS: yes` — the real diagnostic.
pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> {
// The drive-prep unlocker that actually ran (recorded on init):
// "LibreDrive" (MediaTek) or "Renesas" — mutually exclusive per drive.
let prep = drive.unlocker_name();
// Only LibreDrive (MediaTek) removes AACS bus encryption AT THE DRIVE;
// Renesas unlocks features but leaves the bus to the cert.
let ld_removed_bus = prep == Some("LibreDrive");
crate::unlock_bridge::unlocker_names()
.into_iter()
.map(|name| {
let did_work = match name {
// Each firmware unlocker did work iff it was the one that ran.
"LibreDrive" => ld_removed_bus,
"Renesas" => prep == Some("Renesas"),
// The AACS host-cert route removed the bus ONLY when the
// firmware didn't (stock or Renesas drive) AND AACS state was
// actually obtained.
"AACS" => self.aacs.is_some() && !ld_removed_bus,
// The CSS handshake/crack succeeded → title keys recovered.
"CSS" => self.css.is_some(),
// A newly-registered unlocker with no runtime signal wired
// here yet: report `no` rather than guess.
_ => false,
};
(name, did_work)
})
.collect()
}
/// The system-wide decrypt correctness gate.
///
/// Returns `Ok(())` when it is safe to proceed with a copy or mux, and a
/// clear typed error when decryption is **needed but unavailable** — the
/// case that would otherwise write ciphertext (disc→ISO) or feed the demux
/// undecryptable bytes (mux) and exit 0. Every copy/mux entry point calls
/// this **after key resolution and before any source-data processing
/// begins**, so the verdict is identical everywhere and the failure is a
/// pre-flight one (no partial output).
///
/// The verdict, in order:
/// - `raw == true` → `Ok(())`. `--raw` intentionally skips decryption and
/// needs no key (the caller wants an encrypted image).
/// - `self.css_error.is_some()` → `Err(Error::CssKeyMissing)`. The scan saw
/// scrambled CSS sectors but recovered no title key (`self.css` is `None`
/// yet the content IS encrypted). Treating `css.is_none()` as
/// "unencrypted" would mux scrambled MPEG as plaintext garbage.
/// - AACS-encrypted (`self.aacs.is_some()`) with no usable key
/// (`decrypt_keys()` is `None`) → `Err(Error::NoDiscKey { .. })`, naming
/// the disc by hash.
/// - CSS-encrypted (`self.css.is_some()`) with no usable key →
/// `Err(Error::CssKeyMissing)`. (The disc-wide `decrypt_keys()` yields
/// `Css{..}` whenever `css.is_some()`, so this is defensive; the live
/// multi-VTS case is gated by [`Self::ensure_decryptable_keys`].)
/// - otherwise → `Ok(())`. A genuinely unencrypted disc has `None` keys
/// legitimately, and a CSS disc whose keyless crack succeeded has a key.
pub fn ensure_decryptable(&self, raw: bool) -> Result<()> {
self.ensure_decryptable_keys(raw, &self.decrypt_keys())
}
/// [`Self::ensure_decryptable`] against a caller-resolved key set, for the
/// per-title path. A multi-VTS CSS DVD resolves its key with
/// [`Self::decrypt_keys_for_title`] (which can return `None` when the chosen
/// title's VTS could not be re-cracked even though the disc-wide
/// `decrypt_keys()` is `Css{..}`); the gate must judge THAT key, not the
/// disc-wide one. The "is the source encrypted?" question is answered by the
/// scan-captured disc state (`css_error`/`aacs`/`css`), never by the keys —
/// so an unencrypted disc (no AACS/CSS state) never false-errors regardless
/// of `keys`.
pub fn ensure_decryptable_keys(
&self,
raw: bool,
keys: &crate::decrypt::DecryptKeys,
) -> Result<()> {
// --raw skips decryption entirely: never error, even on an encrypted
// disc with no key (the user asked for the encrypted image).
if raw {
return Ok(());
}
// Scrambled-but-uncracked CSS: the disc is encrypted but `css` is None,
// so the key check below can't see it. Surface the recorded hard error.
if self.css_error.is_some() {
return Err(Error::CssKeyMissing);
}
// Decryption is needed iff the disc carries cipher state. A no-key
// verdict on a non-encrypted disc is impossible here (the disc has no
// AACS/CSS state), so a genuinely unencrypted disc never errors.
let needs_key = matches!(keys, crate::decrypt::DecryptKeys::None);
if needs_key {
if self.aacs.is_some() {
// E7017 vs E7022 split: when key resolution had derivation
// material (device / processing keys) but no Volume ID to derive
// the unit key, the captured `aacs_error` is `AacsVidUnavailable`
// — report THAT (the fix is recovering the VID, not adding keys),
// not the generic `NoDiscKey`. Any other (or absent) reason →
// `NoDiscKey` naming the disc by hash, unchanged.
if matches!(self.aacs_error, Some(Error::AacsVidUnavailable)) {
return Err(Error::AacsVidUnavailable);
}
return Err(Error::NoDiscKey {
disc_hash: self.aacs_disc_hash(),
});
}
if self.css.is_some() {
return Err(Error::CssKeyMissing);
}
}
Ok(())
}
/// Resolve decryption keys for muxing a *specific* title.
///
/// CSS title keys are per-VTS. The scan cracks one key (from the main
/// feature, title 0 for autorip). Applying it to a title that lives in
/// a *different* VTS would silently descramble with the wrong key
/// (garbage output). When the requested title's extents don't overlap
/// the span the cracked key came from, re-crack the key from this
/// title's own extents using `reader`. AACS / unencrypted / single-VTS
/// paths are identical to [`Self::decrypt_keys`].
///
/// `batch_sectors` sizes the crack's batched reads (file-safe value for
/// an ISO; `detect_max_batch_sectors` for a live drive).
pub fn decrypt_keys_for_title(
&self,
idx: usize,
reader: &mut dyn SectorSource,
batch_sectors: u16,
) -> crate::decrypt::DecryptKeys {
self.decrypt_keys_for_title_checked(idx, reader, batch_sectors)
.0
}
/// [`Self::decrypt_keys_for_title`] plus the per-title encryption verdict the
/// gate needs to AVOID A FALSE ERROR on a genuinely-clear extra title.
///
/// On a multi-VTS CSS DVD that ALSO carries a clear, unencrypted stub title
/// (a 0.5 s menu loop, an FBI-warning nav title) living in its own VTS, the
/// re-crack over that stub's extents finds NO scrambled sector and recovers
/// no key. The bare `decrypt_keys_for_title` collapses that to
/// `DecryptKeys::None`, indistinguishable from "scrambled but uncrackable",
/// so [`Self::ensure_decryptable_keys`] (which fails whenever `css.is_some()`
/// and the key is `None`) wrongly raised `E7023` for a title that needs no
/// key at all. That is the false error the multi-title mux must never emit.
///
/// This variant runs the re-crack via [`crate::css::crack_key_outcome`] and
/// returns `title_is_clear == true` when the title's own extents showed NO
/// scrambling (`CrackOutcome::Unencrypted`) — the gate then treats that title
/// as needing no key and passes it cleanly. A title that genuinely IS
/// scrambled but uncrackable returns `(None, false)` and still hard-fails.
/// The returned bool pairs with [`Self::ensure_title_decryptable`].
pub fn decrypt_keys_for_title_checked(
&self,
idx: usize,
reader: &mut dyn SectorSource,
batch_sectors: u16,
) -> (crate::decrypt::DecryptKeys, bool) {
let css = match self.css {
Some(ref c) => c,
None => return (self.decrypt_keys(), false),
};
let title = match self.titles.get(idx) {
Some(t) if !t.extents.is_empty() => t,
// No extents to crack from — fall back to the disc-wide key.
_ => return (self.decrypt_keys(), false),
};
// If the title overlaps the span the existing key was cracked from,
// it's the same VTS — the cracked key applies. `crack_span: None`
// (unknown provenance) is also treated as "applies".
let overlaps = match css.crack_span {
None => true,
Some((cs, ce)) => title.extents.iter().any(|e| {
let ts = e.start_lba;
let te = e.start_lba.saturating_add(e.sector_count);
ts < ce && cs < te
}),
};
if overlaps {
return (self.decrypt_keys(), false);
}
// Different VTS: re-crack from this title's extents, largest first
// (the movie body is the biggest scrambled chunk — same heuristic
// the scan uses). The disc-wide key provably does NOT apply here
// (crack_span is Some and this title doesn't overlap it). Use
// `crack_key_outcome` (not the bare `crack_key`) so we can tell a
// genuinely-clear title (`Unencrypted` — no scrambled sector in its
// own extents) apart from a scrambled-but-uncrackable one:
// - Cracked → the title's own key.
// - Unencrypted → (None, title_is_clear=true): this extra title
// needs no key; the gate must NOT raise E7023.
// - ScrambledUncracked → (None, false): genuinely encrypted but no key
// → a real hard failure, still surfaced.
// The disc-wide fallback is reserved for the unknown-provenance case
// (crack_span == None), already handled above via overlaps == true.
let mut extents = title.extents.clone();
extents.sort_by(|a, b| b.sector_count.cmp(&a.sector_count));
match crate::css::crack_key_outcome(reader, &extents, batch_sectors, None) {
crate::css::CrackOutcome::Cracked(state) => (
crate::decrypt::DecryptKeys::Css {
title_key: state.title_key,
},
false,
),
// No scrambled sector in THIS title's extents: it is genuinely clear.
// Signal `title_is_clear` so the per-title gate passes it without a
// key — NO FALSE E7023 for an unencrypted extra title.
crate::css::CrackOutcome::Unencrypted => (crate::decrypt::DecryptKeys::None, true),
// Scrambled sectors seen but no key recovered: a genuine hard failure.
crate::css::CrackOutcome::ScrambledUncracked => {
(crate::decrypt::DecryptKeys::None, false)
}
}
}
/// Per-title decrypt gate that honours the `title_is_clear` verdict from
/// [`Self::decrypt_keys_for_title_checked`].
///
/// Identical to [`Self::ensure_decryptable_keys`] EXCEPT it does not raise
/// `E7023` when the chosen title proved genuinely clear (`title_is_clear`):
/// a multi-VTS CSS disc can carry an unencrypted stub title in its own VTS,
/// and that title needs no key. The disc-wide `css.is_some()` is true, so the
/// plain gate would false-error; this one passes the clear title through.
/// A scrambled-but-uncrackable title (`title_is_clear == false`, key `None`)
/// still hard-fails exactly as before.
pub fn ensure_title_decryptable(
&self,
raw: bool,
keys: &crate::decrypt::DecryptKeys,
title_is_clear: bool,
) -> Result<()> {
if raw {
return Ok(());
}
// A title proven clear by its own re-crack (no scrambled sector in its
// extents) needs no key even though the disc is CSS — pass it. The
// disc-wide `css_error` is deliberately NOT consulted here: it reflects
// the MAIN feature's crack, not this clear extra title.
if title_is_clear && !keys.is_encrypted() {
return Ok(());
}
self.ensure_decryptable_keys(raw, keys)
}
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
/// directly with NO key-service round-trip. Populates `self.aacs.unit_keys`
/// so [`decrypt_keys`] returns them and marks the source `ExternalUk`.
///
/// If the scan built no AACS state (`self.aacs == None`) — which happens
/// when the keydb was absent at scan time (`scan_aacs_no_keydb` →
/// `aacs_error = KeydbLoad`) — this synthesizes a minimal `ExternalUk`
/// state for an encrypted AACS disc. A Unit Key is the FINAL per-title
/// decryption key; the keydb is only needed to *derive* it, and that
/// derivation already happened at sweep (the UK is in the mapfile). So a UK
/// alone is sufficient to decrypt the on-disk ISO — AACS 2.0 bus decryption
/// was applied by the drive at read time, so `read_data_key` is unused for
/// file-backed mux. Without this, a keyed disc swept without a keydb would
/// recover its UK yet still report E8005 (no usable `decrypt_keys`) at
/// remux. No-op for an unencrypted or CSS (DVD) disc.
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
if let Some(aacs) = self.aacs.as_mut() {
aacs.unit_keys = keys;
aacs.key_source = KeyOrigin::ExternalUk;
} else if self.encrypted && self.css.is_none() {
// FMTS is AACS 2.1, a UHD-family (bus-encrypted) format — not BD.
let uhd_family = matches!(self.format, DiscFormat::Uhd | DiscFormat::Fmts);
self.aacs = Some(AacsState {
version: if uhd_family {
crate::aacs::mkb::AACS_MAJOR_UHD
} else {
crate::aacs::mkb::AACS_MAJOR_BD
},
bus_encryption: uhd_family,
mkb_version: None,
disc_hash: String::new(),
key_source: KeyOrigin::ExternalUk,
vuk: None,
unit_keys: keys,
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
});
// The prior resolution error (e.g. KeydbLoad) is now moot — we have
// the decryption key. Clear it so callers don't treat the disc as
// keyless on the stale error.
self.aacs_error = None;
}
}
/// The public AACS inputs for this disc, for a [`crate::KeySource`] to look
/// a key up. `None` when the disc carries no AACS state (unencrypted, CSS,
/// or AACS inputs not captured at scan). Contains no secrets — just disc
/// identity plus the on-disc AACS structures.
pub fn inputs(&self) -> Option<crate::keysource::DiscInputs> {
self.aacs.as_ref().map(|a| crate::keysource::DiscInputs {
disc_hash: a.disc_hash.clone(),
volume_id: a.volume_id,
version: a.version,
mkb: a.mkb.clone(),
unit_key_ro: a.uk_ro.clone(),
// Content samples need the disc reader, which scan does not retain;
// the caller fills these for sources that validate against ciphertext.
samples: Vec::new(),
// Human title: prefer the UDF/ISO volume identifier, fall back to the
// BDMV display name. Identity only — a key service may catalog it.
volume_label: {
let v = self.volume_id.trim();
if v.is_empty() {
self.meta_title.clone()
} else {
Some(v.to_string())
}
},
})
}
/// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
/// decryption state. **Lookup-free**: no keydb, no network — the caller
/// (an application, via a key source) does all resolution and hands the key
/// in here. For [`Key::Unit`] this is the deferred-mux / resume path: the
/// unit keys came from a key source or the mapfile cache, and libfreemkv
/// decrypts directly (see [`Self::inject_unit_keys`]).
/// `samples` are encrypted on-disc aligned units (each 6144 bytes), supplied
/// by the caller for content validation. A wrong key — a keydb VK that does
/// not match this disc, a stale UK — can still *derive* a non-empty (garbage)
/// unit-key set, so before the key touches disc state we confirm it actually
/// de-scrambles real ciphertext. This is the single home of key validation:
/// every caller loops a key source's candidates through `decrypt_with` and a
/// rejected key (`Err(AacsKeyRejected)`) transparently falls through to the
/// next. Pass `&[]` when no content sample is available (resume / mapfile
/// cache) — validation is then skipped and the key is applied as-is.
pub fn decrypt_with(&mut self, key: Key, samples: &[Vec<u8>]) -> Result<()> {
// The AACS 2.x bus key, needed to de-scramble a sample for validation;
// captured before any mutable borrow. None for AACS 1.0 and file-backed
// ISO units (bus encryption was already removed at read time).
let read_data_key = self.aacs.as_ref().and_then(|a| a.read_data_key);
// Resolve the supplied key DOWN to candidate unit keys WITHOUT
// committing them, so a wrong higher-level key can be rejected before it
// poisons disc state.
let (candidate_unit_keys, candidate_vuk) = if let Key::Unit(keys) = key {
// Terminal — the source / mapfile already holds the final UKs.
(keys, None)
} else {
// Every higher level derives DOWN to the unit keys, reusing the
// version-dispatched resolver — the single home for all AACS
// derivation (1.0 / 2.0 / 2.1 / 2.x). It needs the AACS inputs
// (Unit_Key_RO.inf, MKB, VID) stashed on the disc at scan time.
let aacs = self.aacs.as_ref().ok_or(crate::error::Error::AacsNoKeys)?;
if aacs.uk_ro.is_empty() {
// Scan captured no Unit_Key_RO.inf — nothing to derive into.
return Err(crate::error::Error::AacsNoKeys);
}
// Map the supplied Key -> raw material. The source handed in material
// at exactly one level; choosing/applying it is the resolver's job.
let mut supplied = crate::aacs::provider::SuppliedKey {
device_keys: Vec::new(),
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: None,
};
match key {
Key::Device(dks) => supplied.device_keys = dks,
Key::Processing(pks) => supplied.processing_keys = pks,
Key::Media(mks) => supplied.media_keys = mks,
Key::Volume(vuk) => {
supplied.disc_entry = Some(crate::aacs::types::DiscEntry {
disc_hash: aacs.disc_hash.clone(),
title: String::new(),
media_key: None,
disc_id: None,
vuk: Some(vuk),
unit_keys: Vec::new(),
});
}
Key::Unit(_) => unreachable!("Key::Unit handled above"),
}
// Snapshot inputs (releases the &self borrow before the &mut below).
let volume_id = aacs.volume_id;
let mkb = aacs.mkb.clone();
let uk_ro = aacs.uk_ro.clone();
let version_u8 = aacs.version;
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
let ctx = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &volume_id,
providers: &provider_refs,
mkb: if mkb.is_empty() { None } else { Some(&mkb) },
};
// Version dispatch — V10 uses the classical resolver at 48-byte
// stride; V20/V21 share the 64-byte stride, so try the classical V20
// paths first and fall back to the 2.1 variant chain. The
// reason-preserving wrapper threads the no-key cause out so the
// decrypt gate can report E7017 (had derivation material but no VID)
// vs E7022 (no usable material) instead of a flat AacsKeyRejected.
let resolved = crate::aacs::resolve::resolve_keys_with_reason(&ctx, version_u8)
.map_err(|_reason| crate::error::Error::AacsKeyRejected)?;
if resolved.unit_keys.is_empty() {
return Err(crate::error::Error::AacsKeyRejected);
}
(resolved.unit_keys, resolved.vuk)
};
// VALIDATE against real ciphertext. Conservative: reject only when a
// supplied sample is AACS-scrambled and NO candidate unit key can
// de-scramble it. With no samples (or only clear ones) there is nothing
// to disprove against, so the key is accepted as-is — keeping the
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
if !aligned_unit_keys_validate(
&candidate_unit_keys,
read_data_key.as_ref(),
samples,
self.content_format,
) {
return Err(crate::error::Error::AacsKeyRejected);
}
// Commit — only now does the key touch disc state.
match self.aacs.as_mut() {
Some(a) => {
a.unit_keys = candidate_unit_keys;
if candidate_vuk.is_some() {
a.vuk = candidate_vuk;
}
a.key_source = KeyOrigin::ExternalUk;
}
// A Unit key for an AACS disc whose scan built no state (keyless
// scan, no keydb): synthesize a minimal ExternalUk state.
None => self.inject_unit_keys(candidate_unit_keys),
}
// A prior scan-time resolution error (e.g. keyless scan) is now moot.
self.aacs_error = None;
Ok(())
}
/// Copy disc sectors to an ISO image file.
///
/// NOT a stream operation. Copies sectors byte-for-byte producing a valid
/// ISO/UDF image. Records progress in a ddrescue-format mapfile at
/// `path + ".mapfile"` — flushed every block for crash-safe resume.
///
/// Auto-detects the pass based on mapfile state:
/// - **No mapfile** → Pass 1 (sweep): sequential read of the entire disc,
/// ECC-aligned batches, damage-jump on contiguous failures, marks bad
/// blocks as NonTrimmed. No drive-level recovery — fast.
/// - **Mapfile with bad ranges** → Pass N (patch): re-reads only bad ranges
/// sector-by-sector with full drive-level recovery. Marks recovered
/// sectors as Finished, failed as Unreadable (terminal).
/// - **Mapfile clean** → no-op: all sectors are Finished.
///
/// Without `multipass`: aborts on the first read error (legacy single-pass).
pub fn copy(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &CopyOptions,
) -> Result<CopyResult> {
// Pre-flight decrypt gate. A decrypting copy (`opts.decrypt == true`,
// i.e. NOT `--raw`) of an encrypted disc with no usable key would wrap
// the reader in a pass-through `DecryptingSectorSource` and write
// ciphertext to the ISO, then return `Ok` (bytes_good > 0) — a silent
// garbage success at exit 0. Refuse here, BEFORE any sweep/patch reads a
// single sector, so the failure is pre-flight and no partial ISO is
// written. `opts.decrypt == false` is `--raw`: the gate is a no-op (the
// user wants the encrypted image), and an unencrypted disc passes too.
self.ensure_decryptable(!opts.decrypt)?;
// Mapfile-driven resume dispatch. This runs for BOTH plain and
// `--multipass` copies: an interrupted plain `disc:// → iso://` writes
// a per-block-flushed mapfile (crash-safe), and re-issuing the SAME
// command must pick up where it stopped rather than re-sweep from
// sector 0 (the help/CLI examples promise "auto-resumes if
// interrupted"). The ONLY multipass-specific behaviour is the patch
// (Pass N) dispatch on retryable bytes — plain mode has no patch pass,
// so it returns a terminal result there instead.
let mf_path = self.mapfile_for(path);
if mf_path.exists() {
let map = mapfile::Mapfile::load(&mf_path).map_err(|e| Error::IoError { source: e })?;
let stats = map.stats();
let disc_size = self.capacity_bytes;
let covers_disc = map.total_size() == disc_size;
let bad_bytes = stats.bytes_pending + stats.bytes_unreadable;
tracing::info!(
"copy dispatch: disc={} map={} covers={} multipass={} good={} nontried={} pending={} unreadable={}",
disc_size,
map.total_size(),
covers_disc,
opts.multipass,
stats.bytes_good,
stats.bytes_nontried,
stats.bytes_pending,
stats.bytes_unreadable,
);
if covers_disc && bad_bytes == 0 && stats.bytes_nontried == 0 {
// Every sector is Finished — a prior copy completed. Re-issuing
// the command is a no-op (don't re-sweep a finished ISO).
return Ok(CopyResult {
bytes_total: disc_size,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: 0,
recovered_this_pass: 0,
complete: true,
halted: false,
});
}
if !covers_disc {
// Mapfile capacity != disc capacity. Force a full (non-
// resume) sweep on ANY mismatch so [0, disc_size) is covered
// as one fresh region (the non-resume path also set_len's the
// ISO to the full capacity).
//
// UNDER-cover (map.total_size() < disc_size): a resume sweep
// builds its region list only from the mapfile's NonTried
// entries and would silently never read the tail
// [map.total_size(), disc_size) — abandoning readable data
// and the ISO's tail.
//
// OVER-cover (map.total_size() > disc_size): a resume sweep's
// NonTried regions extend past the disc; `reader.read_sectors`
// would then read LBAs beyond capacity (the promised
// capacity clamp was never actually applied). A fresh sweep
// sized to the real disc avoids reading past the end.
tracing::info!(
"copy dispatch: → sweep (covers_disc=false, resume=false, map={}, disc={})",
map.total_size(),
disc_size,
);
return self.sweep_internal(reader, path, opts, false);
}
// NonTried bytes mean a prior sweep was halted mid-way (Ctrl-C /
// crash) and the mapfile still has un-attempted ranges (the un-swept
// tail). The sweep pass's job is to read those — route to a resume
// sweep FIRST, even when retryable bytes also exist. Checking
// retryable before this (and routing straight to patch) would
// silently abandon the un-swept tail: patch only revisits the
// mapfile's bad ranges, never the NonTried ones. The retry
// (patch) passes run after, driven separately by the caller's
// pass loop, and pick up the retryable bytes the sweep leaves.
// This is the plain-copy resume path too: a clean disc interrupted
// by Ctrl-C leaves exactly this state (NonTried tail), so a re-run
// resumes the sweep instead of restarting from sector 0.
if stats.bytes_nontried > 0 {
tracing::info!(
"copy dispatch: → sweep resume (covers_disc=true, \
nontried={}, retryable={})",
stats.bytes_nontried,
stats.bytes_retryable,
);
return self.sweep_internal(reader, path, opts, true);
}
// From here covers_disc=true and nontried=0: the whole disc was
// attempted. Only the retry/patch decision differs by mode.
if opts.multipass {
if stats.bytes_retryable > 0 {
tracing::info!(
"copy dispatch: → patch (retryable={})",
stats.bytes_retryable,
);
return self.patch_internal(reader, path, opts);
}
// Fallthrough: covers_disc=true, nontried=0, retryable=0.
// All sectors were attempted; any remaining bad bytes are
// already Unreadable. A resume sweep would visit zero new
// sectors and patch has nothing retryable — return the
// terminal result immediately.
tracing::info!(
"copy dispatch: all bad sectors already Unreadable \
(retryable=0, nontried=0) — returning terminal result",
);
return Ok(CopyResult {
bytes_total: disc_size,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: 0,
recovered_this_pass: 0,
complete: false,
halted: false,
});
}
// Plain (non-multipass) copy: there is no patch pass and the sweep
// aborts on the first read error, so a fully-attempted mapfile with
// bad bytes is terminal. Re-running must NOT restart from sector 0
// (that re-reads the whole disc and re-hits the same bad sector);
// return the terminal result so the caller surfaces the failure.
// (`complete` is true only when no bad bytes remain.)
tracing::info!(
"copy dispatch: plain copy, disc fully attempted (bad={}) — terminal result",
bad_bytes,
);
return Ok(CopyResult {
bytes_total: disc_size,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: stats.bytes_pending,
recovered_this_pass: 0,
complete: bad_bytes == 0,
halted: false,
});
}
self.sweep_internal(reader, path, opts, false)
}
fn sweep_internal(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &CopyOptions,
resume: bool,
) -> Result<CopyResult> {
let sweep_opts = SweepOptions {
decrypt: opts.decrypt,
resume,
batch_sectors: None,
skip_on_error: opts.multipass,
progress: opts.progress,
halt: opts.halt.clone(),
vid: opts.vid,
unit_keys: opts.unit_keys.clone(),
key_fetch: opts.key_fetch.clone(),
};
self.sweep(reader, path, &sweep_opts)
}
fn patch_internal(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &CopyOptions,
) -> Result<CopyResult> {
let patch_opts = PatchOptions {
decrypt: opts.decrypt,
// 0.18.13: adaptive batching. patch() reads at 32 sectors
// when the drive is healthy, drops to 1 on failure to
// probe each sector individually, then climbs back after
// 16 consecutive clean singles. Walks NonTrimmed regions
// ~32x faster in clean stretches without sacrificing any
// per-sector recovery quality — the drop-to-1 retry from
// the same position guarantees every sector in a failed
// batch is individually probed. See Disc::patch body.
block_sectors: Some(32),
full_recovery: true,
reverse: true,
wedged_threshold: 50,
progress: opts.progress,
halt: opts.halt.clone(),
key_fetch: opts.key_fetch.clone(),
};
let pr = self.patch(reader, path, &patch_opts)?;
tracing::info!(
target: "freemkv::disc",
phase = "patch_done",
bytes_recovered = pr.bytes_recovered_this_pass,
halted = pr.halted,
wedged_exit = pr.wedged_exit,
"Patch completed"
);
Ok(CopyResult {
bytes_total: pr.bytes_total,
bytes_good: pr.bytes_good,
bytes_unreadable: pr.bytes_unreadable,
bytes_pending: pr.bytes_pending,
recovered_this_pass: pr.bytes_recovered_this_pass,
complete: pr.bytes_pending == 0,
halted: pr.halted,
})
}
/// Pass 1 of a multipass rip: walk the disc forward, write
/// every readable sector into `path`, and record the result
/// in the sidecar mapfile. With `skip_on_error: true`, a bad
/// sector zero-fills + marks `NonTrimmed` and the sweep keeps
/// going (jumping ahead through dense damage); without it,
/// the first read failure aborts.
///
/// This is one of the two flat verbs the library exposes
/// for rip orchestration. Multipass + retry decisions are the
/// caller's job — see [`PatchOptions`] for the retry primitive.
pub fn sweep(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &SweepOptions,
) -> Result<CopyResult> {
use crate::io::{DEFAULT_PIPELINE_DEPTH, Pipeline};
use crate::sector::{DecryptingSectorSource, SectorSource};
use sweep::{ProgressSnapshot, SweepSink, WorkItem, try_recv_progress};
// Pre-flight decrypt gate (also enforced in `copy`; re-checked here so a
// direct `sweep` caller can't bypass it). A decrypting sweep of an
// encrypted disc with no usable key would write ciphertext to the ISO at
// exit 0; refuse before reading any sector. No-op for `--raw`
// (`opts.decrypt == false`) and unencrypted discs.
self.ensure_decryptable(!opts.decrypt)?;
let total_bytes = self.capacity_sectors as u64 * 2048;
// Decrypt-aware read.
//
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
//
// Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and
// plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a
// pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI
// read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure.
// (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute
// unit grid against clip-file-anchored AACS units and false-failed good
// clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
// verify and no post-sweep clip-anchored pass; decryptability is proven at
// mux time, not at capture time.)
let keys = if opts.decrypt {
self.decrypt_keys()
} else {
crate::decrypt::DecryptKeys::None
};
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
// Content extent map — only the in-place decrypt path (`opts.decrypt`) gates
// on it so clear filesystem / nav sectors pass through untouched.
let content_ranges = self.encrypted_content_ranges();
let can_gate = !content_ranges.is_empty();
let mut reader = {
let mut dec = DecryptingSectorSource::new(reader, keys);
if opts.decrypt && can_gate {
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
}
if decrypt_is_aacs && opts.decrypt {
if let Some(cb) = &opts.key_fetch {
dec = dec.with_key_fetch(cb.clone());
}
}
dec
};
let reader = &mut reader;
// Mapfile: load if resuming, else wipe + recreate.
let mapfile_path = self.mapfile_for(path);
// covers_disc reconciliation. A resume against a mapfile whose total
// size != the real disc size is unsafe — exactly the case copy()'s
// dispatch forces to a fresh sweep (see Disc::copy). Under-cover
// (map < disc) abandons the disc tail [map.total_size(), disc);
// over-cover (map > disc) reads LBAs past capacity. When sweep() is
// called directly (not via copy()), apply the same downgrade: drop the
// stale mapfile and sweep [0, total_bytes) fresh.
let mut resume = opts.resume;
if resume && mapfile_path.exists() {
match mapfile::Mapfile::load(&mapfile_path) {
Ok(existing) => {
if existing.total_size() != total_bytes {
tracing::info!(
"sweep: mapfile total_size {} != disc {}; forcing fresh sweep",
existing.total_size(),
total_bytes,
);
resume = false;
} else {
// Inconsistent-resume guard. The mapfile claims prior
// progress (some range past NonTried) but the ISO is
// missing or zero-length — the ISO was deleted or
// truncated while the mapfile survived (reachable via
// autorip ResumeMode::Require). The producer only builds
// work from NonTried ranges, so any Finished range would
// never be re-read and would stay ZERO in the fresh ISO,
// silently holed. Downgrade to a fresh full sweep (mirror
// the total_size-mismatch case) so the rip self-heals.
let iso_len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let claims_progress =
existing.stats().bytes_pending != existing.total_size();
if iso_len == 0 && claims_progress {
tracing::info!(
"sweep: mapfile claims prior progress (pending {} of {}) but ISO is missing/zero-length; forcing fresh sweep",
existing.stats().bytes_pending,
existing.total_size(),
);
resume = false;
}
}
}
Err(_) => {
// The mapfile exists but is corrupt / unparseable. Proceeding
// with resume=true would hand a garbage (or empty) mapfile to
// open_or_create and silently skip already-Finished ranges or
// mis-track progress. Downgrade to a fresh sweep — consistent
// with the total_size-mismatch branch above — so the `!resume`
// path below drops the corrupt mapfile and the rip restarts
// clean.
tracing::info!(
"sweep: mapfile at {} is corrupt/unparseable; forcing fresh sweep",
mapfile_path.display(),
);
resume = false;
}
}
}
if !resume {
// A fresh sweep MUST start from an empty mapfile. If the stale file
// can't be removed, open_or_create would load it and the new disc
// would inherit the old Finished ranges → silently zero-filled ISO.
// ENOENT is fine (nothing to remove); any other error aborts.
match std::fs::remove_file(&mapfile_path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(Error::IoError { source: e }),
}
}
let mut map = mapfile::Mapfile::open_or_create(
&mapfile_path,
total_bytes,
concat!("libfreemkv v", env!("CARGO_PKG_VERSION")),
)
.map_err(|e| Error::IoError { source: e })?;
// Persist the disc's decryption state into the mapfile header so it
// survives to deferred-mux / resume. ddrescue-safe (comment lines);
// does not touch the ISO payload. KEYS XOR VID: a keyed disc writes its
// unit keys (the final answer — deferred-mux decrypts directly, no key
// service); an unresolved disc writes only the VID (the retry marker).
if !opts.unit_keys.is_empty() {
map.set_unit_keys(&opts.unit_keys);
} else if let Some(vid) = opts.vid {
map.set_vid(vid);
}
// ISO file: if resuming and mapfile has Finished ranges, open existing;
// otherwise create fresh and pre-size to total_bytes (sparse holes for
// non-tried regions).
//
// `is_regular` MUST be read from the OPEN file handle, not from
// `metadata(path)` — on a fresh rip the path does not exist yet, so a
// pre-create `metadata(path)` always fails (is_regular=false), which both
// skips the pre-size AND makes `SweepSink::close` swallow a real
// `sync_all()` failure on the just-written ISO as if it were /dev/null.
let (file, is_regular) = if resume
&& std::fs::metadata(path)
.map(|m| m.len() > 0)
.unwrap_or(false)
{
let f = std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(|e| Error::IoError { source: e })?;
let reg = f
.metadata()
.map(|m| m.file_type().is_file())
.unwrap_or(false);
(f, reg)
} else {
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
let reg = f
.metadata()
.map(|m| m.file_type().is_file())
.unwrap_or(false);
if reg {
f.set_len(total_bytes)
.map_err(|e| Error::IoError { source: e })?;
}
(f, reg)
};
// Wrap the raw `File` in our bounded-cache `WritebackFile`
// (drains dirty pages continuously instead of bursting; see
// `crate::io`). The `WritebackFile` moves into the consumer
// thread.
let file = crate::io::WritebackFile::new(file).map_err(|e| Error::IoError { source: e })?;
let mut batch: u16 = match opts.batch_sectors {
Some(b) => b,
None if opts.skip_on_error => ecc_sectors(self.format),
None => DEFAULT_BATCH_SECTORS_OPTICAL,
};
// AACS unit alignment for a DECRYPTING sweep. AACS aligned units are 3
// sectors (6144 bytes); `decrypt_sectors` anchors units at buffer offset
// 0, so every read handed to the decrypting reader MUST start on a unit
// boundary AND span a whole number of units — otherwise units straddle
// batch/region boundaries and decrypt under the wrong CBC/unit alignment
// (the verify-gate then leaves content encrypted or aborts DecryptFailed).
//
// ecc_sectors() is 32 for UHD/BD, which is NOT a multiple of 3, so the
// default batch would start every batch-after-the-first mid-unit. Round
// the batch UP to the next multiple of 3 (32 → 33) when this sweep both
// decrypts and is AACS-keyed. Region read-starts are aligned DOWN to a
// unit boundary in the loop below; a fresh sweep starts at LBA 0 (already
// aligned), so alignment only bites on resume NonTried regions.
const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3
if decrypt_is_aacs && batch % UNIT_SECTORS != 0 {
batch = batch.saturating_add(UNIT_SECTORS - (batch % UNIT_SECTORS));
}
// Pre-compute the list of NonTried regions before handing the
// mapfile to the consumer thread. Each region is processed by
// the producer in order; the consumer mutates the mapfile per
// work-item. Any regions left as NonTrimmed/Unreadable after
// sweep finishes are the patch pass's job.
let regions: Vec<(u64, u64)> = map.ranges_with(&[mapfile::SectorStatus::NonTried]);
// Spawn the consumer. It owns WritebackFile + Mapfile; the producer
// (this thread) keeps `reader`, `read_ctx`, halt + set_speed.
// The thread name is preserved from the 0.17.x sweep_pipeline so it
// stays identifiable in stack traces / `top -H`.
let (sink, prog_rx) = SweepSink::new(file, map, is_regular);
let pipe: Pipeline<WorkItem, sweep::ConsumerSummary> =
Pipeline::spawn_named("freemkv-sweep-consumer", DEFAULT_PIPELINE_DEPTH, sink)?;
// Translate `Pipeline::send` failure (consumer gone) into a
// numeric library error so the producer-error semantics are
// unchanged but no English leaks into an io::Error.
fn consumer_gone() -> Error {
Error::PipelineConsumerGone
}
let mut buf = vec![0u8; batch as usize * 2048];
let mut bytes_done = 0u64;
let mut halt_requested = false;
let copy_t0 = std::time::Instant::now();
tracing::info!(
target: "freemkv::scan",
phase = "sweep",
total_bytes,
skip_on_error = opts.skip_on_error,
resume,
"begin"
);
let mut iter_count: u64 = 0;
let mut read_ok_count: u64 = 0;
let mut read_err_count: u64 = 0;
let mut last_log_iter: u64 = 0;
// Sweep heartbeat: fire every 5s OR every 100 iterations, whichever
// comes first, so a slow-but-alive sweep on a marginal disc keeps
// emitting "no silent hang" liveness even between the 100-iter marks.
let mut last_log_time = std::time::Instant::now();
let mut read_ctx = read_error::ReadCtx::for_sweep(batch);
let mut in_damage_zone = false;
const DAMAGE_ZONE_EXIT_THRESHOLD: u64 = 16;
let mut cached_snapshot: Option<ProgressSnapshot> = None;
let mut producer_err: Option<Error> = None;
tracing::trace!(
target: "freemkv::disc",
phase = "copy_start",
total_bytes,
batch,
skip_on_error = opts.skip_on_error,
regions = regions.len(),
"Disc::sweep entered (producer/consumer)"
);
// Request the drive's max read speed for the whole sweep — removes
// riplock. BD/UHD get their speed from the drive unlock/init, but a
// DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so
// without this explicit SET CD SPEED a DVD rip sweeps at the drive's
// default (riplocked) speed. The damage-recovery branch below also
// re-asserts max speed after slowing on bad sectors; this sets it once
// up front so a clean disc never pays the riplock penalty.
reader.set_speed(0xFFFF);
'outer: for (region_pos, region_size) in regions {
let region_end = region_pos + region_size;
// AACS unit alignment: anchor the region's read cursor DOWN to the
// nearest 6144-byte unit boundary so the decrypting reader never gets
// a buffer that starts mid-unit. Re-reading the few already-covered
// head sectors is idempotent (they re-decrypt identically and the
// consumer overwrites the same ISO offsets / mapfile ranges). A fresh
// sweep's NonTried region starts at 0, already unit-aligned; this only
// shifts resume regions that begin mid-unit.
let mut pos = if decrypt_is_aacs {
let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
region_pos - (region_pos % unit_bytes)
} else {
region_pos
};
tracing::trace!(
target: "freemkv::disc",
phase = "region_enter",
region_pos,
region_size,
region_end,
"entering NonTried region"
);
while pos < region_end {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halt_requested = true;
break 'outer;
}
}
let block_bytes = (region_end - pos).min(batch as u64 * 2048);
let block_lba = (pos / 2048) as u32;
let block_count = (block_bytes / 2048) as u16;
let recovery = !opts.skip_on_error;
let read_result = reader.read_sectors(
block_lba,
block_count,
&mut buf[..block_bytes as usize],
recovery,
);
match read_result {
Ok(_) => {
read_ok_count += 1;
read_ctx.on_success();
if read_ctx.consecutive_good >= DAMAGE_ZONE_EXIT_THRESHOLD {
read_ctx.jump_multiplier = 1;
if in_damage_zone {
in_damage_zone = false;
reader.set_speed(0xFFFF);
tracing::debug!(
target: "freemkv::disc",
phase = "damage_exit",
lba = block_lba,
"Exited damage zone; restoring max read speed"
);
}
}
// bridge_degradation_count is reset inside on_success()
// (called above); no separate reset needed here.
// Plaintext: the wrapped reader (DecryptingSectorSource)
// applied AACS / CSS in-place during read_sectors above.
// The consumer thread sees decrypted bytes; the
// pre-0.18 inline decrypt_sectors call lived here.
// Move the batch into the channel via fresh
// owned Vec. The producer's `buf` is reused
// for the next read.
let send_buf = buf[..block_bytes as usize].to_vec();
if pipe.send(WorkItem::Good { pos, buf: send_buf }).is_err() {
producer_err = Some(consumer_gone());
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
pos += block_bytes;
}
Err(err) if !opts.skip_on_error => {
let (status, sense) = extract_scsi_context(&err);
producer_err = Some(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
break 'outer;
}
Err(err) => {
read_err_count += 1;
let action = read_error::handle_read_error(&err, &mut read_ctx);
match action {
read_error::ReadAction::Retry { pause_secs } => {
sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
}
read_error::ReadAction::Bisect => {
read_ctx.bisecting = true;
let saved_batch = read_ctx.batch;
read_ctx.batch = 1;
let mut bisect_aborted = false;
for sector_offset in 0..block_count {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halt_requested = true;
bisect_aborted = true;
break;
}
}
let sector_lba = block_lba + (sector_offset as u32);
let mut sector_buf = [0u8; 2048];
let write_pos = pos + (sector_offset as u64 * 2048);
match reader.read_sectors(
sector_lba,
1,
&mut sector_buf[..],
true,
) {
Ok(_) => {
read_ctx.on_success();
// Plaintext via the wrapping
// DecryptingSectorSource — same
// decrypt path the batch read takes.
if pipe
.send(WorkItem::BisectGood {
pos: write_pos,
buf: Box::new(sector_buf),
})
.is_err()
{
producer_err = Some(consumer_gone());
bisect_aborted = true;
break;
}
}
Err(inner_err) => {
let inner_action = read_error::handle_read_error(
&inner_err,
&mut read_ctx,
);
match inner_action {
read_error::ReadAction::Retry { pause_secs } => {
// Transient (NOT_READY / bridge
// degradation): honour the
// cooldown pause, then mark
// BisectBad and move on. We
// are already inside a
// single-sector retry; a
// second bisect would be
// nonsensical (ctx.bisecting
// is true, so handle_read_error
// can't return Bisect).
sleep_secs_or_halt(
pause_secs,
opts.halt.as_ref(),
);
}
read_error::ReadAction::AbortPass => {
// Transport failure or
// wedge-abort threshold
// reached: stop immediately.
let (status, sense) =
extract_scsi_context(&inner_err);
producer_err = Some(Error::DiscRead {
sector: sector_lba as u64,
status: Some(status),
sense,
});
bisect_aborted = true;
break;
}
// JumpAhead / SkipBlock: honour
// any indicated pause; the
// bisect-inner loop's job is just
// to classify this specific sector,
// so we still mark BisectBad and
// continue to the next sector.
read_error::ReadAction::JumpAhead {
pause_secs,
..
}
| read_error::ReadAction::SkipBlock {
pause_secs,
} => {
sleep_secs_or_halt(
pause_secs,
opts.halt.as_ref(),
);
}
// Bisect cannot recurse: ctx.bisecting
// is true so handle_read_error will
// never return Bisect here.
read_error::ReadAction::Bisect => {}
}
if pipe
.send(WorkItem::BisectBad { pos: write_pos })
.is_err()
{
producer_err = Some(consumer_gone());
bisect_aborted = true;
break;
}
}
}
}
read_ctx.bisecting = false;
read_ctx.batch = saved_batch;
if bisect_aborted {
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
pos += block_bytes;
}
read_error::ReadAction::SkipBlock { pause_secs } => {
if pipe
.send(WorkItem::SkipFill {
pos,
len: block_bytes,
})
.is_err()
{
producer_err = Some(consumer_gone());
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
pos += block_bytes;
}
read_error::ReadAction::JumpAhead {
sectors,
pause_secs,
} => {
if pipe
.send(WorkItem::SkipFill {
pos,
len: block_bytes,
})
.is_err()
{
producer_err = Some(consumer_gone());
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
if !in_damage_zone {
in_damage_zone = true;
reader.set_speed(0x0000);
tracing::debug!(
target: "freemkv::disc",
phase = "damage_enter",
lba = block_lba,
"Entered damage zone; dropping to minimum read speed"
);
}
// Saturating throughout — the read_error side
// computes the sector count with saturating_mul as
// "defence in depth"; honor the same guarantee at
// the consuming multiply/add so a pathological jump
// distance can't wrap.
let jump_pos = pos
.saturating_add(block_bytes)
.saturating_add(sectors.saturating_mul(2048))
.min(region_end);
let gap_start = pos + block_bytes;
let gap_bytes = jump_pos.saturating_sub(gap_start);
if gap_bytes > 0 {
if pipe
.send(WorkItem::GapFill {
pos: gap_start,
len: gap_bytes,
})
.is_err()
{
producer_err = Some(consumer_gone());
break 'outer;
}
bytes_done = bytes_done.saturating_add(gap_bytes);
}
tracing::warn!(
target: "freemkv::disc",
phase = "damage_jump",
from_lba = block_lba,
to_lba = (jump_pos / 2048) as u32,
jump_mb = gap_bytes / 1_048_576,
"damage-jump"
);
pos = jump_pos;
sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
}
read_error::ReadAction::AbortPass => {
let (status, sense) = extract_scsi_context(&err);
producer_err = Some(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
break 'outer;
}
}
}
}
iter_count += 1;
// Drain any consumer-side stats snapshot.
if let Some(snap) = try_recv_progress(&prog_rx) {
cached_snapshot = Some(snap);
}
let time_due = last_log_time.elapsed() >= std::time::Duration::from_secs(5);
if iter_count - last_log_iter >= 100 || time_due {
last_log_iter = iter_count;
last_log_time = std::time::Instant::now();
// Promoted trace -> debug ("no silent hangs"): the sweep
// heartbeat must be visible at the standard debug level, not
// only the trace firehose. Carries lba/pos/region_end and
// bytes_good when a consumer snapshot is available.
let lba = (pos / 2048) as u32;
if let Some(ref snap) = cached_snapshot {
tracing::debug!(
target: "freemkv::disc",
phase = "iter_progress",
iter_count,
read_ok_count,
read_err_count,
lba,
pos,
region_end,
bytes_good = snap.stats.bytes_good,
bytes_pending = snap.stats.bytes_pending,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Disc::sweep inner iter"
);
} else {
tracing::debug!(
target: "freemkv::disc",
phase = "iter_progress",
iter_count,
read_ok_count,
read_err_count,
lba,
pos,
region_end,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Disc::sweep inner iter"
);
}
// Throttled stats refresh request — best-effort
// try_send so a busy consumer doesn't stall the
// producer; the cached snapshot stays current
// enough for one more iteration.
let _ = pipe.try_send(WorkItem::StatsRequest);
}
if let Some(reporter) = opts.progress {
// Use the latest consumer snapshot if we have
// one; otherwise synthesise a producer-side
// placeholder. On a fresh sweep, before the
// first stats round-trip lands, this means
// bytes_good ≈ bytes_done (producer's notion of
// good-so-far) and the bad-range list is empty —
// close enough for an early UI tick; the next
// real snapshot replaces it.
let main_title = self.titles.first();
let main_title_bad = match &cached_snapshot {
Some(snap) => self
.titles
.first()
.map(|t| bytes_bad_in_title(t, &snap.bad_ranges))
.unwrap_or(0),
None => 0,
};
// The consumer's snapshot is the source of truth for
// bytes_unreadable / bytes_pending (the producer doesn't
// see them), but its bytes_good lags producer-side
// `bytes_done` whenever the consumer is behind on draining
// the work channel. Take the max so the user-visible
// counter never regresses below what the producer has
// already sent — Anomaly B in the 0.18.1 prod test was
// this regression: a stale early snapshot pinned the
// display to 0 GB while bytes_done was already advancing.
let (bytes_good, bytes_unreadable, bytes_pending, bytes_retryable) =
match &cached_snapshot {
Some(snap) => (
snap.stats.bytes_good.max(bytes_done),
snap.stats.bytes_unreadable,
snap.stats.bytes_pending,
snap.stats.bytes_retryable,
),
None => (
bytes_done,
0u64,
total_bytes.saturating_sub(bytes_done),
0u64,
),
};
let pp = crate::progress::PassProgress {
kind: crate::progress::PassKind::Sweep,
work_done: pos,
work_total: total_bytes,
bytes_good_total: bytes_good,
bytes_unreadable_total: bytes_unreadable,
bytes_pending_total: bytes_pending,
bytes_retryable_total: bytes_retryable,
bytes_total_disc: total_bytes,
disc_duration_secs: main_title.map(|t| t.duration_secs),
bytes_bad_in_main_title: main_title_bad,
main_title_duration_secs: main_title.map(|t| t.duration_secs),
main_title_size_bytes: main_title.map(|t| t.size_bytes),
// Rendered drilldown from the consumer's in-memory
// snapshot (bad ranges) + title; empty until the first
// snapshot arrives.
located: match &cached_snapshot {
Some(snap) => main_title
.map(|t| locate_ranges(&snap.bad_ranges, t))
.unwrap_or_default(),
None => crate::progress::LocatedProgress::default(),
},
};
if !reporter.report(&pp) {
halt_requested = true;
break 'outer;
}
}
}
}
// Producer side is done. Drop the channel and let the
// consumer drain whatever's still in flight, then run its
// close() (drain writeback, fsync, mapfile.flush) and return
// the final stats. On consumer panic `pipe.finish` returns
// the wrapped panic message via Error::IoError — same shape
// the previous `consumer_handle.join().map_err(...)` produced.
let summary = pipe.finish();
// Producer-side error wins over consumer-side (the read failure
// is what motivated quitting; the consumer's flush error, if
// any, is downstream).
if let Some(e) = producer_err {
// Drop the consumer's result if we already have a producer
// error, but propagate consumer-panic on top of nothing
// since that's strictly informative.
let _ = summary;
return Err(e);
}
let summary = summary?;
let stats = summary.stats;
tracing::debug!(
target: "freemkv::disc",
phase = "sweep_done",
iter_count,
read_ok_count,
read_err_count,
bytes_good = stats.bytes_good,
bytes_pending = stats.bytes_pending,
halted = halt_requested,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Disc::sweep returning"
);
// End-of-pass diagnostic summary (added 2026-05-10 alongside
// the per-error timing instrumentation in read_error.rs).
// One INFO line per sweep that lets a post-mortem analyst tell
// at a glance how much damage the disc + drive saw, without
// grepping through the per-error WARN log. The PassSummary
// counters come from `ReadCtx`'s accumulated state.
let pass_sum = read_ctx.pass_summary();
tracing::info!(
target: "freemkv::disc",
phase = "pass1_summary",
total_reads_ok = pass_sum.total_reads_ok,
total_errors = pass_sum.total_errors,
zones_entered = pass_sum.zones_entered,
jumps_taken = pass_sum.jumps_taken,
bytes_good = stats.bytes_good,
bytes_pending = stats.bytes_pending,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Pass 1 complete"
);
Ok(CopyResult {
bytes_total: total_bytes,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: stats.bytes_pending,
recovered_this_pass: 0,
complete: stats.bytes_pending == 0 && !halt_requested,
halted: halt_requested,
})
}
}
#[derive(Default)]
pub struct CopyOptions<'a> {
pub decrypt: bool,
pub multipass: bool,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// AACS Volume ID (16 bytes) to persist into the mapfile during
/// Pass 1 so it survives to deferred-mux / resume. `None` for
/// unencrypted / non-AACS discs. Caller wires this from
/// `Disc::aacs.volume_id`.
///
/// Persisted ONLY when `unit_keys` is empty (the disc didn't resolve a
/// key): the VID is the "still unresolved, retry-able" marker.
pub vid: Option<[u8; 16]>,
/// Resolved AACS unit keys `(CPS unit, key)` to persist into the mapfile
/// during Pass 1. When non-empty these are written (the final answer, so
/// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID.
/// Caller wires this from `Disc::aacs.unit_keys`.
pub unit_keys: Vec<(u32, [u8; 16])>,
/// On-decrypt-miss key fetch (see [`crate::keysource::key_fetch_factory`]).
/// When set, a read that hits AACS ciphertext no held key opens asks the
/// application's key sources for the CPS unit's key, caches it, and retries —
/// recovering an orphan CPS unit never sampled at resolve time. `None`
/// disables it (the prior behaviour). Threaded into sweep + patch.
pub key_fetch: Option<crate::sector::KeyFetch>,
}
#[derive(Debug, Clone, Copy)]
pub struct CopyResult {
pub bytes_total: u64,
pub bytes_good: u64,
pub bytes_unreadable: u64,
pub bytes_pending: u64,
pub recovered_this_pass: u64,
pub complete: bool,
pub halted: bool,
}
/// Options for [`Disc::sweep`] (Pass 1 / forward sequential pass).
pub struct SweepOptions<'a> {
pub decrypt: bool,
pub resume: bool,
pub batch_sectors: Option<u16>,
pub skip_on_error: bool,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// AACS Volume ID (16 bytes) persisted into the mapfile when the
/// sweep creates / opens it. `None` for unencrypted discs. Written ONLY
/// when `unit_keys` is empty (keys XOR VID — the VID is the retry marker).
pub vid: Option<[u8; 16]>,
/// Resolved AACS unit keys persisted into the mapfile when the sweep
/// creates / opens it. When non-empty these win over `vid`.
pub unit_keys: Vec<(u32, [u8; 16])>,
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]).
pub key_fetch: Option<crate::sector::KeyFetch>,
}
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
pub struct PatchOptions<'a> {
pub decrypt: bool,
pub block_sectors: Option<u16>,
pub full_recovery: bool,
pub reverse: bool,
pub wedged_threshold: u64,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
/// recover an orphan CPS unit's key when re-reading its bad range.
pub key_fetch: Option<crate::sector::KeyFetch>,
}
/// Result returned by [`Disc::patch`].
pub struct PatchOutcome {
pub bytes_total: u64,
pub bytes_good: u64,
pub bytes_unreadable: u64,
pub bytes_pending: u64,
pub bytes_recovered_this_pass: u64,
pub halted: bool,
pub wedged_exit: bool,
pub wedged_threshold: u64,
}
/// Sleep `secs` seconds, but break early if `halt` flips to true.
/// Used by Pass 1's wedge-avoidance inter-error pause so halt
/// remains responsive regardless of how long the pause is.
/// Polling granularity 100 ms — bounded latency on halt regardless
/// of pause length.
pub(crate) fn sleep_secs_or_halt(
secs: u64,
halt: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
) {
if secs == 0 {
return;
}
let Some(h) = halt else {
std::thread::sleep(std::time::Duration::from_secs(secs));
return;
};
let total = std::time::Duration::from_secs(secs);
let slice = std::time::Duration::from_millis(100);
let start = std::time::Instant::now();
while start.elapsed() < total {
if h.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
let remaining = total.saturating_sub(start.elapsed());
std::thread::sleep(remaining.min(slice));
}
}
/// Mapfile path for a regular output file: appends `.mapfile` to the
/// output path. For `/dev/null` (benchmark) output use
/// [`Disc::mapfile_for`], which special-cases it to a temp-dir path
/// derived from the disc title.
pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf {
let mut s = iso_path.as_os_str().to_os_string();
s.push(".mapfile");
std::path::PathBuf::from(s)
}
impl Disc {
/// Path to the mapfile for a given output path.
///
/// For `/dev/null` output, returns
/// `{temp_dir}/{volume_id_or_title}.mapfile` (temp dir is
/// `TMPDIR`-aware and cross-platform). For regular files, returns
/// `{path}.mapfile`.
pub fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf {
if path.as_os_str() == "/dev/null" {
let name: String = self
.meta_title
.as_deref()
.unwrap_or(&self.volume_id)
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
std::env::temp_dir().join(format!("{name}.mapfile"))
} else {
mapfile_path_for(path)
}
}
}
const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192;
const MIN_BATCH_SECTORS: u16 = 3;
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
match format {
// BD-family 64 KiB ECC block (32 × 2048). FMTS is a UHD BD disc.
DiscFormat::Uhd | DiscFormat::Fmts | DiscFormat::BluRay => 32,
// 32 KiB ECC block (16 × 2048) — DVD and HD-DVD.
DiscFormat::Dvd | DiscFormat::HdDvd => 16,
DiscFormat::Unknown => 32,
}
}
/// Coarse damage tier for a finished or in-progress rip. Maps the
/// observable signals (bad sector count + lost wallclock playback time)
/// onto a small discrete classification so UIs can render a colored badge
/// and operators can decide whether to rescan / replug / accept.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DamageSeverity {
/// No bad sectors at all.
Clean,
/// 150 bad sectors AND <1 sec lost. Likely unnoticeable.
Cosmetic,
/// 51500 sectors OR 130 sec lost. Visible artifacts possible.
Moderate,
/// 500+ sectors OR 30+ sec lost. Significant damage; consider rescan
/// or different drive.
Serious,
}
/// Classify damage severity from raw counters. `bad_sectors` is the
/// number of sectors marked unreadable (or NonTrimmed pending Pass 2);
/// `lost_ms` is the cumulative wallclock playback time those sectors
/// represent (computed from the title's bytes-per-sec).
pub fn classify_damage(bad_sectors: u64, lost_ms: f64) -> DamageSeverity {
if bad_sectors == 0 {
return DamageSeverity::Clean;
}
if bad_sectors >= 500 || lost_ms >= 30_000.0 {
return DamageSeverity::Serious;
}
if bad_sectors >= 51 || lost_ms >= 1_000.0 {
return DamageSeverity::Moderate;
}
DamageSeverity::Cosmetic
}
#[cfg(test)]
mod severity_tests {
use super::*;
#[test]
fn clean_when_no_damage() {
assert_eq!(classify_damage(0, 0.0), DamageSeverity::Clean);
}
#[test]
fn cosmetic_for_a_handful() {
assert_eq!(classify_damage(1, 5.0), DamageSeverity::Cosmetic);
assert_eq!(classify_damage(50, 999.0), DamageSeverity::Cosmetic);
}
#[test]
fn moderate_threshold_by_sectors() {
assert_eq!(classify_damage(51, 0.0), DamageSeverity::Moderate);
}
#[test]
fn moderate_threshold_by_time() {
assert_eq!(classify_damage(10, 1_000.0), DamageSeverity::Moderate);
}
#[test]
fn serious_threshold_by_sectors() {
assert_eq!(classify_damage(500, 0.0), DamageSeverity::Serious);
}
#[test]
fn serious_threshold_by_time() {
assert_eq!(classify_damage(10, 30_000.0), DamageSeverity::Serious);
}
}
/// Whether the Linux-sysfs transfer-size probe applies to this device path.
///
/// The probe reads `/sys/block/<name>/...` / `/sys/class/scsi_generic/<name>/...`,
/// which only exist on Linux and only for `/`-delimited node paths. A Windows
/// `\\.\CdRom0` / `\\.\D:` path has no forward slash and no sysfs node, so the
/// probe cannot run and the caller must fall back to the optical default.
fn sysfs_batch_probe_supported(device_path: &str) -> bool {
cfg!(target_os = "linux") && device_path.contains('/')
}
/// Detect the maximum transfer size in sectors for a device.
pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
// The sysfs probe below is Linux-only. Non-sysfs platforms (Windows in
// particular) use `\\.\`-form device paths (e.g. `\\.\CdRom0`, `\\.\D:`)
// that have no forward slash, so the Linux name-parsing below would treat
// the whole path as the device name, find no `/sys` node, and fall through
// to the block default (8192 sectors = 16 MiB) — far over the optical cap.
// Every device we open on a non-sysfs platform here is an optical drive,
// so return the optical default directly.
if !sysfs_batch_probe_supported(device_path) {
return DEFAULT_BATCH_SECTORS_OPTICAL;
}
let dev_name = device_path.rsplit('/').next().unwrap_or("");
if dev_name.is_empty() {
return DEFAULT_BATCH_SECTORS_OPTICAL;
}
// Check whether THIS device (not any device on the host) is an
// optical drive: read the SCSI peripheral type of the target node
// only. Type 0x05 (decimal 5) = CD/DVD. A previous version scanned
// every /sys/class/scsi_device entry and returned true if any was
// optical, misclassifying a block device as optical on a host that
// also has an optical drive.
let is_optical = {
// For an sg node the type lives at scsi_generic/<sg>/device/type;
// for a block node (sr0/sdX) at /sys/block/<name>/device/type.
let type_path = if dev_name.starts_with("sg") {
format!("/sys/class/scsi_generic/{dev_name}/device/type")
} else {
format!("/sys/block/{dev_name}/device/type")
};
std::fs::read_to_string(&type_path)
.ok()
.map(|c| c.trim().parse::<u32>() == Ok(5))
.unwrap_or(false)
};
if is_optical {
// For sg devices, find the corresponding block device name
let block_name = if dev_name.starts_with("sg") {
let block_dir = format!("/sys/class/scsi_generic/{dev_name}/device/block");
std::fs::read_dir(&block_dir)
.ok()
.and_then(|mut entries| entries.next())
.and_then(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
} else {
Some(dev_name.to_string())
};
if let Some(bname) = block_name {
let sysfs_path = format!("/sys/block/{bname}/queue/max_hw_sectors_kb");
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
if let Ok(kb) = content.trim().parse::<u32>() {
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
let sectors = (kb / 2).min(u16::MAX as u32) as u16;
// Align down to 3 (one aligned unit)
let aligned = (sectors / 3) * 3;
if aligned >= MIN_BATCH_SECTORS {
return aligned.min(MAX_BATCH_SECTORS);
}
}
}
}
DEFAULT_BATCH_SECTORS_OPTICAL
} else {
DEFAULT_BATCH_SECTORS_BLOCK
}
}
// ─── Format helpers ────────────────────────────────────────────────────────
// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums
#[cfg(test)]
mod tests {
use super::*;
/// `AacsState` (public via `Disc.aacs`) and `Key` (the key-transport enum)
/// must never print raw key bytes on `{:?}`. Sentinel 213 (0xD5); non-secret
/// fields below are not 213.
#[test]
fn aacs_state_and_key_debug_are_redacted() {
let st = AacsState {
version: 2,
bus_encryption: true,
mkb_version: Some(77),
disc_hash: "0xAA".into(),
key_source: KeyOrigin::ExternalUk,
vuk: Some([0xD5; 16]),
unit_keys: vec![(1, [0xD5; 16])],
read_data_key: Some([0xD5; 16]),
volume_id: [0xD5; 16],
uk_ro: vec![1, 2, 3],
mkb: vec![4, 5, 6],
};
let d = format!("{st:?}");
assert!(!d.contains("213"), "AacsState leaked key bytes: {d}");
assert!(d.contains("redacted"), "AacsState missing marker: {d}");
for k in [
Key::Unit(vec![(1, [0xD5; 16])]),
Key::Volume([0xD5; 16]),
Key::Processing(vec![[0xD5; 16]]),
Key::Media(vec![[0xD5; 16]]),
] {
let d = format!("{k:?}");
assert!(!d.contains("213"), "Key leaked bytes: {d}");
assert!(d.contains("redacted"), "Key missing marker: {d}");
}
}
// ── encrypted-content map (`merged_extents` core) ────────────────────────
fn ext(start_lba: u32, sector_count: u32) -> Extent {
Extent {
start_lba,
sector_count,
}
}
#[test]
fn merged_extents_empty_is_empty() {
assert_eq!(merged_extents([].iter()), Vec::<(u32, u32)>::new());
}
#[test]
fn merged_extents_single() {
assert_eq!(merged_extents([ext(100, 50)].iter()), vec![(100, 50)]);
}
/// Out-of-order extents from several titles, with an OVERLAP, an ADJACENT
/// pair, and a DISJOINT one, must come back sorted + merged + disjoint.
#[test]
fn merged_extents_unions_sorts_and_merges() {
// [300,310) ; [100,150) ; [150,200) adjacent→merges with prev ;
// [120,160) overlaps [100,150)&[150,200) ; [500,505) disjoint.
let v = vec![
ext(300, 10),
ext(100, 50),
ext(150, 50),
ext(120, 40),
ext(500, 5),
];
assert_eq!(
merged_extents(v.iter()),
vec![(100, 100), (300, 10), (500, 5)],
"[100,200) merged, [300,310), [500,505)"
);
}
/// The same clip referenced by two titles (identical extents) de-duplicates
/// to a single range — no double-counting of shared content.
#[test]
fn merged_extents_dedups_shared_clip() {
let v = vec![ext(100, 50), ext(100, 50)];
assert_eq!(merged_extents(v.iter()), vec![(100, 50)]);
}
/// A Windows-form optical device path (`\\.\CdRom0`, `\\.\D:`) must never
/// fall through to the block default (8192 sectors = 16 MiB, well over the
/// optical 510-sector cap). It has no forward slash, so the Linux-sysfs
/// name parse cannot apply; the detector must return the optical default.
#[test]
fn windows_device_path_uses_optical_default() {
for path in ["\\\\.\\CdRom0", "\\\\.\\CdRom15", "\\\\.\\D:", "\\\\.\\E:"] {
let batch = detect_max_batch_sectors(path);
assert_eq!(
batch, DEFAULT_BATCH_SECTORS_OPTICAL,
"windows path {path:?} must map to the optical default, got {batch}"
);
assert!(
batch <= MAX_BATCH_SECTORS,
"windows path {path:?} batch {batch} exceeds optical cap {MAX_BATCH_SECTORS}"
);
}
}
/// `read_aacs_inputs` on a missing/unreadable ISO must surface the real
/// I/O fault (`E_IO_ERROR`, 5000) carrying the OS errno — NOT `AacsNoKeys`
/// (7000). Collapsing ENOENT into a key error makes callers that dispatch
/// on `.code()` tell the user "no keys / check your KEYDB" when the actual
/// problem is that the ISO file does not exist.
#[test]
fn read_aacs_inputs_missing_iso_is_io_error_not_no_keys() {
let missing = std::path::Path::new("/nonexistent/freemkv/does-not-exist.iso");
let err = Disc::read_aacs_inputs(missing).expect_err("opening a nonexistent ISO must fail");
assert_eq!(
err.code(),
crate::error::E_IO_ERROR,
"missing ISO must map to E_IO_ERROR (5000), got {} ({err:?})",
err.code()
);
assert_ne!(
err.code(),
crate::error::E_AACS_NO_KEYS,
"missing ISO must not be reported as AacsNoKeys (7000)"
);
}
/// The sysfs probe only applies on Linux and only to `/`-delimited node
/// paths. A backslash-form path is never sysfs-probeable on any platform.
#[test]
fn windows_path_not_sysfs_probeable() {
assert!(!sysfs_batch_probe_supported("\\\\.\\CdRom0"));
assert!(!sysfs_batch_probe_supported("\\\\.\\D:"));
}
/// AACS unit-alignment of the DECRYPTING multipass sweep. AACS aligned units
/// are 3 sectors (6144 bytes); `decrypt_sectors` anchors units at buffer
/// offset 0, so the sweep MUST (a) round its per-batch sector count UP to a
/// multiple of 3 and (b) align each NonTried region's read cursor DOWN to a
/// unit boundary — otherwise batches after the first start mid-unit and every
/// unit decrypts under the wrong CBC/unit alignment.
///
/// This mirrors the exact arithmetic the sweep loop uses (the full path needs
/// a live AACS `Disc`, out of reach in a unit test). The decorator-level
/// reject for an unaligned start LBA is covered end-to-end in
/// `sector::decrypting::tests::aacs_unaligned_start_lba_rejected`.
#[test]
fn aacs_sweep_batch_and_region_are_unit_aligned() {
const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3
let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64; // 6144
// (a) Batch rounding: ecc_sectors() for UHD/BD is 32, not a multiple of 3.
// The decrypting-AACS path rounds it up to the next multiple of 3 (33).
for format in [DiscFormat::Uhd, DiscFormat::BluRay] {
let mut batch = ecc_sectors(format);
assert_eq!(batch, 32);
if batch % UNIT_SECTORS != 0 {
batch = batch.saturating_add(UNIT_SECTORS - (batch % UNIT_SECTORS));
}
assert_eq!(batch, 33, "batch must round 32 -> 33 (a multiple of 3)");
assert_eq!(batch % UNIT_SECTORS, 0);
// Every full batch read is then a whole number of 6144-byte units.
assert_eq!((batch as u64 * 2048) % unit_bytes, 0);
}
// (b) Region-start down-alignment. A resume NonTried region can begin
// mid-unit; aligning the read cursor DOWN to the nearest unit boundary
// makes block_lba % 3 == 0 for the first (and thus every) batch read.
// Re-reading the few head sectors is idempotent.
for region_pos in [0u64, 2048, 4096, 6144, 8192, 65536, 67_584] {
let pos = region_pos - (region_pos % unit_bytes);
assert_eq!(pos % unit_bytes, 0, "aligned cursor must be unit-aligned");
assert!(pos <= region_pos, "alignment only moves the cursor down");
// block_lba derived as pos/2048 must be a multiple of 3 sectors.
assert_eq!((pos / 2048) % UNIT_SECTORS as u64, 0);
}
// An already-aligned region (fresh sweep starts at 0) is unchanged.
assert_eq!(0u64 - (0u64 % unit_bytes), 0);
assert_eq!(6144u64 - (6144u64 % unit_bytes), 6144);
}
/// Helper: build a DiscTitle with a single video stream at the given resolution.
fn title_with_video(codec: Codec, resolution: Resolution) -> DiscTitle {
DiscTitle {
playlist: "00800.mpls".into(),
playlist_id: 800,
duration_secs: 7200.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![Stream::Video(VideoStream {
pid: 0x1011,
codec,
resolution,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
})],
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
#[test]
fn locate_ranges_at_risk_in_vs_out_of_feature() {
// The honest-"Maybe" behaviour the rendered drilldown must preserve:
// in-feature damage counts as movie time at risk; out-of-feature damage
// is still located but reads 0:00. (Ported from autorip's old
// RipProgress::from_map tests when that logic moved into the library.)
// bps = size_bytes / duration_secs = 4096 B/s → 4096 B == 1000 ms.
let mut title = title_with_video(Codec::Hevc, Resolution::R2160p);
title.duration_secs = 100.0;
title.size_bytes = 409_600;
// Feature extent = sectors [10,110) → bytes [20480, 225280).
title.extents = vec![Extent {
start_lba: 10,
sector_count: 100,
}];
// In-feature pending range: 4096 B == 1000 ms of movie at risk.
let in_feat = locate_ranges(&[(40_960, 4096)], &title);
assert_eq!(in_feat.num_ranges, 1);
assert!(
(in_feat.main_at_risk_ms - 1000.0).abs() < 1.0,
"in-feature damage must count as at-risk movie time, got {}",
in_feat.main_at_risk_ms
);
// Out-of-feature range: located, but zero movie time at risk.
let out_feat = locate_ranges(&[(2_000_000, 100_000)], &title);
assert_eq!(out_feat.num_ranges, 1, "still a located range");
assert_eq!(
out_feat.main_at_risk_ms, 0.0,
"out-of-feature damage must not read as movie loss"
);
}
/// Build a DiscTitle with full control over the fields the title
/// sorter cares about. Used by the canonical-title-order tests.
fn title_with(
playlist: &str,
duration_secs: f64,
size_bytes: u64,
n_clips: usize,
) -> DiscTitle {
let mut t = title_with_video(Codec::Hevc, Resolution::R2160p);
t.playlist = playlist.into();
t.duration_secs = duration_secs;
t.size_bytes = size_bytes;
t.clips = (0..n_clips)
.map(|i| Clip {
clip_id: format!("{i:05}"),
in_time: 0,
out_time: 1,
duration_secs: 1.0,
source_packets: 0,
})
.collect();
t
}
/// Regression for branching-UHD title ordering. Mirrors the live
/// observed *The Amateur (2025)* layout: a 4h13m / 92.4 GB / 253-clip
/// virtual play-all playlist alongside the real 2h02m / 57.2 GB /
/// 1-clip main feature. Disc capacity 58.5 GB. After sorting,
/// titles[0] must be the main feature, not the virtual composite.
#[test]
fn canonical_order_pushes_oversize_play_all_behind_real_main() {
const CAPACITY: u64 = 58_500_000_000; // 58.5 GB
let mut titles = vec![
// Title 1 in the raw MPLS order — virtual play-all
title_with(
"00020.mpls",
4.0 * 3600.0 + 13.0 * 60.0,
92_400_000_000,
253,
),
// Title 2 — actual movie
title_with("00800.mpls", 2.0 * 3600.0 + 2.0 * 60.0, 57_200_000_000, 1),
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(
titles[0].playlist, "00800.mpls",
"main feature should land at index 0"
);
assert_eq!(
titles[1].playlist, "00020.mpls",
"virtual play-all should be pushed back"
);
}
/// Non-branching disc: largest title is the movie. With realistic sizes
/// (bytes track duration for same-codec content) size-first yields the same
/// ranking as duration — biggest/longest feature, then extra, then menu.
#[test]
fn canonical_order_preserves_natural_ranking_on_normal_disc() {
const CAPACITY: u64 = 60_000_000_000;
let mut titles = vec![
title_with("00100.mpls", 600.0, 500_000_000, 1), // 10 min menu (small)
title_with("00800.mpls", 7320.0, 55_000_000_000, 1), // 2h02m main feature
title_with("00200.mpls", 1800.0, 2_000_000_000, 1), // 30 min extra
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(
titles[0].playlist, "00800.mpls",
"longest valid title still wins"
);
assert_eq!(titles[1].playlist, "00200.mpls");
assert_eq!(titles[2].playlist, "00100.mpls");
}
/// Contract pin (owner-flagged): `freemkv -t 1` ALWAYS selects the main
/// feature. The CLI's `-t 1` maps to `titles[0]`, and the title list is
/// ordered by `canonical_title_order` (main feature first), so `titles[0]`
/// IS the movie. Anything but the main feature at index 0 is a
/// title-ordering bug, not a remux problem. DVD-shaped fixture (DVD-9
/// capacity; a 1h49m main feature alongside a menu loop and a short extra).
#[test]
fn title_index_0_is_main_feature_dvd_the_dash_t_1_contract() {
const DVD9: u64 = 7_900_000_000; // dual-layer DVD
let mut titles = vec![
title_with("VTS_01_menu", 120.0, 200_000_000, 1), // 2m menu/setup loop
title_with("VTS_02_main", 6540.0, 6_300_000_000, 1), // 1h49m main feature
title_with("VTS_03_extra", 900.0, 800_000_000, 1), // 15m extra
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, DVD9));
assert_eq!(
titles[0].playlist, "VTS_02_main",
"titles[0] (== what `freemkv -t 1` selects) must be the DVD main feature"
);
}
#[test]
fn detect_format_uhd() {
let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
assert_eq!(Disc::detect_format(&titles), DiscFormat::Uhd);
}
#[test]
fn detect_format_bluray() {
let titles = vec![title_with_video(Codec::H264, Resolution::R1080p)];
assert_eq!(Disc::detect_format(&titles), DiscFormat::BluRay);
}
#[test]
fn detect_format_dvd() {
let titles = vec![title_with_video(Codec::Mpeg2, Resolution::R480i)];
assert_eq!(Disc::detect_format(&titles), DiscFormat::Dvd);
}
#[test]
fn detect_format_empty() {
let titles: Vec<DiscTitle> = Vec::new();
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
}
/// An AACS MKB Type-and-Version record (0x10) carrying `raw_type` — the only
/// record [`Disc::detect_disc_format`] reads to decide BD/UHD/FMTS.
fn mkb_type_record(raw_type: u32) -> Vec<u8> {
let mut v = vec![0x10, 0x00, 0x00, 0x0c]; // record type 0x10, rec_len 12
v.extend_from_slice(&raw_type.to_be_bytes()); // MKBType @ body offset 0
v.extend_from_slice(&0u32.to_be_bytes()); // version @ body offset 4
v
}
/// FORMAT derives from the AACS MKB generation, not the tree or filesystem:
/// 2.1 → FMTS, 2.0 → UHD, 1.0 → BD — all from the MKB Type record.
#[test]
fn detect_format_from_mkb_generation() {
use crate::udf::fixture::*;
for (raw, expected) in [
(0x4815_1003u32, DiscFormat::Fmts),
(0x4814_1003u32, DiscFormat::Uhd),
(0x0004_1003u32, DiscFormat::BluRay),
] {
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "BDMV".into(),
icb_lba: 12,
dir_data_lba: 13,
files: Vec::new(),
subdirs: vec![],
},
DirSpec {
name: "AACS".into(),
icb_lba: 14,
dir_data_lba: 15,
files: vec![file_with(
"MKB_RO.inf",
16,
5000,
mkb_type_record(raw),
true,
)],
subdirs: vec![],
},
],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
Disc::detect_disc_format(&mut disc, &udf, &[]),
expected,
"MKB type {raw:#010x}"
);
}
}
/// HD-DVD is a tree-level format — recognized from `HVDVD_TS/`, no MKB.
#[test]
fn detect_format_hddvd_from_tree() {
use crate::udf::fixture::*;
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".into(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
Disc::detect_disc_format(&mut disc, &udf, &[]),
DiscFormat::HdDvd
);
}
/// Title selection is by largest physical size, NOT clip count or duration.
/// Real-disc shape (Fast Five): a 57 GB / 11-clip feature must outrank both a
/// small 1-clip bonus reel and a long-but-tiny decoy "play-all" (91 reused
/// clips, 1h31m, 0.4 GB). The old clip-count-ascending key put the bonus t1.
#[test]
fn canonical_title_order_picks_largest_feature() {
fn title_sized(size_bytes: u64, duration_secs: f64, n_clips: usize) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs,
size_bytes,
clips: (0..n_clips)
.map(|i| Clip {
clip_id: format!("{i:05}"),
in_time: 0,
out_time: 0,
duration_secs: 0.0,
source_packets: 0,
})
.collect(),
streams: Vec::new(),
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
let capacity = 66_000_000_000u64;
let feature = title_sized(57_000_000_000, 7860.0, 11); // 2h11m, 11 chapters
let bonus = title_sized(1_200_000_000, 600.0, 1); // 10m, 1 clip
let decoy = title_sized(400_000_000, 5460.0, 91); // 1h31m but tiny (reused)
let mut v = vec![bonus, decoy, feature];
v.sort_by(|a, b| Disc::canonical_title_order(a, b, capacity));
assert_eq!(
v[0].size_bytes, 57_000_000_000,
"the largest real title is the main feature"
);
}
#[test]
fn content_format_default_bdts() {
let t = title_with_video(Codec::H264, Resolution::R1080p);
assert_eq!(t.content_format, ContentFormat::BdTs);
}
#[test]
fn content_format_dvd_mpegps() {
let t = DiscTitle {
content_format: ContentFormat::MpegPs,
..title_with_video(Codec::Mpeg2, Resolution::R480i)
};
assert_eq!(t.content_format, ContentFormat::MpegPs);
}
#[test]
fn disc_capacity_gb() {
// Single-layer BD-25: ~12,219,392 sectors
let disc = Disc {
volume_id: String::new(),
meta_title: None,
format: DiscFormat::BluRay,
capacity_sectors: 12_219_392,
capacity_bytes: 12_219_392u64 * 2048,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: ContentFormat::BdTs,
};
let gb = disc.capacity_gb();
// 12,219,392 * 2048 / 1073741824 = ~23.3 GB
assert!((gb - 23.3).abs() < 0.1, "expected ~23.3 GB, got {}", gb);
// Zero sectors
let disc_zero = Disc {
capacity_sectors: 0,
capacity_bytes: 0,
..disc
};
assert_eq!(disc_zero.capacity_gb(), 0.0);
}
#[test]
fn disc_title_duration_display_edge_cases() {
let mut t = DiscTitle::empty();
// 0 seconds
t.duration_secs = 0.0;
assert_eq!(t.duration_display(), "0h 00m");
// 1 second
t.duration_secs = 1.0;
assert_eq!(t.duration_display(), "0h 00m");
// 59 minutes
t.duration_secs = 59.0 * 60.0;
assert_eq!(t.duration_display(), "0h 59m");
// 24 hours
t.duration_secs = 24.0 * 3600.0;
assert_eq!(t.duration_display(), "24h 00m");
}
struct MockReader {
total_sectors: u32,
bad_sectors: std::collections::HashSet<u32>,
}
impl crate::sector::SectorSource for MockReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let n = count as usize * 2048;
for i in 0..count {
if self.bad_sectors.contains(&(lba + i as u32)) {
return Err(crate::error::Error::DiscRead {
sector: (lba + i as u32) as u64,
status: Some(0x02),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x02,
asc: 0x04,
ascq: 0x3E,
}),
});
}
}
buf[..n].fill(0xAA);
Ok(n)
}
fn capacity_sectors(&self) -> u32 {
self.total_sectors
}
}
fn make_test_disc(sectors: u32, name: &str) -> Disc {
Disc {
volume_id: name.into(),
meta_title: Some(name.into()),
format: DiscFormat::Uhd,
capacity_sectors: sectors,
capacity_bytes: sectors as u64 * 2048,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: ContentFormat::BdTs,
}
}
#[test]
fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() {
// Regression (E8005 deferred-mux loop): a keyed AACS disc swept WITHOUT a
// keydb scans to aacs=None + aacs_error=KeydbLoad, but its UK is persisted
// in the mapfile. At remux the UK is recovered and injected — that MUST
// yield usable decrypt keys. Before the fix, inject_unit_keys no-op'd
// (no aacs to mutate), decrypt_keys stayed None, and the mux deferred
// forever with "No keys available (E8005)" despite holding the UK.
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs_error = Some(crate::error::Error::KeydbLoad {
path: "<no keydb in search paths>".into(),
});
assert!(
matches!(disc.decrypt_keys(), crate::decrypt::DecryptKeys::None),
"precondition: encrypted disc with no aacs state => no decrypt keys"
);
let uk = vec![(0u32, [0x11u8; 16])];
disc.inject_unit_keys(uk.clone());
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs {
unit_keys,
read_data_key,
..
} => {
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
}
_ => panic!("expected Aacs decrypt keys after injecting a UK"),
}
assert!(
disc.aacs_error.is_none(),
"stale KeydbLoad must be cleared once a UK is in hand"
);
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeyOrigin::ExternalUk
);
}
#[test]
fn inject_unit_keys_labels_fmts_as_uhd_family() {
// FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK
// on an FMTS disc must synthesize the UHD version + bus encryption, not
// mislabel it AACS 1.0 / bus-off (which would break FMTS decryption on
// the mapfile-recovered-UK path).
let mut disc = make_test_disc(1000, "FMTS");
disc.format = DiscFormat::Fmts;
disc.encrypted = true;
disc.inject_unit_keys(vec![(0u32, [0x22u8; 16])]);
let aacs = disc.aacs.as_ref().expect("aacs state synthesized");
assert_eq!(
aacs.version,
crate::aacs::mkb::AACS_MAJOR_UHD,
"FMTS is AACS 2.x (UHD major), not BD"
);
assert!(aacs.bus_encryption, "FMTS is bus-encrypted like UHD");
}
/// Build an AacsState carrying the given unit keys (other fields are inert
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
AacsState {
version: 2,
bus_encryption: true,
mkb_version: None,
disc_hash: String::new(),
key_source: KeyOrigin::DeviceKey,
vuk: None,
unit_keys,
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
}
}
// ── ensure_decryptable: the system-wide decrypt verdict matrix ──────────
//
// This is the single gate every copy/mux entry point calls. The cases below
// are the full truth table: only "decryption needed AND unavailable AND not
// --raw" may error; every legit non-error case (raw / unencrypted / a
// resolved key) must proceed.
fn css_state() -> crate::css::CssState {
crate::css::CssState {
title_key: [0u8; 5],
crack_span: None,
}
}
/// AACS-encrypted disc, decryption requested, no unit key resolved → the
/// gate must fail with NoDiscKey (this is the headline bug: a pass-through
/// `DecryptingSectorSource` would otherwise write ciphertext at exit 0).
#[test]
fn ensure_decryptable_aacs_no_key_errors() {
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new())); // present but no unit keys → None
assert!(matches!(
disc.decrypt_keys(),
crate::decrypt::DecryptKeys::None
));
let err = disc
.ensure_decryptable(false)
.expect_err("AACS disc, no key, !raw must error");
assert_eq!(
err.code(),
crate::error::Error::NoDiscKey {
disc_hash: String::new()
}
.code()
);
}
/// E7017 vs E7022 split (rc.6 WS1). When key resolution HAD derivation
/// material (device / processing keys) but no Volume ID was available to
/// derive the unit key, the captured `aacs_error` is `AacsVidUnavailable`
/// — the gate must surface THAT (E7017), not the generic `NoDiscKey`
/// (E7022). When there was no usable key material at all, the reason is
/// absent and the gate keeps `NoDiscKey` (E7022). Both branches proven here.
#[test]
fn ensure_decryptable_aacs_vid_unavailable_vs_no_key() {
// Branch 1 — derivation material present, but no VID: E7017.
// The resolver classifies a device-keys-but-zero-VID context as
// `VidUnavailable`; that reason rides on `aacs_error`.
let supplied = crate::aacs::provider::SuppliedKey {
device_keys: vec![crate::aacs::types::DeviceKey {
key: [0x11; 16],
node: 1,
uv: 1,
u_mask_shift: 0,
}],
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: None,
};
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
// A minimal but parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys)
// so resolution proceeds to the path-try logic and fails for lack of a
// VID — not because the .inf failed to parse.
let mut uk_ro = vec![0u8; 40];
uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); // uk_pos = 32
// num_unit_keys = 0 (BE16) at uk_pos -> parses to an empty key file.
let ctx = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0u8; 16], // the "no VID" sentinel
providers: &provider_refs,
mkb: None,
};
assert_eq!(
crate::aacs::resolve::resolve_keys_with_reason(&ctx, 2).err(),
Some(crate::aacs::resolve::ResolveFailure::VidUnavailable),
"device keys + zero VID must classify as VidUnavailable"
);
let mut disc_e7017 = make_test_disc(1000, "UHD");
disc_e7017.encrypted = true;
disc_e7017.aacs = Some(aacs_with(Vec::new())); // present but no unit keys
disc_e7017.aacs_error = Some(crate::error::Error::AacsVidUnavailable);
let err = disc_e7017
.ensure_decryptable(false)
.expect_err("AACS disc, material-but-no-VID, !raw must error");
assert_eq!(
err.code(),
crate::error::Error::AacsVidUnavailable.code(),
"material-but-no-VID must surface E7017 (AacsVidUnavailable), not E7022"
);
// Branch 2 — no key material at all: classified NoMaterial, gate E7022.
let supplied_none = crate::aacs::provider::SuppliedKey {
device_keys: Vec::new(),
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: None,
};
let provider_refs_none: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied_none];
let ctx_none = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0u8; 16],
providers: &provider_refs_none,
mkb: None,
};
assert_eq!(
crate::aacs::resolve::resolve_keys_with_reason(&ctx_none, 2).err(),
Some(crate::aacs::resolve::ResolveFailure::NoMaterial),
"no key material must classify as NoMaterial"
);
let mut disc_e7022 = make_test_disc(1000, "UHD");
disc_e7022.encrypted = true;
disc_e7022.aacs = Some(aacs_with(Vec::new()));
disc_e7022.aacs_error = None; // no reason captured → generic no-key
let err = disc_e7022
.ensure_decryptable(false)
.expect_err("AACS disc, no material, !raw must error");
assert_eq!(
err.code(),
crate::error::Error::NoDiscKey {
disc_hash: String::new()
}
.code(),
"no-material must keep E7022 (NoDiscKey)"
);
}
/// Same AACS-no-key disc under `--raw` (raw=true) must PROCEED — the user
/// asked for the encrypted image and needs no key.
#[test]
fn ensure_decryptable_aacs_no_key_raw_proceeds() {
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new()));
assert!(disc.ensure_decryptable(true).is_ok(), "--raw must proceed");
}
/// AACS disc WITH a resolved unit key → proceed (decrypt_keys is Aacs).
#[test]
fn ensure_decryptable_aacs_with_key_proceeds() {
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(vec![(0, [0x11u8; 16])]));
assert!(disc.ensure_decryptable(false).is_ok());
}
/// A genuinely unencrypted disc has `None` keys legitimately — the gate must
/// NOT false-error. This is the "is the source encrypted?" guard: the answer
/// is the scan-captured disc state, not the keys.
#[test]
fn ensure_decryptable_unencrypted_proceeds() {
let disc = make_test_disc(1000, "BD"); // aacs/css/css_error all None
assert!(matches!(
disc.decrypt_keys(),
crate::decrypt::DecryptKeys::None
));
assert!(
disc.ensure_decryptable(false).is_ok(),
"unencrypted disc with None keys must proceed, not false-error"
);
}
/// CSS scrambled-but-uncracked (the keyless crack failed): `css` is None but
/// `css_error` is Some — the disc IS encrypted. The gate must fail with
/// CssKeyMissing rather than read `css.is_none()` as "unencrypted".
#[test]
fn ensure_decryptable_css_error_errors() {
let mut disc = make_test_disc(1000, "DVD");
disc.encrypted = true;
disc.css_error = Some(crate::error::Error::CssKeyMissing);
let err = disc
.ensure_decryptable(false)
.expect_err("scrambled-but-uncracked CSS must error");
assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
// --raw is exempt.
assert!(disc.ensure_decryptable(true).is_ok());
}
/// CSS-keyless-crack SUCCESS: `css` is Some with a title key → proceed.
#[test]
fn ensure_decryptable_css_with_key_proceeds() {
let mut disc = make_test_disc(1000, "DVD");
disc.encrypted = true;
disc.css = Some(css_state());
assert!(disc.ensure_decryptable(false).is_ok());
}
/// Per-title gate: a multi-VTS CSS disc whose chosen title's VTS could not
/// be re-cracked yields `DecryptKeys::None` even though the disc-wide
/// `decrypt_keys()` is `Css{..}`. `ensure_decryptable_keys` judges the
/// per-title key and must fail with CssKeyMissing.
#[test]
fn ensure_decryptable_keys_css_per_title_none_errors() {
let mut disc = make_test_disc(1000, "DVD");
disc.encrypted = true;
disc.css = Some(css_state());
let err = disc
.ensure_decryptable_keys(false, &crate::decrypt::DecryptKeys::None)
.expect_err("CSS disc, per-title key None, !raw must error");
assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
// The same None key under --raw proceeds.
assert!(
disc.ensure_decryptable_keys(true, &crate::decrypt::DecryptKeys::None)
.is_ok()
);
}
/// `ensure_decryptable_keys` must never false-error an UNENCRYPTED disc no
/// matter the key argument (the verdict keys off disc state, not keys).
#[test]
fn ensure_decryptable_keys_unencrypted_never_errors() {
let disc = make_test_disc(1000, "BD");
assert!(
disc.ensure_decryptable_keys(false, &crate::decrypt::DecryptKeys::None)
.is_ok()
);
}
// ── Fix 2/3: a genuinely-clear extra title on a CSS disc never E7023s ──────
/// Reader that serves clear (unscrambled) sectors for one extent range and
/// CSS-locked errors elsewhere — enough to drive `decrypt_keys_for_title_
/// checked`'s per-title re-crack to `Unencrypted` for a clear stub.
struct ClearStubReader {
clear_range: (u32, u32),
}
impl crate::sector::SectorSource for ClearStubReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let n = count as usize * 2048;
buf[..n].fill(0); // clear sectors: scramble flag never set
let _ = self.clear_range;
Ok(n)
}
fn capacity_sectors(&self) -> u32 {
self.clear_range.1
}
}
/// Build a multi-VTS CSS disc: `css` cracked from the main feature's span
/// `[main_lba, main_end)`, plus a clear stub title living in a DISJOINT VTS.
fn css_disc_with_clear_stub() -> (Disc, usize) {
let mut disc = make_test_disc(100_000, "DVD");
disc.encrypted = true;
disc.css = Some(crate::css::CssState {
title_key: [0u8; 5],
crack_span: Some((0, 1000)), // main feature VTS span
});
// Title 0: the main feature, overlaps the cracked span.
let mut feature = title_with_video(Codec::Mpeg2, Resolution::R480i);
feature.extents = vec![Extent {
start_lba: 0,
sector_count: 1000,
}];
// Title 1: a tiny CLEAR stub in its own VTS, disjoint from the span.
let mut stub = title_with_video(Codec::Mpeg2, Resolution::R480i);
stub.extents = vec![Extent {
start_lba: 50_000,
sector_count: 7, // a 7-sector menu stub
}];
disc.titles = vec![feature, stub];
(disc, 1) // stub is title index 1
}
/// THE Fix 2/3 regression: on a multi-VTS CSS DVD, a genuinely-clear extra
/// title (an unencrypted menu stub in its own VTS) must resolve to
/// `title_is_clear = true` with `None` keys, and `ensure_title_decryptable`
/// must PASS it — no false E7023. The old `decrypt_keys_for_title` +
/// `ensure_decryptable_keys` pair raised CssKeyMissing here because the
/// re-crack of the clear stub returned `None`, indistinguishable from a
/// scrambled-uncracked title.
#[test]
fn clear_stub_title_on_css_disc_is_not_a_key_failure() {
let (disc, stub_idx) = css_disc_with_clear_stub();
let mut reader = ClearStubReader {
clear_range: (0, 100_000),
};
let (keys, title_is_clear) = disc.decrypt_keys_for_title_checked(stub_idx, &mut reader, 8);
assert!(
!keys.is_encrypted(),
"a clear stub needs no key (got encrypted keys)"
);
assert!(
title_is_clear,
"the stub's own extents show no scrambling → title_is_clear must be true"
);
// The gate must PASS the clear stub — NO false E7023.
assert!(
disc.ensure_title_decryptable(false, &keys, title_is_clear)
.is_ok(),
"a genuinely clear extra title must never raise E7023"
);
}
/// Counterpart guard: a scrambled-but-uncrackable title (`title_is_clear ==
/// false`, `None` keys) on a CSS disc must STILL hard-fail with CssKeyMissing.
/// Fix 2/3 must not weaken the genuine encrypted-but-uncrackable case.
#[test]
fn scrambled_uncracked_title_still_hard_fails() {
let (disc, _) = css_disc_with_clear_stub();
let err = disc
.ensure_title_decryptable(false, &crate::decrypt::DecryptKeys::None, false)
.expect_err("scrambled-uncracked title (title_is_clear=false) must error");
assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
// --raw is exempt even for a scrambled-uncracked title.
assert!(
disc.ensure_title_decryptable(true, &crate::decrypt::DecryptKeys::None, false)
.is_ok()
);
}
#[test]
fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() {
// VID-only state (resolved but no Unit Key yet) must read as None, not
// an empty-but-usable key set — callers treat it as "keys missing".
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new()));
assert!(matches!(
disc.decrypt_keys(),
crate::decrypt::DecryptKeys::None
));
}
#[test]
fn decrypt_with_replaces_existing_aacs_unit_keys_and_marks_external() {
// When scan DID build an AACS state, decrypt_with must overwrite its
// unit keys (not append) and mark the source ExternalUk.
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(vec![(0, [0x01; 16])]));
let new = vec![(0u32, [0x77u8; 16]), (1, [0x88; 16])];
disc.decrypt_with(Key::Unit(new.clone()), &[]).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(unit_keys, new, "must replace, preserving every CPS unit");
}
_ => panic!("expected Aacs decrypt keys"),
}
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeyOrigin::ExternalUk
);
}
/// Build a minimal valid `Unit_Key_RO.inf` carrying the given encrypted
/// unit keys at the V20 (64-byte) stride. Header is inert (no titles); only
/// the key-storage area matters for `parse_unit_key_ro`.
fn uk_ro_v20(enc_keys: &[[u8; 16]]) -> Vec<u8> {
let uk_pos = 32usize;
let keys_start = uk_pos + 48;
let stride = 64usize;
let mut data = vec![0u8; keys_start + enc_keys.len().max(1) * stride];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[uk_pos..uk_pos + 2].copy_from_slice(&(enc_keys.len() as u16).to_be_bytes());
for (i, k) in enc_keys.iter().enumerate() {
let off = keys_start + i * stride;
data[off..off + 16].copy_from_slice(k);
}
data
}
#[test]
fn decrypt_with_volume_derives_per_cps_unit_keys() {
// A Volume key (VUK) is NOT terminal — the lib must decrypt
// Unit_Key_RO.inf into ONE unit key per CPS unit. Oracle = the lib's
// own decrypt_unit_key, so this pins the derive-down WIRING (Volume →
// per-CPS Unit), not the cipher.
let vuk = [0x5au8; 16];
let enc0 = [0x12u8; 16];
let enc1 = [0x34u8; 16];
let exp0 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc0);
let exp1 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc1);
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let mut a = aacs_with(Vec::new());
a.uk_ro = uk_ro_v20(&[enc0, enc1]);
disc.aacs = Some(a);
disc.decrypt_with(Key::Volume(vuk), &[]).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(
unit_keys,
vec![(1u32, exp0), (2u32, exp1)],
"VUK must decrypt EACH CPS unit's encrypted key (does not stop at VK)"
);
}
_ => panic!("expected Aacs decrypt keys after Volume-key derive-down"),
}
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeyOrigin::ExternalUk
);
}
#[test]
fn decrypt_with_higher_key_without_inputs_errors() {
// A non-Unit key needs the AACS inputs (Unit_Key_RO.inf) stashed at
// scan. Without them the lib cannot derive — surfaces AacsNoKeys, not a
// panic and not a silent keyless "success".
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new())); // uk_ro empty
assert!(matches!(
disc.decrypt_with(Key::Volume([0x11u8; 16]), &[])
.unwrap_err(),
crate::error::Error::AacsNoKeys
));
// No AACS state at all → same.
let mut disc2 = make_test_disc(1000, "UHD");
disc2.encrypted = true;
assert!(matches!(
disc2
.decrypt_with(Key::Media(vec![[0x22u8; 16]]), &[])
.unwrap_err(),
crate::error::Error::AacsNoKeys
));
}
#[test]
fn decrypt_with_volume_yielding_no_units_is_rejected() {
// A key that produces zero unit keys (here: an empty key-storage area)
// is a rejection, not a silent empty success.
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let mut a = aacs_with(Vec::new());
a.uk_ro = uk_ro_v20(&[]); // num_uk = 0
disc.aacs = Some(a);
assert!(matches!(
disc.decrypt_with(Key::Volume([0x11u8; 16]), &[])
.unwrap_err(),
crate::error::Error::AacsKeyRejected
));
}
#[test]
fn decrypt_with_unit_key_yields_decrypt_keys() {
// The public lookup-free entry point: hand libfreemkv a Key::Unit and
// decrypt_keys() must return usable AACS state (same path as the
// deferred-mux resume — autorip resolves the UK and passes it in).
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let uk = vec![(0u32, [0x44u8; 16])];
disc.decrypt_with(Key::Unit(uk.clone()), &[]).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(unit_keys, uk);
}
_ => panic!("expected Aacs decrypt keys after decrypt_with(Key::Unit)"),
}
}
#[test]
fn unit_key_validation_gates_on_real_ciphertext() {
use crate::aacs::content::ALIGNED_UNIT_LEN;
// No samples -> nothing to disprove against -> accept (sample-less paths
// like resume / mapfile must be unaffected).
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[],
ContentFormat::BdTs
));
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
// accept even with an arbitrary key.
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = 0x47;
off += 192;
}
assert!(crate::aacs::content::is_clean(
&clear,
crate::disc::ContentFormat::BdTs
));
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[clear.clone()],
ContentFormat::BdTs
));
// A genuinely scrambled unit the RIGHT key restores to clear TS.
let uk = [0x5au8; 16];
let enc = encrypt_unit_for_test(&clear, &uk);
assert!(
!crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs),
"encrypted unit must read scrambled"
);
// Right key -> de-scrambles -> accept (NO false reject of a good key).
assert!(super::aligned_unit_keys_validate(
&[(7, uk)],
None,
&[enc.clone()],
ContentFormat::BdTs
));
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(
&[(7, [0x00u8; 16])],
None,
&[enc.clone()],
ContentFormat::BdTs
));
// Empty key set against a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(
&[],
None,
&[enc],
ContentFormat::BdTs
));
}
#[test]
fn unit_key_validation_rejects_partial_cps_unit_coverage() {
// Regression: a multi-CPS-unit disc. CPS unit 0's body is scrambled
// under uk0; CPS unit 1's body under uk1. A resolved key set that
// covers only CPS unit 0 used to pass validation (the old gate accepted
// on the FIRST sample any key decrypted), committing an incomplete set —
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
// ISO/MKV with no error surfaced. The gate must now reject a key set
// that leaves any scrambled sample uncovered.
use crate::aacs::content::ALIGNED_UNIT_LEN;
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = 0x47;
off += 192;
}
let uk0 = [0x11u8; 16];
let uk1 = [0x22u8; 16];
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
assert!(!crate::aacs::content::is_clean(
&sample0,
crate::disc::ContentFormat::BdTs
));
assert!(!crate::aacs::content::is_clean(
&sample1,
crate::disc::ContentFormat::BdTs
));
let samples = vec![sample0.clone(), sample1.clone()];
// Partial key set (CPS unit 0 only) against samples from BOTH units ->
// reject. This is the bug fix: previously this returned true.
assert!(!super::aligned_unit_keys_validate(
&[(0, uk0)],
None,
&samples,
ContentFormat::BdTs
));
// Complete key set (both CPS units) -> accept.
assert!(super::aligned_unit_keys_validate(
&[(0, uk0), (1, uk1)],
None,
&samples,
ContentFormat::BdTs
));
// Order-independent: covering key present anywhere in the set is fine.
assert!(super::aligned_unit_keys_validate(
&[(1, uk1), (0, uk0)],
None,
&samples,
ContentFormat::BdTs
));
}
/// Inverse of `decrypt_unit` for one 6144-byte unit: produce on-disc
/// ciphertext that `decrypt_unit(uk)` restores to `clear`. Mirrors the AACS
/// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the
/// body with the fixed AACS IV.
fn encrypt_unit_for_test(clear: &[u8], uk: &[u8; 16]) -> Vec<u8> {
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::crypto::AACS_IV;
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
// Flag the unit encrypted (CPI bits on byte 0) before key derivation so
// the recovered plaintext header matches and `decrypt_unit`'s CPI gate
// attempts the decrypt.
unit[0] |= 0xC0;
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let cipher = Aes128::new(GenericArray::from_slice(uk));
let mut blk = GenericArray::clone_from_slice(&header);
cipher.encrypt_block(&mut blk);
let mut dk = [0u8; 16];
for i in 0..16 {
dk[i] = blk[i] ^ header[i];
}
let bc = Aes128::new(GenericArray::from_slice(&dk));
let mut prev = AACS_IV;
let mut i = 16;
while i + 16 <= ALIGNED_UNIT_LEN {
let mut b = [0u8; 16];
for j in 0..16 {
b[j] = unit[i + j] ^ prev[j];
}
let mut g = GenericArray::clone_from_slice(&b);
bc.encrypt_block(&mut g);
for j in 0..16 {
unit[i + j] = g[j];
}
prev.copy_from_slice(&unit[i..i + 16]);
i += 16;
}
unit
}
#[test]
fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() {
// Unencrypted disc: nothing to inject into, stays None.
let mut plain = make_test_disc(1000, "PLAIN");
plain.inject_unit_keys(vec![(0, [0x22; 16])]);
assert!(plain.aacs.is_none());
assert!(matches!(
plain.decrypt_keys(),
crate::decrypt::DecryptKeys::None
));
// Encrypted CSS (DVD): an AACS UK must NOT synthesize an AACS state.
let mut dvd = make_test_disc(1000, "DVD");
dvd.format = DiscFormat::Dvd;
dvd.encrypted = true;
dvd.css = Some(crate::css::CssState {
title_key: [0u8; 5],
crack_span: None,
});
dvd.inject_unit_keys(vec![(0, [0x33; 16])]);
assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state");
}
/// Records the LBAs read; returns all-zero (unscrambled) sectors so any
/// re-crack attempt finds no key and falls back, while we observe WHETHER
/// the title's extents were read at all.
struct RecordingSource {
reads: std::cell::RefCell<Vec<u32>>,
}
impl SectorSource for RecordingSource {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.reads.borrow_mut().push(lba);
let n = (count as usize * 2048).min(buf.len());
for b in buf[..n].iter_mut() {
*b = 0;
}
Ok(n)
}
}
fn css_disc_with_two_vts() -> Disc {
// Title 0 (cracked VTS) at LBA 100..200; title 1 (other VTS) at
// 5000..5100. The cracked key's span is title 0's extents.
let mut t0 = title_with_video(Codec::Mpeg2, Resolution::R480p);
t0.extents = vec![Extent {
start_lba: 100,
sector_count: 100,
}];
let mut t1 = title_with_video(Codec::Mpeg2, Resolution::R480p);
t1.playlist = "00801.mpls".into();
t1.extents = vec![Extent {
start_lba: 5000,
sector_count: 100,
}];
let mut disc = make_test_disc(6000, "DVD");
disc.format = DiscFormat::Dvd;
disc.content_format = ContentFormat::MpegPs;
disc.encrypted = true;
disc.titles = vec![t0, t1];
disc.css = Some(crate::css::CssState {
title_key: [0xAB; 5],
crack_span: Some((100, 200)),
});
disc
}
/// Regression (multi-VTS CSS): a title that OVERLAPS the cracked span is
/// the same VTS — the existing key is reused and the reader is NOT touched.
#[test]
fn decrypt_keys_for_title_reuses_key_for_same_vts() {
let disc = css_disc_with_two_vts();
let mut src = RecordingSource {
reads: std::cell::RefCell::new(Vec::new()),
};
match disc.decrypt_keys_for_title(0, &mut src, 16) {
crate::decrypt::DecryptKeys::Css { title_key } => {
assert_eq!(title_key, [0xAB; 5], "same-VTS title reuses cracked key");
}
_ => panic!("expected Css keys for same-VTS title"),
}
assert!(
src.reads.borrow().is_empty(),
"an overlapping title must not trigger a re-crack read"
);
}
/// Regression (multi-VTS CSS): a title in a DIFFERENT VTS (no overlap with
/// the cracked span) must re-crack from its OWN extents — verified by the
/// reader being driven over that title's LBA range (5000..). The fixture
/// yields unscrambled sectors so the re-crack finds NO key; the fix
/// requires this to be a HARD failure (`DecryptKeys::None`), NOT a silent
/// fall-back to the known-wrong-VTS disc-wide key (which would descramble
/// to garbage). Both the read-attempt and the None result are asserted.
#[test]
fn decrypt_keys_for_title_recracks_for_other_vts() {
let disc = css_disc_with_two_vts();
let mut src = RecordingSource {
reads: std::cell::RefCell::new(Vec::new()),
};
let keys = disc.decrypt_keys_for_title(1, &mut src, 16);
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"a re-crack miss in a provably-different VTS must be a hard failure (None), \
not the wrong-VTS disc-wide key"
);
let reads = src.reads.borrow();
assert!(
!reads.is_empty(),
"a non-overlapping title must trigger a re-crack read"
);
assert!(
reads.iter().all(|&lba| lba >= 5000),
"re-crack must read title 1's own extents (>=5000), got {reads:?}"
);
}
#[test]
fn sweep_to_dev_null_no_enodev() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 1000;
let bad: std::collections::HashSet<u32> = [500u32, 501, 502].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad,
};
let disc = make_test_disc(sectors, "T1");
let opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(
result.is_ok(),
"sweep to regular file should succeed: {:?}",
result.err()
);
}
/// disc→ISO correctness gate (the headline bug, at the copy entry point):
/// a DECRYPTING copy (`decrypt: true`, i.e. not --raw) of an AACS disc with
/// no resolved key must ERROR before reading any sector — never write
/// ciphertext to the ISO and return Ok. Asserts the error code is NoDiscKey
/// AND that no non-empty ISO was produced.
#[test]
fn copy_decrypting_aacs_no_key_errors_and_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("garbage.iso");
let sectors: u32 = 999; // 3-aligned for AACS units
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let mut disc = make_test_disc(sectors, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new())); // encrypted, no unit key → None
let opts = CopyOptions {
decrypt: true, // NOT --raw → decryption is required
multipass: false,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let err = disc
.copy(&mut reader, &iso_path, &opts)
.expect_err("decrypting copy of AACS-no-key disc must error pre-flight");
assert_eq!(
err.code(),
crate::error::Error::NoDiscKey {
disc_hash: String::new()
}
.code(),
"must surface NoDiscKey, not silently write ciphertext"
);
// No partial/garbage ISO: the gate fired before the sweep opened/sized
// the file, so either the file doesn't exist or it's empty.
let produced = std::fs::metadata(&iso_path).map(|m| m.len()).unwrap_or(0);
assert_eq!(produced, 0, "no ciphertext ISO may be written");
}
/// The same disc under `--raw` (`decrypt: false`) must PROCEED: the gate is
/// a no-op for raw, the sweep runs as a pass-through and writes the
/// encrypted image the user asked for. Proves the gate doesn't over-fire.
#[test]
fn copy_raw_aacs_no_key_proceeds() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("raw.iso");
let sectors: u32 = 999;
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let mut disc = make_test_disc(sectors, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new()));
let opts = CopyOptions {
decrypt: false, // --raw: no decryption, no key needed
multipass: false,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
assert!(
disc.copy(&mut reader, &iso_path, &opts).is_ok(),
"--raw copy of an encrypted disc must proceed (encrypted image is the goal)"
);
}
#[test]
fn sweep_to_dev_null_real() {
let sectors: u32 = 1000;
let bad: std::collections::HashSet<u32> = [500u32, 501, 502].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad,
};
let disc = make_test_disc(sectors, "T2");
let _cleanup = CleanupGuard(disc.mapfile_for(std::path::Path::new("/dev/null")));
let opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!(
result.is_ok(),
"sweep to /dev/null should not fail with ENODEV: {:?}",
result.err()
);
}
/// End-to-end Pass-1 sweep against a synthetic `MockReader` with an injected
/// bad-sector region, asserting the RESULTING MAPFILE — the thing the sweep
/// loop and damage-jump exist to produce. Drives the real `Disc::sweep` (no
/// live drive, per the project's "synthetic fixtures only" rule) and checks:
/// * the leading good region is marked Finished,
/// * the bad region (and the skip-ahead gap the damage-jump zero-fills) is
/// marked NonTrimmed,
/// * the damage-jump actually engaged — the NonTrimmed span is far larger
/// than the single failed ECC batch, which only happens if Pass-1 jumped
/// ahead (JUMP_BASE_SECTORS×batch) and zero-filled the gap as NonTrimmed,
/// * the mapfile covers the whole disc with no overlap, and good+retryable
/// accounting matches.
///
/// Note: this exercises the real cooldown/pause pacing, so it spends a few
/// seconds of wall time on the single zone-entry pause (same cost the
/// existing `sweep_to_dev_null_real` already pays) — but unlike that test it
/// asserts the actual recovery bookkeeping, not just `is_ok()`.
#[test]
fn sweep_marks_bad_region_nontrimmed_and_engages_damage_jump() {
use crate::disc::mapfile::{Mapfile, SectorStatus};
let sectors: u32 = 1000;
// One bad sector at LBA 320 fails the entire ECC batch [320,352).
// batch=32 for UHD, so [0,320) = 10 clean batches before the failure.
let bad: std::collections::HashSet<u32> = [320u32].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad,
};
let disc = make_test_disc(sectors, "DJ");
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("dj.iso");
let opts = SweepOptions {
decrypt: false,
resume: false,
batch_sectors: None, // → ecc batch (32) for UHD
skip_on_error: true, // multipass → damage-jump engaged
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
disc.sweep(&mut reader, &iso_path, &opts).expect("sweep");
let mf = Mapfile::load(&disc.mapfile_for(&iso_path)).expect("load mapfile");
let good = mf.ranges_with(&[SectorStatus::Finished]);
let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]);
const SEC: u64 = crate::consts::SECTOR_BYTES_U64;
let disc_bytes = sectors as u64 * SEC;
// The first failing batch starts at LBA 320; everything before it read
// cleanly and must be Finished.
let good_bytes: u64 = good.iter().map(|(_, sz)| sz).sum();
assert!(
good_bytes > 0,
"leading clean region must be marked Finished"
);
assert!(
good.iter().all(|(pos, sz)| pos + sz <= 320 * SEC),
"all Finished bytes must lie before the bad batch at LBA 320; got {good:?}"
);
// The clean lead is the 10 batches [0,320) = 320 sectors.
assert_eq!(
good_bytes,
320 * SEC,
"exactly the 320 clean sectors before the failure are Finished"
);
// The bad region must be NonTrimmed and must START at the failed batch.
assert!(
!bad_ranges.is_empty(),
"the failed batch must produce a NonTrimmed range"
);
let bad_bytes: u64 = bad_ranges.iter().map(|(_, sz)| sz).sum();
let (first_bad_pos, _) = bad_ranges[0];
assert_eq!(
first_bad_pos,
320 * SEC,
"NonTrimmed must begin at the failed ECC batch (LBA 320)"
);
// Damage-jump proof: a single ECC batch is 32 sectors. If only the failed
// batch were marked, NonTrimmed would be ~32 sectors. The fast-jump
// (JUMP_BASE_SECTORS=1024 × batch=32) overshoots this 1000-sector disc, so
// the entire tail from the failure to EOF is zero-filled NonTrimmed — far
// more than one batch. That can ONLY happen if the jump engaged.
assert!(
bad_bytes > 32 * SEC,
"NonTrimmed span ({} sectors) must exceed a single ECC batch — proves \
the damage-jump skipped ahead and zero-filled the gap",
bad_bytes / SEC
);
// Specifically: the jump overshoots EOF, so the whole tail [320,1000) is
// NonTrimmed.
assert_eq!(
bad_bytes,
(sectors as u64 - 320) * SEC,
"the damage-jump overshoots EOF → the entire tail is NonTrimmed"
);
// Whole-disc coverage with no gaps/overlap: Finished + NonTrimmed = disc.
assert_eq!(
good_bytes + bad_bytes,
disc_bytes,
"Finished + NonTrimmed must cover the whole disc exactly"
);
// Stats agree with the range view.
let stats = mf.stats();
assert_eq!(stats.bytes_good, good_bytes, "stats.bytes_good vs ranges");
assert_eq!(
stats.bytes_retryable, bad_bytes,
"NonTrimmed counts as retryable in stats"
);
assert!(
stats.bytes_unreadable == 0,
"Pass-1 never promotes to Unreadable (that's a later pass's job)"
);
}
/// Regression (finding 6): sweep() resume against a mapfile whose
/// total_size != the real disc size must DOWNGRADE to a fresh full sweep
/// covering [0, capacity), not reuse the stale mapfile (which would
/// abandon the disc tail or read past capacity). Mirrors copy()'s
/// covers_disc reconciliation for the direct-sweep entry point.
#[test]
fn sweep_resume_downgrades_on_size_mismatch() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("mismatch.iso");
// First sweep: a small disc → mapfile sized to small_sectors.
let small_sectors: u32 = 500;
let mut small_reader = MockReader {
total_sectors: small_sectors,
bad_sectors: std::collections::HashSet::new(),
};
let small_disc = make_test_disc(small_sectors, "SMALL");
let opts0 = SweepOptions {
decrypt: false,
resume: false,
batch_sectors: None,
skip_on_error: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
small_disc
.sweep(&mut small_reader, &iso_path, &opts0)
.expect("initial small sweep");
let mf = small_disc.mapfile_for(&iso_path);
assert_eq!(
mapfile::Mapfile::load(&mf).unwrap().total_size(),
small_sectors as u64 * 2048,
"precondition: mapfile reflects the small disc"
);
// Now a LARGER disc resumes against that stale (under-cover) mapfile.
// The reconciliation must force a fresh full sweep of the big disc.
let big_sectors: u32 = 2000;
let mut big_reader = MockReader {
total_sectors: big_sectors,
bad_sectors: std::collections::HashSet::new(),
};
let big_disc = make_test_disc(big_sectors, "BIG");
let opts_resume = SweepOptions {
resume: true,
..opts0
};
let result = big_disc
.sweep(&mut big_reader, &iso_path, &opts_resume)
.expect("resume sweep on mismatched mapfile");
assert_eq!(
result.bytes_total,
big_sectors as u64 * 2048,
"fresh sweep must be sized to the real (big) disc"
);
assert_eq!(
result.bytes_good,
big_sectors as u64 * 2048,
"the whole big disc (incl. the tail beyond the stale mapfile) must be swept"
);
assert_eq!(
mapfile::Mapfile::load(&mf).unwrap().total_size(),
big_sectors as u64 * 2048,
"mapfile must be re-created at the real disc size, not the stale one"
);
}
/// Regression (resume/mapfile consistency, MED): a resume sweep against a
/// mapfile that claims prior progress (Finished ranges) while the ISO is
/// missing/zero-length must DOWNGRADE to a fresh full sweep — NOT reuse the
/// stale mapfile. The producer only builds work from NonTried ranges, so a
/// reused mapfile would leave every Finished range unread and ZERO in the
/// new ISO (a silent hole). Reachable via autorip ResumeMode::Require when
/// the ISO was deleted/truncated but the mapfile survived. The fresh-sweep
/// downgrade self-heals: all ranges are re-read and the ISO is fully
/// populated.
#[test]
fn sweep_resume_downgrades_on_zero_iso_with_progress_mapfile() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("zeroed.iso");
let sectors: u32 = 500;
let total_bytes = sectors as u64 * 2048;
let disc = make_test_disc(sectors, "ZEROED");
// First sweep: clean disc → ISO fully written, mapfile all-Finished.
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let opts0 = SweepOptions {
decrypt: false,
resume: false,
batch_sectors: None,
skip_on_error: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
disc.sweep(&mut reader, &iso_path, &opts0)
.expect("initial clean sweep");
let mf = disc.mapfile_for(&iso_path);
let loaded = mapfile::Mapfile::load(&mf).unwrap();
assert_eq!(
loaded.stats().bytes_pending,
0,
"precondition: a clean sweep leaves no pending (all Finished) ranges"
);
// Truncate the ISO to zero length while the progress-claiming mapfile
// survives — exactly the inconsistent-resume case.
std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&iso_path)
.expect("truncate ISO to zero");
assert_eq!(
std::fs::metadata(&iso_path).unwrap().len(),
0,
"precondition: ISO is zero-length"
);
// Resume sweep: must downgrade to a fresh FULL sweep, re-reading every
// range (including the formerly-Finished ones).
let mut reader2 = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let opts_resume = SweepOptions {
resume: true,
..opts0
};
let result = disc
.sweep(&mut reader2, &iso_path, &opts_resume)
.expect("resume sweep on zero-length ISO");
// A holed resume would re-read nothing (no NonTried ranges) → bytes_good
// == 0 and a zero ISO. The downgrade re-reads the whole disc.
assert_eq!(
result.bytes_good, total_bytes,
"downgrade must re-read the whole disc, not skip Finished ranges"
);
assert_eq!(
std::fs::metadata(&iso_path).unwrap().len(),
total_bytes,
"ISO must be re-sized + fully written, not left zero/holed"
);
// The ISO must actually contain the swept data (0xAA) at LBA 0 — proof
// the formerly-Finished head range was re-read, not left as a hole.
let iso = std::fs::read(&iso_path).unwrap();
assert_eq!(
&iso[..2048],
&[0xAAu8; 2048][..],
"head sector must hold re-read data, not a zero hole"
);
}
/// Regression (resume reconciliation, MED follow-on): a resume sweep against
/// a CORRUPT / unparseable mapfile must DOWNGRADE to a fresh full sweep —
/// not proceed with resume=true (which would hand a garbage/empty mapfile to
/// open_or_create and silently skip ranges). The `load()` Err arm sets
/// resume=false; the `!resume` path then drops the corrupt mapfile and the
/// rip restarts clean. Consistent with the total_size-mismatch downgrade.
#[test]
fn sweep_resume_downgrades_on_corrupt_mapfile() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("corrupt.iso");
let sectors: u32 = 500;
let total_bytes = sectors as u64 * 2048;
let disc = make_test_disc(sectors, "CORRUPT");
let mf = disc.mapfile_for(&iso_path);
// Write a non-empty ISO so the zero-length-ISO guard is NOT what triggers
// the downgrade — we want the corrupt-mapfile path specifically.
std::fs::write(&iso_path, vec![0u8; total_bytes as usize]).unwrap();
// Plant a corrupt mapfile: garbage bytes that Mapfile::load can't parse.
std::fs::write(&mf, b"this is not a valid ddrescue mapfile\nxxxx\n").unwrap();
assert!(
mapfile::Mapfile::load(&mf).is_err(),
"precondition: the planted mapfile must be unparseable"
);
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let opts = SweepOptions {
decrypt: false,
resume: true,
batch_sectors: None,
skip_on_error: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc
.sweep(&mut reader, &iso_path, &opts)
.expect("resume sweep on corrupt mapfile");
// The downgrade must re-sweep the whole disc from a fresh mapfile.
assert_eq!(
result.bytes_good, total_bytes,
"corrupt-mapfile resume must downgrade to a fresh full sweep"
);
let reloaded = mapfile::Mapfile::load(&mf)
.expect("a valid mapfile must have been written by the fresh sweep");
assert_eq!(
reloaded.total_size(),
total_bytes,
"mapfile must be re-created at the real disc size"
);
assert_eq!(
reloaded.stats().bytes_pending,
0,
"the fresh sweep must leave all ranges Finished"
);
}
/// Regression: a fresh (non-resume) sweep MUST abort if the stale mapfile
/// cannot be removed, rather than swallowing the error and letting
/// `open_or_create` load the stale file (which would make the new disc
/// inherit old Finished ranges → silently zero-filled ISO). We force the
/// remove to fail with a non-ENOENT error by placing a NON-EMPTY DIRECTORY
/// at the mapfile path (`remove_file` on a dir fails, and a non-empty dir
/// can't be ENOENT).
#[test]
fn sweep_fresh_aborts_when_stale_mapfile_unremovable() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("blocked.iso");
let sectors: u32 = 500;
let disc = make_test_disc(sectors, "BLOCKED");
let mf = disc.mapfile_for(&iso_path);
// Put a non-empty directory where the mapfile would live.
std::fs::create_dir_all(&mf).unwrap();
std::fs::write(mf.join("occupant"), b"x").unwrap();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let opts = SweepOptions {
decrypt: false,
resume: false,
batch_sectors: None,
skip_on_error: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc.sweep(&mut reader, &iso_path, &opts);
assert!(
result.is_err(),
"fresh sweep must abort when the stale mapfile cannot be removed"
);
}
struct CleanupGuard(std::path::PathBuf);
impl Drop for CleanupGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[test]
fn sweep_dev_null_full_good() {
let sectors: u32 = 2000;
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let disc = make_test_disc(sectors, "T3");
let _cleanup = CleanupGuard(disc.mapfile_for(std::path::Path::new("/dev/null")));
let opts = CopyOptions {
decrypt: false,
multipass: false,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!(
result.is_ok(),
"full-good sweep to /dev/null should succeed: {:?}",
result.err()
);
let r = result.unwrap();
assert!(r.complete, "should be complete");
assert_eq!(r.bytes_good, sectors as u64 * 2048);
}
/// Finding #6 regression: on resume, copy() must NOT abandon the un-swept
/// NonTried tail when retryable (NonTrimmed) bytes also remain. The mapfile
/// covers the disc and has BOTH a NonTrimmed (retryable) range and a
/// NonTried tail; dispatch must route to a resume sweep first so the tail is
/// actually read. Before the fix, `bytes_retryable > 0` short-circuited to
/// patch and the NonTried tail was silently left unread.
#[test]
fn resume_sweeps_nontried_tail_even_with_retryable_present() {
use crate::disc::mapfile::{Mapfile, SectorStatus};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
// Reader that records every LBA it is asked to read.
struct TrackingReader {
total_sectors: u32,
reads: Arc<Mutex<HashSet<u32>>>,
}
impl crate::sector::SectorSource for TrackingReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
{
let mut r = self.reads.lock().unwrap();
for i in 0..count as u32 {
r.insert(lba + i);
}
}
let n = count as usize * 2048;
buf[..n].fill(0xAA);
Ok(n)
}
fn capacity_sectors(&self) -> u32 {
self.total_sectors
}
}
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 200;
let disc = make_test_disc(sectors, "T6Tail");
// Pre-build a mapfile covering the whole disc:
// [0..100) Finished
// [100..150) NonTrimmed (retryable)
// [150..200) NonTried (un-swept tail)
let mf_path = disc.mapfile_for(&iso_path);
{
let mut mf = Mapfile::create(&mf_path, sectors as u64 * 2048, "test").unwrap();
mf.record(0, 100 * 2048, SectorStatus::Finished).unwrap();
mf.record(100 * 2048, 50 * 2048, SectorStatus::NonTrimmed)
.unwrap();
// [150..200) stays NonTried from create()'s initial region.
mf.flush().unwrap();
// Sanity on the constructed state.
let st = mf.stats();
assert!(st.bytes_nontried > 0, "must have a NonTried tail");
assert!(st.bytes_retryable > 0, "must have retryable bytes too");
assert_eq!(mf.total_size(), sectors as u64 * 2048);
}
// The ISO file must exist for the sweep to write into.
std::fs::write(&iso_path, vec![0u8; sectors as usize * 2048]).unwrap();
let reads = Arc::new(Mutex::new(HashSet::new()));
let mut reader = TrackingReader {
total_sectors: sectors,
reads: reads.clone(),
};
let opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(result.is_ok(), "resume copy failed: {:?}", result.err());
// The un-swept tail [150..200) MUST have been read by the resume sweep.
let got = reads.lock().unwrap();
let tail_read = (150u32..200).any(|lba| got.contains(&lba));
assert!(
tail_read,
"resume must sweep the NonTried tail; tail sectors were never read"
);
}
/// Regression (rc.6 user fix): a PLAIN (non-`--multipass`) `disc:// → iso://`
/// copy interrupted by Ctrl-C must RESUME from where it stopped when the
/// SAME command is re-issued — not restart from sector 0. The CLI help and
/// `rip_iso` examples promise "auto-resumes if interrupted". Before the fix
/// the whole mapfile-resume dispatch in `Disc::copy` was gated behind
/// `if opts.multipass`, so a plain copy always called
/// `sweep_internal(resume=false)`, which wiped the mapfile + ISO and swept
/// the disc again from LBA 0.
///
/// Simulate an interrupted plain sweep: a mapfile that covers the disc with
/// a Finished prefix [0..100) and a NonTried tail [100..200). A plain re-run
/// must read ONLY the tail (resume) and leave the prefix untouched.
#[test]
fn plain_copy_resumes_nontried_tail_after_interrupt() {
use crate::disc::mapfile::{Mapfile, SectorStatus};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
// Reader that records every LBA it is asked to read.
struct TrackingReader {
total_sectors: u32,
reads: Arc<Mutex<HashSet<u32>>>,
}
impl crate::sector::SectorSource for TrackingReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
{
let mut r = self.reads.lock().unwrap();
for i in 0..count as u32 {
r.insert(lba + i);
}
}
let n = count as usize * 2048;
buf[..n].fill(0xAA);
Ok(n)
}
fn capacity_sectors(&self) -> u32 {
self.total_sectors
}
}
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 200;
let disc = make_test_disc(sectors, "PlainResume");
// Pre-build a mapfile mimicking an interrupted plain sweep:
// [0..100) Finished (already written before Ctrl-C)
// [100..200) NonTried (un-swept tail)
let mf_path = disc.mapfile_for(&iso_path);
{
let mut mf = Mapfile::create(&mf_path, sectors as u64 * 2048, "test").unwrap();
mf.record(0, 100 * 2048, SectorStatus::Finished).unwrap();
// [100..200) stays NonTried from create()'s initial region.
mf.flush().unwrap();
let st = mf.stats();
assert!(st.bytes_nontried > 0, "must have a NonTried tail");
assert_eq!(st.bytes_retryable, 0, "plain interrupt leaves no retryable");
assert_eq!(mf.total_size(), sectors as u64 * 2048);
}
// The ISO file must already exist (it was being written before the
// interrupt) so the resume opens it rather than recreating it.
std::fs::write(&iso_path, vec![0u8; sectors as usize * 2048]).unwrap();
let reads = Arc::new(Mutex::new(HashSet::new()));
let mut reader = TrackingReader {
total_sectors: sectors,
reads: reads.clone(),
};
// PLAIN copy — multipass: false. This is the path the bug broke.
let opts = CopyOptions {
decrypt: false,
multipass: false,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(
result.is_ok(),
"plain resume copy failed: {:?}",
result.err()
);
let got = reads.lock().unwrap();
// The NonTried tail [100..200) MUST have been read by the resume sweep.
let tail_read = (100u32..200).any(|lba| got.contains(&lba));
assert!(
tail_read,
"plain copy must resume-sweep the NonTried tail; tail sectors were never read"
);
// The Finished prefix [0..100) must NOT be re-read — that would mean a
// restart-from-zero (the bug), not a resume.
let prefix_reread = (0u32..100).any(|lba| got.contains(&lba));
assert!(
!prefix_reread,
"plain copy must NOT re-read the already-Finished prefix (it restarted from sector 0)"
);
// The mapfile must now be fully Finished (disc fully swept on resume).
let reloaded = Mapfile::load(&mf_path).unwrap();
assert_eq!(
reloaded.stats().bytes_nontried,
0,
"resume sweep must clear the NonTried tail"
);
}
#[test]
fn patch_dev_null_after_sweep() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 500;
let bad: std::collections::HashSet<u32> = [100u32, 200, 300].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad.clone(),
};
let disc = make_test_disc(sectors, "T4");
let sweep_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
assert!(
sweep_result.is_ok(),
"sweep should succeed: {:?}",
sweep_result.err()
);
let mut reader2 = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let patch_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
assert!(
patch_result.is_ok(),
"patch should succeed: {:?}",
patch_result.err()
);
let pr = patch_result.unwrap();
assert!(
pr.complete,
"patch should complete: bytes_pending={}",
pr.bytes_pending
);
}
#[test]
fn patch_dev_null_direct() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 500;
let bad: std::collections::HashSet<u32> = [100u32, 200, 300].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad.clone(),
};
let disc = make_test_disc(sectors, "T5");
let sweep_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
let mut reader2 = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let patch_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
assert!(
patch_result.is_ok(),
"patch to /dev/null should succeed: {:?}",
patch_result.err()
);
}
/// Synthetic regression test for the 0.18 SweepSink + Pipeline
/// migration. ~100 batches of clean reads (6000 sectors at the
/// default 60-sector single-pass batch size); verifies all bytes
/// land in the ISO and the consumer's final stats match the input.
/// The throughput regression check (vs 0.17.13) is a separate
/// manual / live-drive concern; here we only assert correctness.
#[test]
fn sweep_pipeline_full_good_100_batches() {
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
// 6000 sectors / 60-sector default batch = exactly 100
// produce/consume cycles through the pipeline.
let sectors: u32 = 6000;
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let disc = make_test_disc(sectors, "TPipeline100");
let opts = CopyOptions {
decrypt: false,
multipass: false,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
let r = result.expect("100-batch clean sweep should succeed");
assert!(r.complete, "complete=true expected");
assert!(!r.halted, "halted=false expected");
assert_eq!(
r.bytes_good,
sectors as u64 * 2048,
"all sectors must be marked good after a 100% clean sweep"
);
assert_eq!(
r.bytes_pending, 0,
"no pending bytes expected after a clean sweep"
);
// The ISO file must end up the right size — the consumer
// wrote everything before fsync.
let meta = std::fs::metadata(&iso_path).unwrap();
assert_eq!(meta.len(), sectors as u64 * 2048);
}
/// bytes_bad_in_title must overlap per-extent, not against a single
/// bounding box: a bad range in the gap between two extents of the
/// same title must NOT be counted.
#[test]
fn bytes_bad_in_title_ignores_inter_extent_gap() {
let mut title = title_with_video(Codec::Hevc, Resolution::R2160p);
// Two extents: sectors [0,10) and [100,110). Gap = [10,100).
title.extents = vec![
Extent {
start_lba: 0,
sector_count: 10,
},
Extent {
start_lba: 100,
sector_count: 10,
},
];
// A bad range entirely inside the gap (sector 50 == byte 50*2048).
let gap = vec![(50 * 2048, 2048)];
assert_eq!(
bytes_bad_in_title(&title, &gap),
0,
"bad bytes in the inter-extent gap must not be counted"
);
// A bad range overlapping the first extent counts.
let in_first = vec![(0, 4096)];
assert_eq!(bytes_bad_in_title(&title, &in_first), 4096);
// A bad range spanning both extents plus the gap counts only the
// bytes that fall inside the two extents (10 + 10 sectors).
let spanning = vec![(0, 110 * 2048)];
assert_eq!(bytes_bad_in_title(&title, &spanning), 20 * 2048);
}
/// 0xA2 is secondary DTS-HD MA (lossless), not lossy HR.
#[test]
fn coding_type_a2_is_dts_hd_ma() {
assert_eq!(Codec::from_coding_type(0xA2), Codec::DtsHdMa);
assert_eq!(Codec::from_coding_type(0x86), Codec::DtsHdMa);
}
/// HDMV coding_type 0x90 = Presentation Graphics (PG / subtitles) → Pgs,
/// but 0x91 = Interactive Graphics (IG / menus) is NOT a subtitle stream.
/// It must NOT map to Pgs (whose kind() is Subtitle), else a menu ES would
/// surface as a bogus PGS subtitle track. 0x91 falls through to Unknown so
/// the PMT/STN walker drops it.
#[test]
fn coding_type_ig_0x91_is_not_pgs_subtitle() {
assert_eq!(Codec::from_coding_type(0x90), Codec::Pgs);
assert_eq!(Codec::from_coding_type(0x90).kind(), CodecKind::Subtitle);
// IG must not be a PGS subtitle.
assert_eq!(Codec::from_coding_type(0x91), Codec::Unknown(0x91));
assert_ne!(Codec::from_coding_type(0x91).kind(), CodecKind::Subtitle);
}
/// chapter_name emits a bare 1-based ordinal (no localized prose).
#[test]
fn chapter_name_is_bare_ordinal() {
assert_eq!(chapter_name(0), "1");
assert_eq!(chapter_name(41), "42");
}
// ── Regression tests for bisect inner-loop ReadAction dispatch ───────────
//
// Before the fix the bisect inner loop discarded the ReadAction returned by
// handle_read_error:
//
// let _ = read_error::handle_read_error(&inner_err, &mut read_ctx);
//
// Consequences:
// (a) Retry{pause_secs} — cooldown skipped; sector immediately marked
// BisectBad, hammering a degraded drive (violates Hard Rule #2).
// (b) AbortPass — ignored; loop kept issuing reads against a crashed drive.
//
// The fix replaces the discard with a match. The tests below prove the
// required ReadAction values are produced by handle_read_error in the
// bisect-inner context (bisecting=true, batch=1), so that any regression
// to `let _ = ...` would break real behaviour on the tested error paths.
/// NOT_READY inside a bisect must return Retry, not SkipBlock.
/// If the inner loop discarded the action the 3-second cooldown would be
/// skipped, hammering the drive during a transient NOT_READY condition.
#[test]
fn bisect_inner_not_ready_returns_retry_with_pause() {
use crate::disc::read_error::{ReadAction, ReadCtx, handle_read_error};
use crate::error::Error;
use crate::scsi::ScsiSense;
let not_ready_err = Error::DiscRead {
sector: 500,
status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION),
sense: Some(ScsiSense {
sense_key: crate::scsi::SENSE_KEY_NOT_READY,
asc: 0x04,
ascq: 0x00, // not 0x3E — generic NOT_READY, not bridge degradation
}),
};
let mut ctx = ReadCtx::for_patch(1);
ctx.bisecting = true; // simulate being inside the bisect inner loop
let action = handle_read_error(&not_ready_err, &mut ctx);
match action {
ReadAction::Retry { pause_secs } => {
assert!(
pause_secs > 0,
"NOT_READY retry must carry a non-zero pause; got {pause_secs}s"
);
}
other => panic!(
"bisect inner NOT_READY must return Retry{{pause_secs}}, got {other:?}; \
a discard (`let _ = ...`) would skip this pause and hammer the drive"
),
}
}
/// A transport failure inside a bisect must return AbortPass.
/// If the inner loop discarded the action the loop would continue
/// issuing reads against a crashed bridge, producing spurious BisectBad
/// entries and potentially looping until the batch is exhausted.
#[test]
fn bisect_inner_transport_failure_returns_abort_pass() {
use crate::disc::read_error::{ReadAction, ReadCtx, handle_read_error};
use crate::error::Error;
let transport_err = Error::DiscRead {
sector: 500,
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
sense: None,
};
let mut ctx = ReadCtx::for_patch(1);
ctx.bisecting = true;
let action = handle_read_error(&transport_err, &mut ctx);
assert_eq!(
action,
ReadAction::AbortPass,
"bisect inner transport failure must return AbortPass; \
a discard (`let _ = ...`) would silently keep looping against a crashed drive"
);
}
/// After enough consecutive wedge errors with bisecting=true the handler
/// must eventually return AbortPass. Before the fix, the inner loop
/// discarded the returned action and kept issuing reads against a permanently
/// wedged drive at full rate.
///
/// The threshold is 16 consecutive wedges (WEDGE_ABORT_THRESHOLD in
/// read_error.rs); we drive 20 iterations to give the assertion headroom
/// without hard-coding the internal constant here.
#[test]
fn bisect_inner_wedge_abort_threshold_reached_returns_abort_pass() {
use crate::disc::read_error::{ReadAction, ReadCtx, handle_read_error};
use crate::error::Error;
use crate::scsi::ScsiSense;
let hardware_err = || Error::DiscRead {
sector: 500,
status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION),
sense: Some(ScsiSense {
sense_key: crate::scsi::SENSE_KEY_HARDWARE_ERROR,
asc: 0x44,
ascq: 0x00,
}),
};
let mut ctx = ReadCtx::for_patch(1);
ctx.bisecting = true;
let mut aborted = false;
for _ in 0..20 {
let action = handle_read_error(&hardware_err(), &mut ctx);
if action == ReadAction::AbortPass {
aborted = true;
break;
}
}
assert!(
aborted,
"bisect inner wedge loop must reach AbortPass after consecutive hardware errors; \
a discard (`let _ = ...`) would loop forever on a bricked drive"
);
}
/// Regression: copy() dispatch with covers_disc=true, retryable=0, nontried>0 must
/// route to sweep_internal(resume=true) so the unread NonTried ranges are actually
/// read rather than silently abandoned.
///
/// Before the fix the fallthrough returned a terminal CopyResult immediately,
/// leaving the NonTried sectors unread.
#[test]
fn copy_dispatch_routes_to_sweep_when_nontried_gt_zero() {
use crate::disc::mapfile::{self, SectorStatus};
let tmp = tempfile::tempdir().unwrap();
let iso_path = tmp.path().join("test.iso");
let sectors: u32 = 200;
let disc = make_test_disc(sectors, "DispatchNonTried");
let disc_size = sectors as u64 * 2048;
// Synthesise a mapfile that covers the disc (total_size == disc_size) with:
// - [0, half_bytes): Finished
// - [half_bytes, disc_size): NonTried
// This gives covers_disc=true, bytes_retryable=0, bytes_nontried>0.
let mf_path = disc.mapfile_for(&iso_path);
let half_bytes = disc_size / 2;
{
let mut map =
mapfile::Mapfile::create(&mf_path, disc_size, "test").expect("create mapfile");
map.record(0, half_bytes, SectorStatus::Finished)
.expect("record Finished");
map.flush().expect("flush");
}
// Create an ISO file pre-sized to the full disc size so the resume
// sweep can open it and write the NonTried regions at their offsets.
// (len > 0 selects the resume-open branch; full pre-size avoids
// short-seek writes past EOF.)
{
let f = std::fs::File::create(&iso_path).expect("create iso");
f.set_len(disc_size).expect("pre-size iso");
}
// All sectors are readable in this reader.
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: std::collections::HashSet::new(),
};
let opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
key_fetch: None,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(
result.is_ok(),
"copy with nontried>0 should succeed: {:?}",
result.err()
);
let r = result.unwrap();
// The sweep must have read the NonTried half — bytes_good should be
// the whole disc, not just the already-Finished half.
assert_eq!(
r.bytes_good, disc_size,
"all sectors must be good after resume sweep reads the NonTried half \
(before fix: terminal returned with bytes_good={}, skipping {} NonTried bytes)",
half_bytes, half_bytes
);
}
}