Give `info` the same forced-subtitle verdict the muxer derives during a rip, so the two agree. A shared classifier (mux::codec::pgs::ForcedTracker) folds a PGS track's display sets — forced iff every one carries the forced_on_flag — and is used by BOTH the MKV writer and a new scan-time probe that reads the title's PGS streams (reusing the TS demuxer and PGS parser). The probe only overrides a track it actually observed content for, so an undecrypted/unread stream keeps its vendor-derived flag. Gated behind ScanOptions::probe_forced_subtitles (off for the rip path, which detects forced while muxing without a second read).
6847 lines
286 KiB
Rust
6847 lines
286 KiB
Rust
//! 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(crate) mod pgs_forced_probe;
|
||
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, PartialEq, Eq)]
|
||
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(crate) 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,
|
||
}
|
||
|
||
// No `KeyOrigin::name()`: the library holds ZERO user-facing English (CLAUDE.md).
|
||
// `KeyOrigin` is a typed enum; applications map its variants to display text
|
||
// (see freemkv's `disc_info::key_origin_label`).
|
||
|
||
// ─── 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>,
|
||
/// Read the PGS subtitle streams during the scan to detect forced-narrative
|
||
/// tracks from their content (the `forced_on_flag`), matching what the mux
|
||
/// derives during a rip. OFF by default — it reads the clip's PGS content,
|
||
/// which is slow, so only callers that want authoritative forced flags in the
|
||
/// scanned title (e.g. `freemkv info`) opt in. The rip path leaves it off:
|
||
/// the muxer detects forced during muxing without a second read.
|
||
pub probe_forced_subtitles: bool,
|
||
}
|
||
|
||
/// 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);
|
||
|
||
// Optional content-based forced-subtitle detection. `info` opts in so its
|
||
// forced flags match what the muxer derives during a rip (both use the
|
||
// one shared PGS classifier); the rip path leaves it off — the muxer
|
||
// detects forced while muxing, without a second read of the clip.
|
||
if _opts.probe_forced_subtitles {
|
||
for title in &mut titles {
|
||
if title.content_format == ContentFormat::BdTs {
|
||
pgs_forced_probe::probe_and_set_forced(reader, title);
|
||
}
|
||
}
|
||
}
|
||
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 ~#13–36), 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(crate) 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(crate) 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).
|
||
/// Also returns 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. Returning ONLY the keys would collapse 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`) would wrongly raise `E7023` for a title that needs
|
||
/// no key at all — the false error the multi-title mux must never emit.
|
||
///
|
||
/// So the re-crack runs via [`crate::css::crack_key_outcome`] and ALSO
|
||
/// 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(
|
||
&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`].
|
||
///
|
||
/// 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(crate) 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,
|
||
/// 1–50 bad sectors AND <1 sec lost. Likely unnoticeable.
|
||
Cosmetic,
|
||
/// 51–500 sectors OR 1–30 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`'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(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).0 {
|
||
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).0;
|
||
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(¬_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
|
||
);
|
||
}
|
||
}
|