//! 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(crate) mod pgs_forced_probe; use crate::drive::Drive; 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, /// 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, /// Disc region pub region: DiscRegion, /// AACS state -- None if disc is unencrypted or keys unavailable pub aacs: Option, /// CSS state -- None if not a CSS-encrypted DVD pub css: Option, /// 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, /// 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`]. /// /// This records the MAIN feature's crack, so it is a WHOLE-DISC signal: the /// gates convert it into [`crate::error::Error::CssNoDiscKey`] (disc-level, /// `error::is_disc_level_no_key`), not the per-title /// [`crate::error::Error::CssKeyMissing`] the field itself carries as its /// recorded reason. pub css_error: Option, /// 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, Eq, Hash)] 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), /// DVD regions (1-8 or combination) Dvd(Vec), } /// 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, /// All streams (video, audio, subtitle, etc.) pub streams: Vec, /// Chapter points pub chapters: Vec, /// Sector extents for ripping (clip LBA ranges) pub extents: Vec, /// 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>>, } /// 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, } /// 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>, } /// 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) -> 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 = 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> = 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) }; } } } /// Merge per-title AACS key ranges into the sorted, disjoint set the whole-disc map /// needs ([`crate::decrypt::AacsKeyMap::entry_for`] requires disjoint ranges). /// Titles that share a clip resolve the SAME physical span (same LBAs → same CPS /// unit → same key). When a later range overlaps a kept one that carries the SAME /// key index and phase, the two are UNIONED (end extended to the max) — this covers /// both the exact-duplicate (shared clip) case and any partial overlap without ever /// dropping coverage, so no encrypted LBA is left in no range (which would pass /// through as ciphertext). A real disc never produces two DIFFERENT keys for one /// LBA; if that malformed case ever appeared, the later range is dropped to keep the /// set disjoint rather than extend one key over another key's LBAs. fn merge_content_key_ranges( mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>, ) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> { ranges.sort_by_key(|&(s, _, _, _)| s); let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); for r in ranges { match merged.last_mut() { // Overlaps the previous kept range. Some(last) if r.0 < last.1 => { // Same key + phase → union (coverage-preserving); a genuine // different-key overlap (malformed disc) is dropped to stay disjoint. if r.2 == last.2 && r.3 == last.3 { last.1 = last.1.max(r.1); } } // Disjoint or exactly adjacent → keep as its own range. _ => merged.push(r), } } merged } /// 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 { use crate::consts::SECTOR_BYTES_U64; let mut cumulative = 0u64; for ext in &title.extents { // Saturating, like every other extent-end computation in the crate // (`bytes_bad_in_title`, `crack_key_scan`'s crack span, // `DiscStream::fill_extents`). ECMA-167 logical block numbers are // 32-bit, so a malformed UDF/IFO extent near the top of that space // makes a plain `+` overflow — a debug-build PANIC inside a library, // and a release-build wrap to a tiny end LBA that silently reports the // offset as outside the title. let ext_end = ext.start_lba.saturating_add(ext.sector_count); if lba >= ext.start_lba && lba < ext_end { 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, Option) { if let Some(byte_offset) = byte_offset_in_title(lba, title) && let Some((ch, t)) = chapter_at_offset( &title.chapters, byte_offset, title.duration_secs, title.size_bytes, ) { return (Some(ch as u32), Some(t)); } (None, None) } /// Annotate raw bad byte-ranges with chapter + movie time, producing the /// rendered drilldown ([`crate::progress::LocatedProgress`]) a client draws. /// `raw` is the mapfile's `(byte_pos, byte_len)` set for whichever statuses the /// caller cares about (the live "Maybe" set during a patch, or terminal /// `Unreadable` for the verdict). The list is sorted largest-movie-time first /// and capped at 50; `truncated` reports the overflow. `bps` (title bytes/sec) /// is derived from the title so callers don't thread it. /// /// This is the single place range→chapter/time annotation happens; autorip used /// to own it and read the mapfile to do so. Now the library computes it from /// its in-memory mapfile + title, and the client renders the result verbatim. pub fn locate_ranges(raw: &[(u64, u64)], title: &DiscTitle) -> crate::progress::LocatedProgress { use crate::consts::{MILLIS_PER_SEC, SECTOR_BYTES_U64}; use crate::progress::{LocatedProgress, LocatedRange}; const MAX_LOCATED: usize = 50; let bps = if title.duration_secs > 0.0 { title.size_bytes as f64 / title.duration_secs } else { 0.0 }; let num_ranges = raw.len() as u32; let mut ranges: Vec = 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, } } // ─── 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, // 0xA2 is the SECONDARY DTS-HD audio stream (Blu-ray Disc // Read-Only Format part 3, stream_coding_type table): DTS Express / // DTS-HD LBR, a LOSSY low-bitrate extension carried alongside the // primary track for picture-in-picture and BD-J mixing — exactly // parallel to 0xA1 (secondary E-AC-3) on the Dolby side. It is NOT // DTS-HD Master Audio, which has its own primary code 0x86; mapping // it there advertised a lossless track for lossy content, so the // muxer's stream metadata and every label derived from it claimed a // quality the bitstream does not carry. The crate has no distinct // DTS Express variant, so it is represented by the LOSSY DTS-HD // member. c::DTS_HD_SECONDARY => Codec::DtsHdHr, // 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). `(0, 0)` when the resolution is /// [`Resolution::Unknown`] — "no dimensions", not a guess. /// /// The SD frames are the DVD-Video coded pictures (ITU-R BT.601 525/60 and /// 625/50 active area); the HD frames are the Blu-ray Disc Read-Only Format /// part 3 video formats; 3840x2160 is the UHD BD frame. /// /// `Unknown` deliberately does NOT fabricate a plausible 1920x1080. This is /// the same trap already closed on [`AudioChannels::count`] (which used to /// return 6) and [`SampleRate::hz`] (48000.0): a plausible wrong answer is /// indistinguishable from a real one at every call site, so it makes each /// caller responsible for remembering to check the variant first — and the /// `json://` sink had already walked into exactly that, reporting confident /// dimensions for a stream whose neighbouring `resolution` field said /// "unknown". A zero is unmistakable, and every caller in tree already /// handles it: the Matroska sink omits the optional PixelWidth/PixelHeight, /// the VobSub `.idx` writer omits its `size:` line, and no caller divides by /// either dimension. 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 => (0, 0), } } /// 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, // 0, not 6. An unknown layout has no channel count, and returning a // plausible one made every caller responsible for remembering to // check the variant first — a trap, and one this crate walked into: // the json:// sink reported a confident 5.1 for audio its own // neighbouring fields called "unknown". 0 is the value Matroska and // the sinks already coerce Unknown to, and unlike 6 it is obviously // wrong if it ever reaches output. AudioChannels::Unknown => 0, } } /// 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, // 0.0, not 48000.0 — see AudioChannels::count. A fabricated rate is // indistinguishable from a real one; a zero is not. SampleRate::Unknown => 0.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 { 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 { 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 { 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 { 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() } /// The title's audio streams, in declared order. Cleaner than matching on /// the [`Stream`] enum for the common "iterate the audio tracks" case /// (stream selection, the desktop UI's info panel, disc-info listing). pub fn audio_streams(&self) -> impl Iterator { self.streams.iter().filter_map(|s| match s { Stream::Audio(a) => Some(a), _ => None, }) } /// The title's subtitle streams, in declared order. pub fn subtitle_streams(&self) -> impl Iterator { self.streams.iter().filter_map(|s| match s { Stream::Subtitle(s) => Some(s), _ => None, }) } /// The title's video streams, in declared order (usually one; two for a /// Blu-ray 3D MVC title — the base view plus the dependent view). pub fn video_streams(&self) -> impl Iterator { self.streams.iter().filter_map(|s| match s { Stream::Video(v) => Some(v), _ => None, }) } } // ─── 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, /// 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, /// 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, } // 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(|_| "")) .field("unit_keys_len", &self.unit_keys.len()) .field("read_data_key", &self.read_data_key.map(|_| "")) .field("volume_id", &"") .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, } /// 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, /// 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>, /// 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, /// 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, /// 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 { 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 { // 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); // CSS bus-auth unlock — run BEFORE any scrambled-sector read. // On a CSS-enforcing drive (e.g. the BU40N) the UDF metadata prefetch // below reaches small/menu VOB extents that are themselves CSS-scrambled; // without the bus-auth handshake first, each of those reads is rejected // with sense 05/6F/03 ("read of scrambled sector without authentication") // after a full drive round-trip — ~13s of pure waste on a real disc, and // the prefetch caches nothing. The handshake needs no title info (it takes // no extents), is self-guarding to DVD media, and is non-fatal on failure, // so run it as soon as the drive has classified the disc as a DVD. The // per-VTS title key is NOT recovered here (or anywhere in scan) — it is // cracked keylessly at read/mux time via `css::resolve_dvd_title_key`; // this only unlocks the drive's read gating so those reads can happen. if session.disc_is_dvd() { tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)"); 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" ); } } // Read UDF filesystem with buffered sector reader tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem"); let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?; tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read"); // Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv). // Without this, each read_file() triggers individual SCSI commands at 500ms each. if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) { buffered.prefetch_ranges(&ranges); } tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams"); let disc = Self::scan_with( &mut buffered, capacity, handshake, handshake_error, opts, udf_fs, )?; tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed"); // No CSS key recovery at scan time. DVD CSS keys are per-VTS/per-title, // and the descramble path re-cracks each title's key keylessly from that // title's own extents at the moment its sectors are read/decrypted — so a // single up-front "disc key" is meaningless (it's not valid for the other // titles and every title re-derives its key at read time anyway). The // scan's only CSS responsibility is the bus-auth unlock above, which opens // the drive's read gating so the sweep can read scrambled sectors at all. // Detection is likewise moot: encrypted or not, a DVD muxes identically — // the read-time descrambler cracks a title key if the sectors are // scrambled and is a no-op if they are clear. // `disc.css` is intentionally never set at scan time now (per-title CSS // keys are cracked at read/mux time), so log the format the scan actually // determined rather than a key state that is always `None` here. tracing::info!(target: "freemkv::scan", format = ?disc.format, titles = disc.titles.len(), "phase: scan complete"); Ok(disc) } /// The extents an image-time CSS crack scans, in the crate's CANONICAL /// order: the main feature's own extents, in natural playback order. /// /// Both halves are deferred to logic the crate already owns rather than /// re-derived here: /// - WHICH title — `scan_with` has already sorted `titles` with /// [`Self::canonical_title_order`], so the canonical main feature is the /// first title that actually has extents. No local pick. /// - WHAT ORDER — playback order, i.e. the extent vector untouched, exactly /// as [`Self::decrypt_keys_for_title`] hands `&title.extents` to /// `css::crack_key_outcome`. /// /// **Why this function exists (do not let the copy grow back):** /// `Disc::scan_image` used to re-implement both halves inline — it picked /// the title with the largest total sector count, then re-sorted that /// title's extents LARGEST-CELL-FIRST before handing them to /// `crack_key_outcome`. Both had drifted from the canonical rules: /// /// - Largest-cell-first is the 1.5.1 garbage bug. A CSS DVD's biggest cell /// opens with a long CLEAR run, and the crack's 50_000-sector budget is /// shared across the whole extent list, so starting there can exhaust the /// budget without ever reaching a scrambled sector — the crack reports /// `Unencrypted` and the mux emits scrambled MPEG as plaintext. Playback /// order reaches the scrambled feature body after only the small clear /// front matter that precedes it on a real disc (CSS: the title key is /// recovered from the scrambled data itself, so the scan must actually /// MEET scrambled data). /// - The sector-count pick ignores `canonical_title_order`'s capacity gate, /// so it selects the oversize "play-all" composite (whose declared cells /// double-count data shared with other playlists) instead of the real /// feature — i.e. a DIFFERENT title, and on a multi-VTS disc a different /// VTS, whose CSS title key does not descramble the feature at all. /// /// The result: the same disc cracked as an ISO could disagree with the same /// disc cracked from the drive, which is precisely what the duplicate was /// free to do. Keep the derivation here, shared, so it cannot recur. fn image_crack_extents(titles: &[DiscTitle]) -> &[Extent] { titles .iter() .find(|t| !t.extents.is_empty()) .map(|t| t.extents.as_slice()) .unwrap_or(&[]) } /// 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 { 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() { // Copied out so the crack's `disc.css` / `disc.encrypted` writes // below don't collide with a live borrow of `disc.titles`. The // ORDER is whatever `image_crack_extents` returns — never re-sorted // here (see that function's docs: the local re-sort was the defect). let main_extents = Self::image_crack_extents(&disc.titles).to_vec(); 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, Vec, u8)> { let inf = crate::aacs::read_first( &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::UnitKey), |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::role_paths(udf_fs, crate::aacs::AacsRole::ContentCert), |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> { 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::role_paths(udf_fs, crate::aacs::AacsRole::Mkb), |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, Vec, 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, Vec, 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, handshake_error: Option, opts: &ScanOptions, udf_fs: udf::UdfFs, ) -> Result { 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 { // One cache across every title: a disc's playlists overwhelmingly // reference the same handful of clips (main feature, play-all, // seamless-branch variants), so without memoisation the same physical // extents are re-read from the drive once per playlist — 30-150 times // on a typical Blu-ray. let mut cache = pgs_forced_probe::ForcedProbeCache::new(); for title in &mut titles { if title.content_format == ContentFormat::BdTs { pgs_forced_probe::probe_and_set_forced( reader, title, &mut cache, opts.halt.as_ref(), ); } } } 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. /// The sort keys [`Self::canonical_title_order`] applies, in priority /// order, as diagnostic-facing tokens. /// /// Defined HERE, immediately beside the comparator, so a diagnostic can /// NAME the ordering instead of restating it. The `freemkv::diag` /// main-feature decision row used to carry its own hand-written copy of /// this list, and it drifted: it still advertised a `fewest-clips` key long /// after the comparator replaced clip-count with largest-physical-size, so /// the `--log-level 3` bug-report log explained freemkv's top-level pick /// with a rule freemkv does not apply. Any change to the keys below must /// change this list in the same edit. pub const CANONICAL_TITLE_ORDER_KEYS: &'static [&'static str] = &["fits-disc", "largest-size", "longest", "richest-audio"]; 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 { 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), /// 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()"), 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 `is_clean` content-clarity 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], 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 } } /// Resolve a WHOLE-DISC AACS key map for a decrypting sweep (`disc:// → iso://`): /// the union of every title's proactive key map ([`crate::mux::resolve_mux_key_map`]), /// so a sequential read of the entire disc decrypts each content unit with its /// mapped key and passes clear filesystem/nav sectors (in no range) through. /// Fails loud (via the per-title resolve) if any content unit's key is missing. /// `keys` is mutated as fetched keys are banked; the merged ranges are disjoint /// (titles that share a clip resolve the same span — the duplicate is dropped). pub fn resolve_content_key_map( &self, reader: &mut dyn SectorSource, keys: &mut crate::decrypt::DecryptKeys, fetch: Option<&crate::sector::KeyFetch>, halt: Option<&crate::halt::Halt>, ) -> Result { let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); // ONE per-disc memo across every title. Without it every playlist re-derives // the same disc-wide facts off the drive: // // * the multi-CPS "which held key opens this extent" decision — 8 random // 6144-byte reads per extent, ~200 ms of seek apiece on a stock BD drive. // A disc's playlists overwhelmingly reference the same handful of clips // (main feature, play-all, per-chapter and seamless-branch variants), so // this recomputed the same index from byte-identical input; // * the UDF walk + `/AACS/IndividualSegment.tbl` read that decides whether // the disc is FMTS at all — ~35 single-sector reads at low LBAs, reached // from a head the previous title's content sampling left deep in the // content area, so a full-stroke seek out and back per playlist. This // runs on EVERY disc, FMTS or not; // * on an FMTS (AACS 2.1) disc, the forensic anchor probe, the per-index // phase probe AND the key-service round trip that returns the disc's // index-key set. That last one is the key-server storm: one round trip // per playlist for one disc-wide answer. // // The FMTS memos are what makes the multi-CPS memo reachable at all on an // FMTS disc — that path returns its finished map before the extent loop. let mut cache = crate::mux::resolve::DiscKeyCache::new(); for title in &self.titles { let map = crate::mux::resolve::resolve_mux_key_map_cached( reader, title, keys, fetch, self.content_format, halt, &mut cache, )?; ranges.extend_from_slice(map.ranges()); } Ok(crate::decrypt::AacsKeyMap::from_ranges_phased( merge_content_key_ranges(ranges), )) } /// 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 the TS-sync content check about /// non-content bytes — filesystem data has no TS sync and would otherwise be /// mistaken for ciphertext (the first-2-GB false-positive this fixes). /// /// Empty when the disc has no parsed titles (CSS / unencrypted / unscanned); /// callers treat an empty map as "no content gate" and fall back accordingly. pub fn encrypted_content_ranges(&self) -> Vec<(u32, u32)> { merged_extents(self.titles.iter().flat_map(|t| &t.extents)) } /// The 40-hex AACS disc id (SHA1 of `Unit_Key_RO.inf`, no `0x` prefix), or /// empty when this disc has no captured AACS state. Used to name the disc in /// a [`Error::NoDiscKey`] so the application can tell the user which disc to /// add to the keydb. pub(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 DVD read-unlock (CSS bus-auth) is issued during the scan // for EVERY DVD to clear the drive's scrambled-read barrier; // unlike the firmware/AACS arms above (whose success is already // captured in disc state), the bus-auth outcome is not tracked // separately, so this arm reports the MEDIUM — "the DVD // read-unlock path engaged" — rather than a per-rip success // bit. That is intentional: the CSS descramble is keyless and // handled at mux time, and a genuine bus-auth failure surfaces // downstream as a read/crack error, not here. (Formerly this // reported `self.css.is_some()` — "a crack recovered a key" — // which under-reported: an encrypted DVD that reads and muxes // fine showed "no".) "DVD" => self.format == DiscFormat::Dvd, // 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::CssNoDiscKey)`. 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. A /// DISC-LEVEL verdict (`error::is_disc_level_no_key`) — the main feature's /// crack failed, so every title fails the same way and the rip loop must /// stop rather than skip each title in turn. /// - 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. `css_error` records the MAIN // feature's crack, so this is a WHOLE-DISC verdict — every title would // fail identically — and it is raised as `CssNoDiscKey` (disc-level, // `error::is_disc_level_no_key`), never as the per-title // `CssKeyMissing` (`error::is_skippable_title_stub`). Raised as the // latter, an undecryptable disc made the rip loop iterate all N titles // logging "title skipped, it was an empty stub" and exit 0. if self.css_error.is_some() { return Err(Error::CssNoDiscKey); } // 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. /// /// For a **DVD** the CSS title key MUST be recovered before descrambling: a /// scrambled sector without a Stevenson crib cannot self-crack, and CSS leaves /// the pack/PES header clear, so a sector left un-descrambled would mux as a /// structurally-valid but corrupt PES packet with no loss reported. Two ways /// to get it: /// /// - **Fast path** — the scan already cracked a key whose LBA span /// ([`crate::css::CssState::crack_span`]) covers this title's VTS: reuse it. /// No re-read, and on a live drive no second CSS bus-auth round-trip. CSS /// title keys are per-VTS, so an overlapping span is the same key. /// - **Crack** — when up-front detection missed (`self.css == None`) or the /// title lives in a different VTS: crack the key from this title's OWN /// extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell- /// first, which starved the crack in a big cell's clear prefix — the 1.5.1 /// bug). Playback order reaches the scrambled feature body after only the /// small clear front matter (logo / rating card) that precedes it. One /// scan = one CSS-locked early-bail, so a locked title is not re-hammered /// per cell against a live drive (hard rule #2); its 50k-sector budget is /// the same accepted bound the disc-wide scan uses. /// /// Crucially the crack path does NOT gate on `self.css`: a detection miss can /// never route the mux into raw passthrough of scrambled sectors. /// /// Outcomes (`batch_sectors` sizes the crack's batched reads): /// - `(Css{title_key}, false)` — descramble with the recovered/reused key. /// - `(None, true)` — a genuinely-clear title needs no key; the gate passes it. /// - `(None, false)` — scrambled but no key recoverable → hard failure via /// [`Self::ensure_title_decryptable`], never a silent garbage mux. /// /// Non-DVD schemes (AACS / FMTS / genuinely unencrypted) return /// [`Self::decrypt_keys`] unchanged. pub fn decrypt_keys_for_title( &self, idx: usize, reader: &mut dyn SectorSource, batch_sectors: u16, ) -> (crate::decrypt::DecryptKeys, bool) { // Non-DVD (AACS / FMTS / genuinely unencrypted): disc-wide keys, unchanged. if self.format != DiscFormat::Dvd { 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: nothing scrambled to worry about, so mark // it clear (`true`). Returning `false` here would let the gate's DVD // "None keys + not clear = scrambled-uncracked" rule wrongly hard-fail // a genuinely-unencrypted DVD title that has no extents. _ => return (self.decrypt_keys(), true), }; // Fast path: reuse the scan's cracked key if its span covers this title's // VTS. `crack_span: None` (unknown provenance) is treated as covering. if let Some(css) = self.css.as_ref() { let covers = match css.crack_span { None => true, Some((cs, ce)) => title .extents .iter() .any(|e| e.start_lba < ce && cs < e.start_lba.saturating_add(e.sector_count)), }; if covers { return ( crate::decrypt::DecryptKeys::Css { title_key: css.title_key, }, false, ); } } // Detection miss or a different VTS: crack this title's key from its OWN // extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell- // first — that was the 1.5.1 garbage bug, where a big cell's clear prefix // starved the crack). Playback order reaches the scrambled feature body // after only the (small) clear front matter that precedes it on a real // disc. This is ONE crack_key_outcome call, exactly like the disc-wide // scan: its single CSS-locked early-bail runs at most once, so a locked/ // uncrackable title is NOT re-hammered per cell against the live drive // (hard rule #2). The crack's 50k-sector budget bounds a fully-clear title, // the same accepted bound the disc-wide scan uses. match crate::css::crack_key_outcome(reader, &title.extents, batch_sectors, None) { crate::css::CrackOutcome::Cracked(state) => ( crate::decrypt::DecryptKeys::Css { title_key: state.title_key, }, false, ), // Scrambled but no key recoverable → hard failure. crate::css::CrackOutcome::ScrambledUncracked => { (crate::decrypt::DecryptKeys::None, false) } // No scrambled sector anywhere in the whole title → genuinely clear. crate::css::CrackOutcome::Unencrypted => (crate::decrypt::DecryptKeys::None, true), } } /// 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(()); } // A DVD title that cracked to no key and is NOT clear is scrambled-but- // uncrackable (`decrypt_keys_for_title` → `ScrambledUncracked`). Hard-fail // here directly: `ensure_decryptable_keys` gates CSS on `self.css.is_some()` // (the scan's disc-wide detection), which can be `None` when that up-front // detection missed — exactly the case the per-title crack exists to catch. // Without this, an uncrackable DVD title would fall through to `Ok` and mux // scrambled sectors as corrupt PES at exit 0. // // `CssKeyMissing` (per-title, `error::is_skippable_title_stub`) is the // RIGHT code here and must stay: this is one title of a multi-VTS disc, // and a sibling title in another VTS may still crack its own key, so an // all-titles rip skips this one and finishes the rest. The whole-disc // failure — nothing on the disc cracked — is the gate above's // `CssNoDiscKey`. if self.format == DiscFormat::Dvd && !title_is_clear && !keys.is_encrypted() { return Err(Error::CssKeyMissing); } // A usable per-title key was resolved (a freshly-cracked CSS key, or AACS // unit keys) — the title IS decryptable, so pass it WITHOUT consulting the // disc-wide gate. `ensure_decryptable_keys` hard-fails (disc-level, // `CssNoDiscKey`) on `self.css_error` // unconditionally, which reflects the MAIN feature's crack: a bonus title // in a different VTS that just cracked its own key must not be blocked by // the main title having failed. if 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 { 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]) -> 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(()) } } /// 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(crate) 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; /// Whether the Linux-sysfs transfer-size probe applies to this device path. /// /// The probe reads `/sys/block//...` / `/sys/class/scsi_generic//...`, /// 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//device/type; // for a block node (sr0/sdX) at /sys/block//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::() == 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) && let Ok(kb) = content.trim().parse::() { // 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::*; // ── image-time CSS crack: canonical extent ordering (finding 1) ───────── /// Build a title with the given extents (start_lba, sector_count) and a /// declared `size_bytes` (used by `canonical_title_order`'s capacity gate). fn title_with_extents(size_bytes: u64, extents: &[(u32, u32)]) -> DiscTitle { DiscTitle { size_bytes, extents: extents .iter() .map(|&(start_lba, sector_count)| Extent { start_lba, sector_count, }) .collect(), content_format: ContentFormat::MpegPs, ..DiscTitle::empty() } } /// The image-time CSS crack must scan the main feature's extents in natural /// PLAYBACK order — the same order `decrypt_keys_for_title` (the canonical /// per-title crack) uses. Largest-cell-first is the 1.5.1 garbage bug: a big /// cell's long clear prefix starves the 50k-sector budget before the crack /// ever reaches a scrambled sector. /// /// This test distinguishes the two orderings: the fixture's LARGEST cell is /// physically LAST, so largest-cell-first yields a strictly different LBA /// sequence than playback order. #[test] fn image_crack_extents_are_playback_order_not_largest_cell_first() { // A real CSS DVD: a short clear front matter cell (logo/rating card) // precedes the big scrambled feature body. let title = title_with_extents(0, &[(1_000, 16), (1_016, 512), (2_000, 40_960)]); let got = Disc::image_crack_extents(std::slice::from_ref(&title)); let lbas: Vec = got.iter().map(|e| e.start_lba).collect(); assert_eq!( lbas, vec![1_000, 1_016, 2_000], "extents must be handed to the crack in playback order" ); } /// The main feature for the image-time crack is the canonically-ordered /// `titles[0]` (`scan_with` has already applied `canonical_title_order`), /// NOT a locally re-derived "most sectors" pick. The capacity gate is the /// difference: an oversize play-all composite double-counts shared cells, so /// it has the most sectors while being demoted to the back by the canonical /// order. Cracking from its extents is cracking from the wrong title. #[test] fn image_crack_extents_follow_canonical_title_order_not_sector_count() { // titles[0] = the real feature (canonical order already applied). let feature = title_with_extents(2_000_000_000, &[(5_000, 100_000)]); // Demoted play-all composite: MORE total sectors than the feature. let play_all = title_with_extents(9_000_000_000, &[(5_000, 100_000), (5_000, 100_000)]); let titles = [feature, play_all]; let got = Disc::image_crack_extents(&titles); assert_eq!( got.len(), 1, "the crack must use the canonical main feature's single extent" ); assert_eq!(got[0].sector_count, 100_000); } // ── Unknown must not fabricate a plausible value (finding 2) ──────────── /// `Resolution::Unknown` has no dimensions, so `pixels()` must report none /// (0, 0) — the same treatment `AudioChannels::count()` and /// `SampleRate::hz()` already give their `Unknown` variants. Returning a /// plausible 1920x1080 is indistinguishable from a real 1080p title, so a /// sink (the `json://` one already did this for audio) reports confident /// dimensions for video its own neighbouring `resolution` field calls /// "unknown". /// /// The real variants are asserted against spec literals, not against /// `pixels()` itself: 720x480 / 720x576 are the DVD-Video coded frames /// (ITU-R BT.601 525/60 and 625/50 active area), 1920x1080 and 1280x720 are /// the Blu-ray Disc Read-Only Format part 3 HD frames, 3840x2160 the UHD /// BD frame. #[test] fn unknown_resolution_reports_no_pixel_dimensions() { assert_eq!( Resolution::Unknown.pixels(), (0, 0), "an unknown resolution must report no dimensions, not a fabricated default" ); assert_eq!(Resolution::R480i.pixels(), (720, 480)); assert_eq!(Resolution::R480p.pixels(), (720, 480)); assert_eq!(Resolution::R576i.pixels(), (720, 576)); assert_eq!(Resolution::R576p.pixels(), (720, 576)); assert_eq!(Resolution::R720p.pixels(), (1280, 720)); assert_eq!(Resolution::R1080i.pixels(), (1920, 1080)); assert_eq!(Resolution::R1080p.pixels(), (1920, 1080)); assert_eq!(Resolution::R2160p.pixels(), (3840, 2160)); assert_eq!(Resolution::R4320p.pixels(), (7680, 4320)); } /// Every `Unknown` variant that exposes a numeric accessor must report /// "nothing", never a plausible default — the sibling sweep the previous /// round skipped. `FrameRate::Unknown` reports the 0/1 null fraction rather /// than 0 fps, because callers divide by the numerator. #[test] fn no_unknown_variant_fabricates_a_numeric_value() { assert_eq!(Resolution::Unknown.pixels(), (0, 0)); assert_eq!(FrameRate::Unknown.as_fraction(), (0, 1)); assert_eq!(AudioChannels::Unknown.count(), 0); assert_eq!(SampleRate::Unknown.hz(), 0.0); // Not numeric, but the same rule: the token names the unknown, it does // not name a plausible colorimetry. assert_eq!(ColorSpace::Unknown.id(), "unknown"); } // ── BD-ROM stream_coding_type 0xA2 (finding 3) ────────────────────────── /// Blu-ray Disc Read-Only Format part 3, `stream_coding_type` table: 0xA2 is /// the SECONDARY DTS-HD audio stream — DTS Express / DTS-HD LBR, a LOSSY /// low-bitrate codec carried alongside the primary track for /// picture-in-picture and BD-J mixing. It is NOT DTS-HD Master Audio (0x86), /// which is the lossless primary code. /// /// Literals, not the `consts::coding_type` names, so renaming or re-valuing /// a constant cannot make this pass vacuously. #[test] fn secondary_dts_hd_0xa2_is_lossy_not_master_audio() { assert_ne!( Codec::from_coding_type(0xA2), Codec::DtsHdMa, "0xA2 is the lossy secondary DTS-HD stream, not lossless Master Audio" ); assert_eq!(Codec::from_coding_type(0xA2), Codec::DtsHdHr); // The lossless primary keeps its own code, unchanged. assert_eq!(Codec::from_coding_type(0x86), Codec::DtsHdMa); // ...and both remain audio, so the STN/PMT walker still enumerates them. assert_eq!(Codec::from_coding_type(0xA2).kind(), CodecKind::Audio); } // ── extent-end arithmetic saturates (finding 5) ───────────────────────── /// `byte_offset_in_title` must compute its extent end with saturating /// arithmetic, like every other extent-end computation in the crate. A /// malformed UDF/IFO extent near the top of the 32-bit LBA space (ECMA-167 /// logical block numbers are 32-bit) otherwise overflows: a debug build /// PANICS inside a library, and a release build wraps to a tiny end LBA so /// the range test silently fails and the offset comes back `None`. #[test] fn byte_offset_in_title_saturates_the_extent_end() { let title = title_with_extents(0, &[(u32::MAX - 10, 100)]); // 9 sectors past the extent start; 2048 is the ECMA-167 / UDF logical // sector size, so the byte offset is 9 * 2048. let got = byte_offset_in_title(u32::MAX - 1, &title); assert_eq!(got, Some(18_432)); // The saturated end is u32::MAX (exclusive), so the very last // addressable LBA is still inside the extent. assert_eq!( byte_offset_in_title(u32::MAX - 10, &title), Some(0), "the extent start itself maps to offset 0" ); } /// `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)]); } // ── merge_content_key_ranges (whole-disc AACS map assembly) ──────────────── use crate::decrypt::Phase; /// Ranges from different titles are sorted by start LBA and kept disjoint. #[test] fn merge_key_ranges_sorts_and_keeps_disjoint() { let v = vec![ (500u32, 600u32, 1usize, Phase::All), (100, 200, 0, Phase::All), (300, 400, 2, Phase::All), ]; assert_eq!( merge_content_key_ranges(v), vec![ (100, 200, 0, Phase::All), (300, 400, 2, Phase::All), (500, 600, 1, Phase::All), ] ); } /// A clip shared by two titles resolves the SAME span twice; the duplicate is /// dropped so `entry_for` sees a disjoint set (one key for the span). #[test] fn merge_key_ranges_dedups_shared_clip_span() { let v = vec![ (100u32, 300u32, 0usize, Phase::All), (100, 300, 0, Phase::All), ]; assert_eq!(merge_content_key_ranges(v), vec![(100, 300, 0, Phase::All)]); } /// A later range that partially overlaps a kept one carrying the SAME key is /// UNIONED, not dropped — the tail (400..500) must stay covered, or those /// encrypted LBAs would fall in no range and pass through as ciphertext. #[test] fn merge_key_ranges_unions_same_key_overlap() { let v = vec![ (100u32, 400u32, 0usize, Phase::All), (200, 500, 0, Phase::All), ]; assert_eq!(merge_content_key_ranges(v), vec![(100, 500, 0, Phase::All)]); } /// A different-key partial overlap (malformed disc) is dropped rather than /// unioned, so one unit key is never stretched over another key's LBAs; the set /// stays disjoint for `entry_for`. #[test] fn merge_key_ranges_drops_conflicting_key_overlap() { let v = vec![ (100u32, 400u32, 0usize, Phase::All), (200, 500, 1, Phase::All), ]; assert_eq!(merge_content_key_ranges(v), vec![(100, 400, 0, Phase::All)]); } /// Adjacent (touching) ranges are BOTH kept — `r.0 >= prev_end` holds when the /// next starts exactly at the previous end, so no coverage is lost. #[test] fn merge_key_ranges_keeps_adjacent() { let v = vec![ (100u32, 200u32, 0usize, Phase::All), (200, 300, 1, Phase::All), ]; assert_eq!( merge_content_key_ranges(v), vec![(100, 200, 0, Phase::All), (200, 300, 1, Phase::All)] ); } /// 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:")); } /// 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 = 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 { 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"); } 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: "".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 rather /// than read `css.is_none()` as "unencrypted", and with the DISC-LEVEL /// `CssNoDiscKey` (not the per-title, skippable `CssKeyMissing`): `css_error` /// reflects the main feature's crack, so every title fails identically. #[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::CssNoDiscKey.code()); // --raw is exempt. assert!(disc.ensure_decryptable(true).is_ok()); } /// The two CSS no-key conditions are NOT the same verdict and must classify /// oppositely through the public predicates: /// /// - **disc-wide** — `css_error` is set: the MAIN feature's crack failed, so /// every title of this disc fails identically. Must be /// [`crate::error::is_disc_level_no_key`] (the rip loop fail-fasts) and /// must NOT be [`crate::error::is_skippable_title_stub`]. While both /// conditions shared `E_CSS_KEY_MISSING`, an uncrackable CSS disc iterated /// all N titles logging "title skipped" and exited 0 — a total failure /// reported as success. /// - **per-title** — one title's own re-crack failed on a multi-VTS disc /// (`title_is_clear == false`, no key): skipping it and finishing the rest /// is correct policy, so it must STAY skippable and must NOT be disc-level. /// /// Pinned in both directions so a future change cannot silently flip either. #[test] fn css_disc_wide_no_key_is_disc_level_while_per_title_stays_skippable() { // Disc-wide: the scan saw scrambled sectors and recovered no key. let mut disc = make_test_disc(1000, "DVD"); disc.encrypted = true; disc.css_error = Some(crate::error::Error::CssKeyMissing); let wide: std::io::Error = disc .ensure_decryptable(false) .expect_err("scrambled-but-uncracked CSS disc must error") .into(); assert!( crate::error::is_disc_level_no_key(&wide), "a whole-disc CSS crack failure must classify as disc-level: {wide}" ); assert!( !crate::error::is_skippable_title_stub(&wide), "a whole-disc CSS crack failure must NOT be a skippable title stub: {wide}" ); // Per-title: this title's VTS could not be re-cracked; the rest of the // disc may still rip. let (stub_disc, _) = css_disc_with_clear_stub(); let per_title: std::io::Error = stub_disc .ensure_title_decryptable(false, &crate::decrypt::DecryptKeys::None, false) .expect_err("scrambled-uncracked title must error") .into(); assert!( crate::error::is_skippable_title_stub(&per_title), "a per-title CSS re-crack failure must stay skippable: {per_title}" ); assert!( !crate::error::is_disc_level_no_key(&per_title), "a per-title CSS re-crack failure must NOT stop the whole rip: {per_title}" ); } /// 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 { 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.format = DiscFormat::Dvd; // make_test_disc defaults to Uhd disc.content_format = ContentFormat::MpegPs; 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 } /// A genuinely-clear extra title (an unencrypted menu stub in its own VTS) /// on a CSS DVD must mux without a false E7023. The stub lives in a DISJOINT /// VTS (its extents don't overlap the scan's `crack_span`), so /// `decrypt_keys_for_title` takes the crack path over the stub's own extents; /// the reader serves only clear sectors, so the crack returns `Unencrypted` /// → `(None, title_is_clear=true)`. The gate must then PASS the title with no /// key — no false E7023. #[test] fn clear_stub_title_on_css_disc_is_not_a_key_failure() { let (disc, stub_idx) = css_disc_with_clear_stub(); assert_eq!( disc.format, DiscFormat::Dvd, "fixture must exercise the DVD path" ); 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!( matches!(keys, crate::decrypt::DecryptKeys::None), "a clear stub in a disjoint VTS cracks to no key" ); assert!(title_is_clear, "the stub's own extents show no scrambling"); // The gate must PASS a clear title — 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 { 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 { 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>, } impl SectorSource for RecordingSource { fn read_sectors( &mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool, ) -> Result { 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 } /// Build a Stevenson-crackable scrambled CSS sector (a periodic run in the /// clear header continuing past 0x80), mirroring the css-module fixture. fn crackable_css_sector(title_key: &[u8; 5]) -> [u8; 2048] { const RUN_START: usize = 0x59; const PERIOD: usize = 8; let mut sec = [0u8; 2048]; sec[0x00..0x04].copy_from_slice(&crate::css::PACK_START); sec[0x14] = 0x10; // scramble flag for (i, b) in sec.iter_mut().enumerate().skip(RUN_START) { *b = (0xA0u8.wrapping_add((i % PERIOD) as u8)) ^ 0x5A; } crate::css::lfsr::scramble_sector(title_key, &mut sec); sec } /// A reader that serves crackable CSS sectors for LBAs in `scrambled` /// (half-open), all-zero (clear) elsewhere — records every LBA read. struct CssMapReader { key: [u8; 5], scrambled: (u32, u32), reads: std::cell::RefCell>, } impl SectorSource for CssMapReader { fn read_sectors( &mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool, ) -> Result { self.reads.borrow_mut().push(lba); let n = (count as usize * 2048).min(buf.len()); for s in 0..(n / 2048) { let this = lba + s as u32; let dst = &mut buf[s * 2048..(s + 1) * 2048]; if this >= self.scrambled.0 && this < self.scrambled.1 { dst.copy_from_slice(&crackable_css_sector(&self.key)); } else { dst.fill(0); } } Ok(n) } } fn css_dvd_with_extents(extents: Vec) -> Disc { let mut disc = make_test_disc(200_000, "DVD"); disc.format = DiscFormat::Dvd; disc.content_format = ContentFormat::MpegPs; disc.encrypted = true; let mut t = title_with_video(Codec::Mpeg2, Resolution::R480p); t.extents = extents; disc.titles = vec![t]; disc } /// `decrypt_keys_for_title` cracks a scrambled DVD title's key from the /// title's OWN extents and hands the mux the validated key — the seed the /// descramble needs, since a crib-less sector cannot self-crack and CSS leaves /// the pack/PES header clear (an un-seeded mux would emit corrupt PES). #[test] fn decrypt_keys_for_title_cracks_the_titles_key() { let key = [0x11, 0x22, 0x33, 0x44, 0x55]; let disc = css_dvd_with_extents(vec![Extent { start_lba: 100, sector_count: 64, }]); let mut src = CssMapReader { key, scrambled: (100, 164), reads: std::cell::RefCell::new(Vec::new()), }; let (keys, title_is_clear) = disc.decrypt_keys_for_title(0, &mut src, 16); assert!(!title_is_clear, "a scrambled title is not clear"); match keys { crate::decrypt::DecryptKeys::Css { title_key } => { assert_eq!(title_key, key, "must crack the title's own key") } _ => panic!("expected Css{{key}} for a scrambled DVD title"), } } /// REGRESSION (the 1.5.1 garbage bug): the crack scans extents in PLAYBACK /// ORDER, never largest-cell-first. A title whose LARGEST cell opens with a /// long unscrambled run must still crack its key from the smaller, /// scrambled-early cell that plays first — largest-first would exhaust the /// crack budget in the clear giant and wrongly report the title unencrypted, /// which the mux would pass through as scrambled garbage. #[test] fn decrypt_keys_for_title_scans_playback_order_not_largest_first() { let key = [0xDE, 0xAD, 0xBE, 0xEF, 0x01]; let disc = css_dvd_with_extents(vec![ // Plays FIRST: small, scrambled from its start. Extent { start_lba: 100, sector_count: 32, }, // A CLEAR cell far larger than the crack budget (would starve a // largest-first scan before it reached the scrambled cell above). Extent { start_lba: 10_000, sector_count: 100_000, }, ]); let mut src = CssMapReader { key, scrambled: (100, 132), reads: std::cell::RefCell::new(Vec::new()), }; let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16); match keys { crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( title_key, key, "must crack from the scrambled cell that plays first, not miss it behind the clear giant" ), _ => panic!("largest-first regression: the title was read as unencrypted"), } assert!( src.reads.borrow().iter().all(|&l| l < 10_000), "the key is found in the first (scrambled) cell — the clear giant must never be scanned: {:?}", src.reads.borrow() ); } /// A reader whose every read is CSS-locked (`05/6F/03`) — a genuinely /// encrypted DVD whose sectors can't be authenticated/cracked. struct LockedReader; impl SectorSource for LockedReader { fn read_sectors( &mut self, lba: u32, _count: u16, _buf: &mut [u8], _recovery: bool, ) -> Result { Err(Error::DiscRead { sector: lba as u64, status: Some(2), sense: Some(crate::scsi::ScsiSense { sense_key: 0x05, asc: 0x6F, ascq: 0x03, }), }) } } /// End-to-end: a scrambled-but-uncrackable DVD title with NO up-front /// detection (`self.css == None`) drives `decrypt_keys_for_title` to /// `(None, false)`, and the gate MUST hard-fail (CssKeyMissing) rather than /// pass it to the muxer — the silent-garbage case the per-title crack catches. #[test] fn decrypt_keys_for_title_scrambled_uncracked_dvd_hard_fails_even_without_detection() { let disc = css_dvd_with_extents(vec![Extent { start_lba: 100, sector_count: 8, }]); assert!(disc.css.is_none(), "fixture: no up-front detection"); let mut reader = LockedReader; let (keys, title_is_clear) = disc.decrypt_keys_for_title(0, &mut reader, 8); assert!( matches!(keys, crate::decrypt::DecryptKeys::None) && !title_is_clear, "a locked/uncrackable scrambled title resolves to (None, false)" ); let err = disc .ensure_title_decryptable(false, &keys, title_is_clear) .expect_err("scrambled-uncracked DVD title must hard-fail without detection"); assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code()); } /// Fast path: when the scan already cracked a key whose `crack_span` COVERS /// this title's VTS, `decrypt_keys_for_title` reuses it and never touches the /// reader (no redundant crack, no second bus-auth on a live drive). #[test] fn decrypt_keys_for_title_reuses_covered_scan_key_without_reading() { let disc = css_disc_with_two_vts(); // css=[0xAB;5], crack_span=(100,200) let mut src = RecordingSource { reads: std::cell::RefCell::new(Vec::new()), }; // Title 0's extents (100..200) overlap the cracked span → reuse. let (keys, clear) = disc.decrypt_keys_for_title(0, &mut src, 16); assert!(!clear); match keys { crate::decrypt::DecryptKeys::Css { title_key } => { assert_eq!(title_key, [0xAB; 5], "reuse the scan's cracked key") } _ => panic!("expected the reused Css key"), } assert!( src.reads.borrow().is_empty(), "a covered title must NOT re-read/re-crack: {:?}", src.reads.borrow() ); } /// A title in a DIFFERENT VTS (extents disjoint from `crack_span`) does NOT /// reuse the scan key — it cracks its own key from its own extents. #[test] fn decrypt_keys_for_title_cracks_other_vts_on_no_overlap() { let key = [0x77, 0x66, 0x55, 0x44, 0x33]; let disc = css_disc_with_two_vts(); // title 1 lives at 5000.., span=(100,200) let mut src = CssMapReader { key, scrambled: (5000, 5100), reads: std::cell::RefCell::new(Vec::new()), }; let (keys, _) = disc.decrypt_keys_for_title(1, &mut src, 16); match keys { crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( title_key, key, "a disjoint-VTS title cracks its OWN key, not the reused scan key" ), _ => panic!("expected a freshly-cracked Css key for the other VTS"), } assert!( src.reads.borrow().iter().all(|&l| l >= 5000), "must crack from title 1's own extents (>=5000): {:?}", src.reads.borrow() ); } /// A title whose (realistic) clear front matter — studio logo / rating card — /// plays FIRST, then the scrambled feature, still cracks: the single /// playback-order scan reads through the small clear prefix and reaches the /// scrambled body within its budget. (A clear prefix LARGER than the ~100 MB /// crack budget would starve — the accepted bounded-budget limit, identical to /// the disc-wide scan; not producible by real DVD front matter.) #[test] fn decrypt_keys_for_title_cracks_feature_after_clear_front_matter() { let key = [0xCA, 0xFE, 0xBA, 0xBE, 0x02]; // css=None so the crack path runs. ~10 MB of clear front matter plays // first (well under the crack budget), then the scrambled feature. let disc = css_dvd_with_extents(vec![ Extent { start_lba: 10_000, sector_count: 5_000, }, // clear front matter (~10 MB), plays first Extent { start_lba: 100, sector_count: 2_000, }, // scrambled feature body ]); let mut src = CssMapReader { key, scrambled: (100, 2_100), reads: std::cell::RefCell::new(Vec::new()), }; let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16); match keys { crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( title_key, key, "must crack the scrambled feature after reading through clear front matter" ), _ => panic!("clear front matter wrongly starved the crack"), } } /// Scrambling that begins well INTO a cell (after a clear prefix), not at its /// start, must still be cracked: the single playback-order scan reads through /// the clear prefix and reaches the scrambled body within its budget — never a /// silent "clear" verdict that would mux the scrambled tail as corrupt PES. #[test] fn decrypt_keys_for_title_cracks_scrambling_after_a_clear_prefix_in_one_cell() { let key = [0x0D, 0xEE, 0x40, 0x00, 0x05]; // One cell: clear for the first 9000 sectors, then scrambled (well within // the crack budget). css=None so the crack path runs. let disc = css_dvd_with_extents(vec![Extent { start_lba: 100, sector_count: 20_000, }]); let mut src = CssMapReader { key, scrambled: (100 + 9_000, 100 + 20_000), reads: std::cell::RefCell::new(Vec::new()), }; let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16); match keys { crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( title_key, key, "the scan must crack scrambling that starts past a clear prefix" ), _ => panic!("in-cell-deep scrambling was misread as clear (silent-garbage direction)"), } } /// A DVD title with EMPTY extents (an angle/PGC placeholder with no cells) /// resolves to `(decrypt_keys(), true)` — clear, no key needed — and the gate /// must PASS it. Returning `false` here would trip the DVD scrambled-uncracked /// rule and wrongly hard-fail a genuinely-clear empty title. #[test] fn decrypt_keys_for_title_empty_extents_is_clear_not_hard_fail() { let mut disc = css_dvd_with_extents(vec![Extent { start_lba: 100, sector_count: 8, }]); disc.titles .push(title_with_video(Codec::Mpeg2, Resolution::R480p)); // idx 1: no extents let mut reader = LockedReader; let (keys, title_is_clear) = disc.decrypt_keys_for_title(1, &mut reader, 8); assert!( title_is_clear, "an empty-extents title is clear (nothing to descramble)" ); assert!( disc.ensure_title_decryptable(false, &keys, title_is_clear) .is_ok(), "an empty-extents DVD title must not hard-fail" ); } /// A bonus title that cracked its OWN valid key must NOT be blocked by the /// disc-wide `css_error` set when the MAIN feature's scan failed. A usable /// per-title key means the title is decryptable regardless of another title's /// failure. (Regression for the audit r5 css_error-over-valid-key finding.) #[test] fn ensure_title_decryptable_valid_key_ignores_disc_wide_css_error() { let mut disc = css_dvd_with_extents(vec![Extent { start_lba: 100, sector_count: 8, }]); disc.css_error = Some(crate::error::Error::CssKeyMissing); // main feature failed let keys = crate::decrypt::DecryptKeys::Css { title_key: [0x42; 5], // this bonus title cracked its own key }; assert!( disc.ensure_title_decryptable(false, &keys, false).is_ok(), "a title with its own valid CSS key must pass despite disc-wide css_error" ); } /// 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); } // (The former `coding_type_a2_is_dts_hd_ma` asserted the DEFECT — that // BD-ROM Part 3 code 0xA2 is lossless Master Audio. It is the lossy // secondary stream; see `secondary_dts_hd_0xa2_is_lossy_not_master_audio`, // which now covers both 0xA2 and the 0x86 primary.) /// 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"); } // ── correct_truehd_channels ────────────────────────────────────────── /// Records every `read_sectors` call and serves a fixed byte buffer /// (zero-padded to the requested size) — probes /// `correct_truehd_channels`'s early-return guards (empty pid list, `n == /// 0`) without needing real TrueHD content, and carries real synthetic /// TrueHD bytes for the full round-trip tests below. struct ThdSpyReader { calls: std::cell::RefCell>, data: Vec, } impl SectorSource for ThdSpyReader { fn read_sectors( &mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool, ) -> Result { self.calls.borrow_mut().push((lba, count)); let n = self.data.len().min(buf.len()); buf[..n].copy_from_slice(&self.data[..n]); for b in buf[n..].iter_mut() { *b = 0; } Ok(buf.len()) } } /// One 192-byte BD-TS PES packet on `pid` carrying `es` as its raw /// elementary payload. Minimal PES header (no PTS/DTS) — this probe /// reads and demuxes+flushes in one shot, so no timestamp is needed. fn thd_bd_pes(pid: u16, es: &[u8]) -> Vec { let mut pkt = vec![0u8; 192]; pkt[4] = 0x47; // TS sync pkt[5] = 0x40 | ((pid >> 8) & 0x1F) as u8; // PUSI + PID hi pkt[6] = (pid & 0xFF) as u8; // PID lo pkt[7] = 0x10; // adaptation = payload-only, cc = 0 let p = 8; pkt[p] = 0x00; pkt[p + 1] = 0x00; pkt[p + 2] = 0x01; pkt[p + 3] = 0xBD; // private_stream_1 pkt[p + 4] = 0x00; pkt[p + 5] = 0x00; pkt[p + 6] = 0x80; // flags1 marker bits pkt[p + 7] = 0x00; // flags2: no PTS/DTS pkt[p + 8] = 0x00; // PES_header_data_length = 0 let es_off = p + 9; let n = es.len().min(192 - es_off); pkt[es_off..es_off + n].copy_from_slice(&es[..n]); pkt } /// A synthetic TrueHD major-sync access unit: 2 junk bytes, the /// 0xF8726FBA sync, `format_info`, then padding through the /// num_substreams byte (sync offset + 16) so Atmos detection can read it. fn thd_major_sync_es(format_info: u32, num_substreams: u8) -> Vec { let mut es = vec![0u8; 24]; es[0] = 0xAA; es[1] = 0xBB; es[2..6].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); es[6..10].copy_from_slice(&format_info.to_be_bytes()); es[2 + 16] = num_substreams << 4; es } fn truehd_audio_stream(pid: u16, channels: AudioChannels, sample_rate: SampleRate) -> Stream { Stream::Audio(AudioStream { pid, codec: Codec::TrueHd, channels, language: "eng".into(), sample_rate, secondary: false, purpose: LabelPurpose::Normal, label: crate::labels::generate_audio_label(&Codec::TrueHd, &channels, false), }) } /// No TrueHd stream in the title → the pid list is empty and the probe /// must return before ever touching the reader (extent/read-count guards /// are irrelevant once there's nothing to probe for). Mutation guard: /// `matches!(a.codec, Codec::TrueHd)` flipped to `true` would sweep this /// title's non-TrueHD stream's pid into the probe list too, and it would /// read the (spied) source. #[test] fn correct_truehd_channels_skips_probe_when_no_truehd_stream() { let mut title = DiscTitle::empty(); title.streams = vec![Stream::Audio(AudioStream { pid: 0x1100, codec: Codec::Ac3, channels: AudioChannels::Surround51, language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, purpose: LabelPurpose::Normal, label: String::new(), })]; title.extents = vec![Extent { start_lba: 0, sector_count: 10, }]; let mut reader = ThdSpyReader { calls: std::cell::RefCell::new(Vec::new()), data: Vec::new(), }; correct_truehd_channels(&mut reader, &mut title); assert!( reader.calls.borrow().is_empty(), "no TrueHD stream present → the reader must never be touched: {:?}", reader.calls.borrow() ); } /// A TrueHd stream IS present → the probe must proceed past the pid-list /// guard and actually read the title's first extent. Mutation guard: /// `matches!(a.codec, Codec::TrueHd)` flipped to `false` would empty the /// pid list even here and the probe would return before ever reading. #[test] fn correct_truehd_channels_reads_when_truehd_stream_present() { let mut title = DiscTitle::empty(); title.streams = vec![truehd_audio_stream( 0x1100, AudioChannels::Surround51, SampleRate::S48, )]; title.extents = vec![Extent { start_lba: 7, sector_count: 10, }]; let mut reader = ThdSpyReader { calls: std::cell::RefCell::new(Vec::new()), data: Vec::new(), }; correct_truehd_channels(&mut reader, &mut title); assert!( !reader.calls.borrow().is_empty(), "a TrueHD stream present must drive a probe read" ); } /// The bounded-probe sector count is `ext.sector_count.min(4096)`; when the /// extent has ZERO sectors that count is zero and there is nothing to read /// — the probe must return before calling into the reader. Mutation guard: /// `n == 0` flipped to `n != 0` inverts this so a zero-sector extent /// wrongly falls through to a (zero-length) read. #[test] fn correct_truehd_channels_skips_read_on_zero_sector_extent() { let mut title = DiscTitle::empty(); title.streams = vec![truehd_audio_stream( 0x1100, AudioChannels::Surround51, SampleRate::S48, )]; title.extents = vec![Extent { start_lba: 7, sector_count: 0, }]; let mut reader = ThdSpyReader { calls: std::cell::RefCell::new(Vec::new()), data: Vec::new(), }; correct_truehd_channels(&mut reader, &mut title); assert!( reader.calls.borrow().is_empty(), "a zero-sector extent must never trigger a read: {:?}", reader.calls.borrow() ); } /// Full round-trip: a real major sync carrying a 7.1 (8ch) presentation, a /// whitelisted 96 kHz rate nibble, and an Atmos substream count, probed /// through a container-declared 5.1/48 kHz basic-descriptor stream. All /// three corrections must land, and the label must be promoted to the /// Atmos form (the stream still carried the basic, non-editorial label). /// Kills the was_basic `==`, the channels/rate `!=`/`&&` guards' "already /// correct" branch, the `!matches!` per-stream skip, the `is_atmos == /// Some(true)` branch, and the whole-function no-op mutant. #[test] fn correct_truehd_channels_full_correction_and_atmos_promotion() { let pid = 0x1100u16; // format_info: top nibble 0x1 -> 96 kHz; low 13 bits 0x1F -> 7.1 (8ch). let format_info = (0x1u32 << 28) | 0x1F; let es = thd_major_sync_es(format_info, 4); // num_substreams=4 -> Atmos let ts = thd_bd_pes(pid, &es); let mut title = DiscTitle::empty(); title.streams = vec![truehd_audio_stream( pid, AudioChannels::Surround51, // base 5.1 the MPLS descriptor understates SampleRate::S48, // base 48 kHz the container guessed )]; title.extents = vec![Extent { start_lba: 0, sector_count: 1, }]; let mut reader = ThdSpyReader { calls: std::cell::RefCell::new(Vec::new()), data: ts, }; correct_truehd_channels(&mut reader, &mut title); let Stream::Audio(a) = &title.streams[0] else { panic!("stream type must be preserved") }; assert_eq!( a.channels, AudioChannels::Surround71, "the 8ch major-sync presentation must correct the understated 5.1" ); assert_eq!( a.sample_rate, SampleRate::S96, "the whitelisted 0x1 rate nibble must correct the guessed 48 kHz" ); assert_eq!( a.label, crate::labels::generate_audio_label_atmos( &Codec::TrueHd, &AudioChannels::Surround71, false ), "basic descriptor + detected Atmos substream must promote the label" ); } /// A major sync whose 8ch/6ch presentation masks are BOTH set to values /// with no real channel-count meaning (all 13 8ch bits, summing to 20) — /// `AudioChannels::from_count` maps that to `Unknown`. The correction must /// leave the container's channel count untouched rather than overwrite a /// known-good value with `Unknown`. Kills the `new_ch != Unknown` guard's /// `==` and `&&`-to-`||` mutants (both would let an unmapped count /// clobber a valid `a.channels`). #[test] fn correct_truehd_channels_leaves_channels_when_count_unmapped() { let pid = 0x1100u16; // All 13 8ch bits set -> truehd_channels sums to 20 -> from_count(20) // -> Unknown. Rate nibble 0x0 -> 48 kHz (matches the container, so // this test isolates the channels guard from the rate guard). let format_info = 0x1FFF; let es = thd_major_sync_es(format_info, 0); // not Atmos let ts = thd_bd_pes(pid, &es); let mut title = DiscTitle::empty(); title.streams = vec![truehd_audio_stream( pid, AudioChannels::Surround51, SampleRate::S48, )]; title.extents = vec![Extent { start_lba: 0, sector_count: 1, }]; let mut reader = ThdSpyReader { calls: std::cell::RefCell::new(Vec::new()), data: ts, }; correct_truehd_channels(&mut reader, &mut title); let Stream::Audio(a) = &title.streams[0] else { panic!("stream type must be preserved") }; assert_eq!( a.channels, AudioChannels::Surround51, "an unmapped (Unknown) major-sync channel count must not overwrite a known container value" ); } // ── bytes_bad_in_title: empty-input guard ──────────────────────────── // NOTE: `bad_ranges.is_empty() || title.extents.is_empty()` (mod.rs:628) — // the `||`-to-`&&` mutant is EQUIVALENT here, not tested: the guard is a // pure short-circuit. Whichever operand is empty, the corresponding loop // (the outer `for ext in &title.extents` or the inner `for (pos, size) in // bad_ranges`) simply iterates zero times and `total` stays its initial // 0 — the early return changes nothing observable. See report. // ── byte_offset_in_title ────────────────────────────────────────────── fn title_with_size(size_bytes: u64, extents: Vec) -> DiscTitle { DiscTitle { size_bytes, extents, ..DiscTitle::empty() } } /// A multi-extent title where the target LBA lands in the SECOND extent. /// Exercises both the boundary check for the FIRST (non-matching) extent /// and the running `cumulative` byte total added on the way past it. /// Kills: `lba >= start && lba < end` flipped to `||` (the first extent's /// disjunction would trivially match almost any lba and return the wrong, /// too-early offset); `cumulative +=` flipped to `*=` (cumulative is /// seeded at 0, so `*=` freezes it at 0 forever); and `sector_count * /// SECTOR_BYTES_U64` flipped to `+` or `/` (wrong per-extent byte length /// folded into cumulative). #[test] fn byte_offset_in_title_accumulates_across_extents() { let title = title_with_size( 0, vec![ Extent { start_lba: 100, sector_count: 10, }, // LBAs 100..110, 20_480 bytes Extent { start_lba: 200, sector_count: 10, }, // LBAs 200..210 ], ); // lba 205 is 5 sectors into the SECOND extent. let got = byte_offset_in_title(205, &title); assert_eq!( got, Some(20_480 + 5 * 2048), "offset must be the first extent's full byte length plus the \ position within the second extent, not a first-extent mismatch" ); } /// An extent's end is EXCLUSIVE (`start_lba + sector_count`): the LBA one /// past the last sector of an extent belongs to no extent (or the next /// one), never this one. Kills `lba < ext_end` flipped to `<=`. #[test] fn byte_offset_in_title_extent_end_is_exclusive() { let title = title_with_size( 0, vec![Extent { start_lba: 100, sector_count: 10, // covers LBAs 100..110 }], ); assert_eq!( byte_offset_in_title(110, &title), None, "LBA 110 is one past this extent's last sector (109) and must not resolve inside it" ); assert_eq!( byte_offset_in_title(109, &title), Some(9 * 2048), "sanity: the extent's actual last sector still resolves" ); } // ── chapter_at_offset ───────────────────────────────────────────────── fn three_chapters() -> Vec { vec![ Chapter { time_secs: 0.0, name: "1".into(), }, Chapter { time_secs: 50.0, name: "2".into(), }, Chapter { time_secs: 100.0, name: "3".into(), }, ] } /// Concrete end-to-end arithmetic check: byte_offset 60/100 of a 100s /// title lands at t=60s, which is chapter index 1 (0-based, the last /// chapter whose start <= 60) → 1-based chapter 2. Kills: the `/` /// (time-fraction) flipped to `%` or `*`; the `*` (duration scale) /// flipped to `+`; the `<=` chapter-scan comparison flipped to `>`; the /// final `chapter_idx + 1` flipped to `-` or `*`; and every /// whole-function fixed-tuple replacement (none produce `(2, 60.0)`). #[test] fn chapter_at_offset_concrete_arithmetic() { let chapters = three_chapters(); let got = chapter_at_offset(&chapters, 60, 100.0, 100); assert_eq!( got, Some((2, 60.0)), "byte 60/100 of a 100s title = t=60s = chapter 2 (1-based)" ); } /// `total_bytes == 0` must short-circuit to `None` (no title size to /// compute a fraction against) regardless of whether chapters exist. /// Kills `total_bytes == 0` flipped to `!=`, and (combined with the next /// test) the `||` flipped to `&&`. #[test] fn chapter_at_offset_zero_total_bytes_is_none() { let chapters = three_chapters(); assert_eq!( chapter_at_offset(&chapters, 10, 100.0, 0), None, "a title with no declared size has no byte-fraction to place a chapter at" ); } /// No chapters declared → `None`, even with a perfectly valid nonzero /// title size. Kills the `||` flipped to `&&` (which would let this case /// fall through the guard and return a bogus `Some((1, ..))` from the /// then-empty scan loop). #[test] fn chapter_at_offset_no_chapters_is_none() { assert_eq!( chapter_at_offset(&[], 10, 100.0, 100), None, "a title with no chapters has nothing to report a chapter index against" ); } // ── range_chapter ───────────────────────────────────────────────────── fn title_for_range_chapter() -> DiscTitle { DiscTitle { duration_secs: 100.0, size_bytes: 204_800, // 100 sectors * 2048 chapters: three_chapters(), extents: vec![Extent { start_lba: 1_000, sector_count: 100, }], ..DiscTitle::empty() } } /// Concrete positive case chaining `byte_offset_in_title` + /// `chapter_at_offset`: lba 1060 is 60 sectors (122_880 bytes) into the /// extent, 60% of the 204_800-byte title → t=60s → chapter 2. This exact /// non-default tuple kills every fixed-tuple whole-function replacement /// mutant (`(None, None)`, `(Some(0), ..)`, `(Some(1), ..)`, etc. — none /// equal `(Some(2), Some(60.0))`). #[test] fn range_chapter_concrete_positive_case() { let title = title_for_range_chapter(); assert_eq!(range_chapter(1_060, &title), (Some(2), Some(60.0))); } /// An LBA outside every extent resolves to `(None, None)`. #[test] fn range_chapter_outside_extents_is_none() { let title = title_for_range_chapter(); assert_eq!(range_chapter(5_000, &title), (None, None)); } // ── locate_ranges ───────────────────────────────────────────────────── /// Isolates the per-range `lba`/`count` sector-arithmetic from every /// bps-dependent branch (duration_secs is negative, so `bps` is 0.0 /// under both the real `>` and any surviving `>=`/`==`/`<` mutant at that /// same guard). Non-power-coincidental `pos`/`size` so `/` vs `%` vs `*` /// all disagree with the expected quotient. Kills `pos / SECTOR_BYTES_U64` /// and `size / SECTOR_BYTES_U64` each flipped to `%` or `*`. #[test] fn locate_ranges_lba_and_count_are_sector_quotients() { let title = title_with_size(0, vec![]); let mut title = title; title.duration_secs = -1.0; let result = locate_ranges(&[(5_000, 6_000)], &title); assert_eq!(result.ranges.len(), 1); assert_eq!(result.ranges[0].lba, 2, "5000 / 2048 = 2"); assert_eq!(result.ranges[0].count, 2, "6000 / 2048 = 2"); } /// Concrete positive-`bps` arithmetic for both `duration_ms` (per-range) /// and `main_at_risk_ms` (title-wide): bps = 204_800 B / 100 s = 2048 /// B/s exactly. A 4096-byte range = 2 sectors = 2000 ms at that rate, /// and it's entirely inside the title's only extent so all of it counts /// toward `main_at_risk_ms` too. Kills `bps > 0.0` flipped to `==`/`<` /// (both would take the `else 0.0` branch here, wrongly reporting 0); /// `(*size as f64) / bps` flipped to `%` or `*`; and the trailing /// `* MILLIS_PER_SEC` flipped to `+` or `/`. #[test] fn locate_ranges_positive_bps_duration_and_at_risk() { let title = title_with_size( 204_800, vec![Extent { start_lba: 0, sector_count: 100, }], ); let mut title = title; title.duration_secs = 100.0; let result = locate_ranges(&[(0, 4096)], &title); assert_eq!(result.ranges.len(), 1); assert_eq!( result.ranges[0].duration_ms, 2000.0, "4096 B / 2048 B/s * 1000 = 2000 ms" ); assert_eq!(result.largest_gap_ms, 2000.0); assert_eq!( result.main_at_risk_ms, 2000.0, "the range is entirely inside the title's extent" ); } /// `bps` computed exactly `0.0` (duration_secs == 0.0): both the /// per-range `duration_ms` and title-wide `main_at_risk_ms` must stay /// `0.0`, never a divide-by-zero `inf`/`NaN`. Kills `bps > 0.0` flipped /// to `>=` at BOTH sites (mod.rs:743 and mod.rs:768) — with the boundary /// exactly zero, `>=` wrongly takes the division branch and produces /// `inf` instead of the real code's `0.0`. #[test] fn locate_ranges_zero_bps_stays_zero_not_infinite() { let title = title_with_size( 204_800, vec![Extent { start_lba: 0, sector_count: 100, }], ); // duration_secs left at DiscTitle::empty()'s default 0.0. let result = locate_ranges(&[(0, 4096)], &title); assert_eq!(result.ranges[0].duration_ms, 0.0); assert_eq!(result.main_at_risk_ms, 0.0); } // NOTE: mod.rs:732 (`title.duration_secs > 0.0` seeding `bps`) — the // `>`-to-`>=` mutant is EQUIVALENT. `bps` is only ever consumed behind // its own `bps > 0.0` re-check at both use sites (mod.rs:743, 768); at // the exact boundary (`duration_secs == 0.0`) the mutant instead computes // `bps = size_bytes / 0.0` (`inf` or `NaN` since `size_bytes >= 0`), and // `inf > 0.0` / `NaN > 0.0` are both `false` at the re-check — so the // final output (`0.0` via the `else` branch) is identical either way. No // reachable input makes this observable. // ── Codec::name / Display ──────────────────────────────────────────── /// `name()` is a linear lookup keyed by `==` against `ALL_CODECS`; picking /// a codec that is NOT the first table entry means a mutated `==`→`!=` /// returns the (wrong) first entry's name instead. `Unknown` isn't in the /// table at all, exercising the post-loop fallback. #[test] fn codec_name_lookup_and_unknown_fallback() { assert_eq!(Codec::Hevc.name(), "HEVC"); assert_eq!(Codec::TrueHd.name(), "TrueHD"); assert_eq!( Codec::Unknown(0xAB).name(), "Unknown", "a coding type outside the table falls back to the literal \"Unknown\"" ); } /// `Display` must forward to `name()`, not silently emit nothing. #[test] fn codec_display_forwards_to_name() { assert_eq!(format!("{}", Codec::TrueHd), "TrueHD"); } // ── Resolution::is_sd / from_height ────────────────────────────────── #[test] fn resolution_is_sd_matches_sd_variants_only() { assert!(Resolution::R480i.is_sd()); assert!(Resolution::R480p.is_sd()); assert!(Resolution::R576i.is_sd()); assert!(Resolution::R576p.is_sd()); assert!(!Resolution::R720p.is_sd()); assert!(!Resolution::R1080p.is_sd()); assert!(!Resolution::Unknown.is_sd()); } /// Every from_height bucket boundary — deleting any one match arm makes /// its heights fall through to the NEXT surviving arm (a strictly /// different variant), so each of these pairs (top of one bucket, bottom /// of the next) pins the arm to its own boundary. #[test] fn resolution_from_height_bucket_boundaries() { assert_eq!(Resolution::from_height(0), Resolution::R480p); assert_eq!(Resolution::from_height(480), Resolution::R480p); assert_eq!(Resolution::from_height(481), Resolution::R576p); assert_eq!(Resolution::from_height(576), Resolution::R576p); assert_eq!(Resolution::from_height(577), Resolution::R720p); assert_eq!(Resolution::from_height(720), Resolution::R720p); assert_eq!(Resolution::from_height(721), Resolution::R1080p); assert_eq!(Resolution::from_height(1080), Resolution::R1080p); assert_eq!(Resolution::from_height(1081), Resolution::R2160p); assert_eq!(Resolution::from_height(2160), Resolution::R2160p); assert_eq!(Resolution::from_height(2161), Resolution::R4320p); } // ── AudioChannels::from_count ───────────────────────────────────────── /// Every mapped count 1..=8, plus an out-of-range fallback. Deleting any /// one match arm makes that count fall through to `_ => Unknown`. #[test] fn audio_channels_from_count_every_mapped_value() { assert_eq!(AudioChannels::from_count(1), AudioChannels::Mono); assert_eq!(AudioChannels::from_count(2), AudioChannels::Stereo); assert_eq!(AudioChannels::from_count(3), AudioChannels::Stereo21); assert_eq!(AudioChannels::from_count(4), AudioChannels::Quad); assert_eq!(AudioChannels::from_count(5), AudioChannels::Surround50); assert_eq!(AudioChannels::from_count(6), AudioChannels::Surround51); assert_eq!(AudioChannels::from_count(7), AudioChannels::Surround61); assert_eq!(AudioChannels::from_count(8), AudioChannels::Surround71); assert_eq!(AudioChannels::from_count(0), AudioChannels::Unknown); assert_eq!(AudioChannels::from_count(9), AudioChannels::Unknown); } // ── SampleRate::from_hz ─────────────────────────────────────────────── /// Every rate the enum can represent, expressed in Hz. Deleting any one /// match arm sends that rate to `_ => Unknown`, so a rate-by-rate check /// pins each arm independently. The two combo rates (`S48_96`, /// `S48_192`) deliberately have no Hz spelling — `hz()` collapses them /// onto 48000, so 48000 must map back to the plain `S48` and nothing else. #[test] fn sample_rate_from_hz_every_mapped_rate() { assert_eq!(SampleRate::from_hz(44_100), SampleRate::S44_1); assert_eq!(SampleRate::from_hz(48_000), SampleRate::S48); assert_eq!(SampleRate::from_hz(88_200), SampleRate::S88_2); assert_eq!(SampleRate::from_hz(96_000), SampleRate::S96); assert_eq!(SampleRate::from_hz(176_400), SampleRate::S176_4); assert_eq!(SampleRate::from_hz(192_000), SampleRate::S192); assert_eq!(SampleRate::from_hz(0), SampleRate::Unknown); assert_eq!(SampleRate::from_hz(32_000), SampleRate::Unknown); } /// `from_hz` must invert `hz()` for every rate that has a single Hz /// value (i.e. all but the two combo rates, whose `hz()` reports their /// primary 48 kHz). Round-tripping rather than restating the table keeps /// this honest if a rate is ever added. #[test] fn sample_rate_from_hz_inverts_hz_for_single_rate_variants() { for r in [ SampleRate::S44_1, SampleRate::S48, SampleRate::S88_2, SampleRate::S96, SampleRate::S176_4, SampleRate::S192, ] { assert_eq!( SampleRate::from_hz(r.hz() as u32), r, "from_hz must round-trip {r:?}" ); } } // ── HdrFormat / ColorSpace: name, Display, FromStr ──────────────────── /// `Display` must forward to `name()`, not emit an empty string: these /// strings reach Matroska track names and the JSON sink, where a blank /// HDR field is indistinguishable from "no HDR metadata". #[test] fn hdr_format_display_forwards_to_name() { assert_eq!(format!("{}", HdrFormat::Hdr10Plus), "HDR10+"); assert_eq!(format!("{}", HdrFormat::DolbyVision), "Dolby Vision"); assert_eq!(format!("{}", HdrFormat::Hlg), HdrFormat::Hlg.name()); } /// `FromStr` accepts the human display names as well as the compact ids, /// via a second linear scan keyed on `name(v) == s`. Every probe here is /// a display name that is NOT its own id, so it can only be resolved by /// that second scan — and none of them is the first table entry, so a /// scan whose comparison is inverted returns the wrong (first) variant /// rather than the right one. #[test] fn hdr_format_from_str_resolves_display_names() { assert_eq!( "Dolby Vision".parse::(), Ok(HdrFormat::DolbyVision) ); assert_eq!("HDR10+".parse::(), Ok(HdrFormat::Hdr10Plus)); assert_eq!("HLG".parse::(), Ok(HdrFormat::Hlg)); // An unrecognised string is an error, never a silent SDR. assert_eq!("not-an-hdr-format".parse::(), Err(())); } /// `ColorSpace::name` is the ITU-R designation used in track metadata. /// `Unknown` is the one variant with no designation: it names the empty /// string so nothing prints a fabricated colour space. #[test] fn color_space_name_is_the_itu_designation() { assert_eq!(ColorSpace::Bt709.name(), "BT.709"); assert_eq!(ColorSpace::Bt2020.name(), "BT.2020"); assert_eq!(ColorSpace::Bt470bg.name(), "BT.470BG"); assert_eq!(ColorSpace::Smpte170m.name(), "SMPTE 170M"); assert!(ColorSpace::Unknown.name().is_empty()); } /// `Display` must forward to `name()`. #[test] fn color_space_display_forwards_to_name() { assert_eq!(format!("{}", ColorSpace::Bt2020), "BT.2020"); assert_eq!( format!("{}", ColorSpace::Smpte170m), ColorSpace::Smpte170m.name() ); } /// Same second-scan property as `HdrFormat`: display names resolve, and /// they resolve to THEIR OWN variant. `ColorSpace` has no error case — an /// unrecognised string is `Unknown`, not `Err`. #[test] fn color_space_from_str_resolves_display_names() { assert_eq!("BT.2020".parse::(), Ok(ColorSpace::Bt2020)); assert_eq!("BT.470BG".parse::(), Ok(ColorSpace::Bt470bg)); assert_eq!( "SMPTE 170M".parse::(), Ok(ColorSpace::Smpte170m) ); assert_eq!("bt2020".parse::(), Ok(ColorSpace::Bt2020)); assert_eq!("nonsense".parse::(), Ok(ColorSpace::Unknown)); } // ── DiscTitle stream filters ────────────────────────────────────────── /// A title whose stream list interleaves all three kinds. Each accessor /// must yield exactly its own kind, in declared order — an accessor that /// yields nothing (or drops its match arm) would leave stream selection /// and the info panel with no tracks at all. #[test] fn disc_title_stream_filters_select_their_own_kind_in_order() { let mut title = DiscTitle::empty(); title.streams = vec![ Stream::Subtitle(SubtitleStream { pid: 0x1200, codec: Codec::Pgs, language: "eng".into(), forced: false, qualifier: LabelQualifier::None, codec_data: None, }), Stream::Video(VideoStream { pid: 0x1011, codec: Codec::Hevc, resolution: Resolution::R2160p, frame_rate: FrameRate::F23_976, hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt2020, display_aspect: None, secondary: false, label: String::new(), measured_cicp: None, }), Stream::Audio(AudioStream { pid: 0x1100, codec: Codec::TrueHd, channels: AudioChannels::Surround71, language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, purpose: LabelPurpose::Normal, label: String::new(), }), Stream::Audio(AudioStream { pid: 0x1101, codec: Codec::Ac3, channels: AudioChannels::Stereo, language: "fra".into(), sample_rate: SampleRate::S48, secondary: true, purpose: LabelPurpose::Commentary, label: String::new(), }), // Blu-ray 3D dependent view: a second video stream. Stream::Video(VideoStream { pid: 0x1012, codec: Codec::H264, resolution: Resolution::R1080p, frame_rate: FrameRate::F23_976, hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, display_aspect: None, secondary: true, label: String::new(), measured_cicp: None, }), ]; let audio: Vec = title.audio_streams().map(|a| a.pid).collect(); assert_eq!( audio, vec![0x1100, 0x1101], "audio_streams must yield both audio PIDs in declared order" ); let subs: Vec = title.subtitle_streams().map(|s| s.pid).collect(); assert_eq!(subs, vec![0x1200]); let video: Vec = title.video_streams().map(|v| v.pid).collect(); assert_eq!( video, vec![0x1011, 0x1012], "video_streams must yield the base view then the dependent view" ); // The three filters partition the stream list: nothing is dropped and // nothing is counted twice. assert_eq!(audio.len() + subs.len() + video.len(), title.streams.len()); // Each accessor's payload is the real stream, not a placeholder. assert_eq!( title.audio_streams().next().unwrap().channels, AudioChannels::Surround71 ); assert_eq!( title.video_streams().next().unwrap().resolution, Resolution::R2160p ); assert_eq!(title.subtitle_streams().next().unwrap().language, "eng"); } // ── DiscId::name ────────────────────────────────────────────────────── /// The disc's best available name: the META/DL `bdmt_*.xml` title when the /// disc carries one, otherwise the UDF Volume Identifier. Never a constant /// and never empty when either source has content — this string names the /// output file and the rip's directory. #[test] fn disc_id_name_prefers_meta_title_then_volume_id() { let with_meta = DiscId { volume_id: "SAMPLE_FILM".to_string(), meta_title: Some("Sample Film".to_string()), format: DiscFormat::BluRay, capacity_sectors: 0, encrypted: false, layers: 1, }; assert_eq!(with_meta.name(), with_meta.meta_title.as_deref().unwrap()); let without_meta = DiscId { meta_title: None, ..with_meta }; assert_eq!(without_meta.name(), without_meta.volume_id); } // ── canonical_title_order: the capacity gate is STRICTLY greater-than ── /// The "physically possible on this disc" gate is `size_bytes <= /// capacity_bytes` (see [`Disc::canonical_title_order`]'s contract: /// *"Real titles (`size_bytes ≤ capacity_bytes`) before virtual /// composites"*). A title whose declared size EXACTLY equals the disc /// capacity fits — a full-disc single-layer authoring, no double-counted /// clips — so it is a REAL title and must outrank the oversize composite, /// never be demoted alongside it. /// /// Asserted on the comparator directly (both argument orders), not on a /// sort: an inconsistent comparator produces an implementation-defined /// permutation, which would make a sort-based assertion prove nothing. #[test] fn canonical_order_capacity_gate_admits_a_title_that_exactly_fills_the_disc() { use std::cmp::Ordering; const CAP: u64 = 50_000_000_000; // Exactly fills the disc — physically possible, therefore real. let exact = title_with("00800.mpls", 7_200.0, CAP, 1); // Twice the disc: cannot exist unless clips are double-counted. let huge = title_with("00020.mpls", 15_000.0, CAP * 2, 253); // A smaller real title. let smaller = title_with("00200.mpls", 3_600.0, CAP / 2, 1); // exact (real) before huge (composite), whichever way round it is asked. assert_eq!( Disc::canonical_title_order(&exact, &huge, CAP), Ordering::Less, "a title that exactly fills the disc is real and outranks the oversize composite" ); assert_eq!( Disc::canonical_title_order(&huge, &exact, CAP), Ordering::Greater, "the oversize composite is demoted behind the exactly-fitting real title" ); // Both real: the LARGER real title wins. `exact` is the larger, so it // must still be treated as real when it is the RIGHT-hand argument. assert_eq!( Disc::canonical_title_order(&smaller, &exact, CAP), Ordering::Greater, "the exactly-fitting title is real on the right-hand side too, and it is larger" ); assert_eq!( Disc::canonical_title_order(&exact, &smaller, CAP), Ordering::Less ); } // ── audio_richness: the same-size / same-duration tiebreak ───────────── /// A title carrying the given audio tracks, with size and duration fixed so /// every comparison below falls through to the audio-richness tiebreak. fn title_with_audio(audio: &[(Codec, AudioChannels)]) -> DiscTitle { DiscTitle { size_bytes: 40_000_000_000, duration_secs: 7_200.0, streams: audio .iter() .enumerate() .map(|(i, &(codec, channels))| { Stream::Audio(AudioStream { pid: 0x1100 + i as u16, codec, channels, language: "eng".into(), sample_rate: SampleRate::S48, secondary: false, purpose: LabelPurpose::Normal, label: String::new(), }) }) .collect(), ..DiscTitle::empty() } } /// Equal-size, equal-duration sibling playlists (the same feature authored /// twice — a full-audio main and an audio-reduced twin) are separated by /// audio richness, ranked `(any lossless, best channel count, track count)` /// with richer first. Each assertion below varies exactly ONE component of /// that key and holds the other two equal, so each component is pinned /// independently; the final pair is identical in all three and must compare /// Equal, so "richer first" is not satisfied by a comparator that simply /// never reports a tie. #[test] fn canonical_order_breaks_equal_size_ties_on_audio_richness() { use std::cmp::Ordering; const CAP: u64 = 50_000_000_000; // (1) lossless beats lossy at the same channel count and track count. let lossless = title_with_audio(&[(Codec::DtsHdMa, AudioChannels::Stereo)]); let lossy = title_with_audio(&[(Codec::Ac3, AudioChannels::Stereo)]); assert_eq!( Disc::canonical_title_order(&lossless, &lossy, CAP), Ordering::Less, "a lossless track outranks a lossy one" ); assert_eq!( Disc::canonical_title_order(&lossy, &lossless, CAP), Ordering::Greater ); // (2) more channels wins when both are lossy and single-track. let surround = title_with_audio(&[(Codec::Ac3, AudioChannels::Surround51)]); assert_eq!( Disc::canonical_title_order(&surround, &lossy, CAP), Ordering::Less, "5.1 outranks stereo at the same losslessness" ); // (3) more tracks wins when losslessness and channel count are equal. let two_tracks = title_with_audio(&[ (Codec::Ac3, AudioChannels::Stereo), (Codec::Ac3, AudioChannels::Stereo), ]); assert_eq!( Disc::canonical_title_order(&two_tracks, &lossy, CAP), Ordering::Less, "the title with more audio tracks is the richer one" ); // (4) identical audio really is a tie. let same = title_with_audio(&[(Codec::Ac3, AudioChannels::Stereo)]); assert_eq!( Disc::canonical_title_order(&same, &lossy, CAP), Ordering::Equal, "identical titles must compare Equal — the tiebreak is a real comparison, not a constant" ); } // ── detect_disc_format: the MKB-less BDMV fallback ──────────────────── /// A BDMV-tree disc with NO readable `/AACS/MKB_RO.inf` (unencrypted, or an /// unreadable MKB) has no AACS generation to classify by, so the format /// falls back to video resolution. Two rules apply there: /// * UHD resolution PROMOTES the disc to [`DiscFormat::Uhd`] — the ECC /// block sweep is sized off this, so losing the promotion mis-sizes it; /// * everything else is clamped UP to [`DiscFormat::BluRay`], because a /// BD-tree disc is never a DVD even when an SD bonus title is scanned /// first. #[test] fn mkb_less_bdmv_disc_is_promoted_to_uhd_by_resolution_and_clamped_up_otherwise() { 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(), // BDMV only — no /AACS, so there is no MKB Type record to read. subdirs: vec![DirSpec { name: "BDMV".into(), icb_lba: 12, dir_data_lba: 13, 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"); let uhd = [title_with_video(Codec::Hevc, Resolution::R2160p)]; assert_eq!( Disc::detect_disc_format(&mut disc, &udf, &uhd), DiscFormat::Uhd, "a 2160p BDMV disc with no MKB is a UHD" ); let hd = [title_with_video(Codec::H264, Resolution::R1080p)]; assert_eq!( Disc::detect_disc_format(&mut disc, &udf, &hd), DiscFormat::BluRay ); let sd = [title_with_video(Codec::Mpeg2, Resolution::R480i)]; assert_eq!( Disc::detect_disc_format(&mut disc, &udf, &sd), DiscFormat::BluRay, "an SD title on a BDMV disc must never downgrade the disc to DVD" ); } // ── encrypted_content_ranges ────────────────────────────────────────── /// The authoritative "which sectors are AACS content" map is the UNION of /// every title's extents, sorted and merged into a disjoint set. Titles /// routinely share clips (a play-all playlist references the feature's /// clips), so the raw per-title extents overlap and arrive in playlist /// order, not LBA order. The fixture below carries all three shapes at /// once — an OVERLAP across two titles, an ADJACENT pair, and a DISJOINT /// region — supplied out of order. #[test] fn encrypted_content_ranges_unions_sorts_and_merges_every_titles_extents() { let mut disc = make_test_disc(200_000, "BD"); let mut feature = DiscTitle::empty(); feature.extents = vec![ext(1_000, 100), ext(5_000, 50)]; let mut play_all = DiscTitle::empty(); // [1050,1250) overlaps the feature's [1000,1100); [1250,1260) is // exactly adjacent to it. play_all.extents = vec![ext(1_050, 200), ext(1_250, 10)]; disc.titles = vec![play_all, feature]; assert_eq!( disc.encrypted_content_ranges(), vec![(1_000, 260), (5_000, 50)], "the encrypted-content map is the merged, disjoint union of every title's extents" ); // No parsed titles => no content gate at all (callers fall back). let unscanned = make_test_disc(200_000, "BD"); assert!( unscanned.encrypted_content_ranges().is_empty(), "a disc with no titles declares no encrypted content" ); } // ── aacs_disc_hash ──────────────────────────────────────────────────── /// The disc hash names the disc in an [`Error::NoDiscKey`] so the caller can /// tell the user which keydb entry to add — it must be the disc's OWN /// captured SHA-1 of `Unit_Key_RO.inf`, in the bare 40-hex form the keydb is /// keyed on (the stored field carries a `0x` prefix). A disc with no AACS /// state has no hash to report, and reports nothing rather than a /// placeholder that would send the user hunting a non-existent entry. #[test] fn aacs_disc_hash_is_the_captured_hash_without_its_0x_prefix() { const SHA1: &str = "0123456789abcdef0123456789abcdef01234567"; let mut disc = make_test_disc(1_000, "UHD"); assert!( disc.aacs_disc_hash().is_empty(), "no AACS state => no disc to name" ); disc.aacs = Some(AacsState { disc_hash: format!("0x{SHA1}"), ..aacs_with(Vec::new()) }); assert_eq!(disc.aacs_disc_hash(), SHA1); // Already bare (no prefix) passes through unchanged, never re-stripped. disc.aacs = Some(AacsState { disc_hash: SHA1.to_string(), ..aacs_with(Vec::new()) }); assert_eq!(disc.aacs_disc_hash(), SHA1); } // ── decrypt_keys_for_title: CSS crack-span reuse is half-open ───────── /// A CSS title key is per-VTS: reusing the scan's cracked key for a title /// that lives OUTSIDE the cracked span descrambles that title with the wrong /// key, and the mux emits garbage at exit 0. `crack_span` is documented as /// the half-open LBA span `[start, end)`, so overlap is /// `extent.start < span.end && span.start < extent.end` — both comparisons /// STRICT. A title that merely ABUTS the span (ends exactly where it begins, /// or begins exactly where it ends) shares no sector with it and must NOT /// reuse the key. /// /// The reader serves only clear (all-zero) sectors, so a title that falls /// through to its own crack is reported unencrypted — distinguishable from /// the reused-key answer both in the key and in the is-clear flag. #[test] fn decrypt_keys_for_title_css_span_reuse_is_half_open() { const KEY: [u8; 5] = [0xA1, 0xB2, 0xC3, 0xD4, 0xE5]; let mut disc = make_test_disc(200_000, "DVD"); disc.format = DiscFormat::Dvd; disc.content_format = ContentFormat::MpegPs; disc.encrypted = true; // Key cracked from sectors [250, 300). disc.css = Some(crate::css::CssState { title_key: KEY, crack_span: Some((250, 300)), }); let mk = |extents: &[(u32, u32)]| { let mut t = title_with_video(Codec::Mpeg2, Resolution::R480p); t.extents = extents .iter() .map(|&(start_lba, sector_count)| Extent { start_lba, sector_count, }) .collect(); t }; disc.titles = vec![ // 0: [200,250) — ends exactly where the span begins. mk(&[(200, 50)]), // 1: [300,350) — begins exactly where the span ends. mk(&[(300, 50)]), // 2: [260,270) — genuinely inside the span. mk(&[(260, 10)]), // 3: no extents at all. mk(&[]), ]; let mut clear = CssMapReader { key: KEY, scrambled: (0, 0), reads: std::cell::RefCell::new(Vec::new()), }; for idx in [0usize, 1] { let (keys, title_is_clear) = disc.decrypt_keys_for_title(idx, &mut clear, 16); assert!( matches!(keys, crate::decrypt::DecryptKeys::None), "title {idx} only ABUTS the crack span — it shares no sector with it, so the \ per-VTS key must not be reused" ); assert!( title_is_clear, "title {idx} re-cracks from its own (clear) extents and is reported unencrypted" ); } let (keys, title_is_clear) = disc.decrypt_keys_for_title(2, &mut clear, 16); match keys { crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( title_key, KEY, "a title INSIDE the crack span reuses the scan's key" ), _ => panic!("expected the reused CSS key for an overlapping title"), } assert!(!title_is_clear); // A title with NO extents has nothing to crack from: it short-circuits // to the disc-wide keys and is marked clear, so the decrypt gate's // "None keys + not clear" rule cannot hard-fail it. let (keys, title_is_clear) = disc.decrypt_keys_for_title(3, &mut clear, 16); match keys { crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(title_key, KEY), _ => panic!("an extent-less title must return the disc-wide keys"), } assert!( title_is_clear, "an extent-less title is clear — nothing scrambled to worry about" ); } // ── mapfile paths ───────────────────────────────────────────────────── /// The mapfile sits BESIDE the output as `.mapfile`: the suffix is /// appended to the whole path, never substituted for the extension (which /// would make `movie.iso` and `movie.mkv` share one mapfile). #[test] fn mapfile_path_for_appends_the_suffix_to_the_whole_output_path() { assert_eq!( mapfile_path_for(std::path::Path::new("/tmp/rip/movie.iso")), std::path::PathBuf::from("/tmp/rip/movie.iso.mapfile") ); assert_eq!( mapfile_path_for(std::path::Path::new("/tmp/rip/movie")), std::path::PathBuf::from("/tmp/rip/movie.mapfile") ); } /// Regular output: `Disc::mapfile_for` is the plain `.mapfile` rule. #[test] fn mapfile_for_regular_output_is_the_output_path_plus_suffix() { let disc = make_test_disc(1_000, "SOME_DISC"); assert_eq!( disc.mapfile_for(std::path::Path::new("/tmp/rip/movie.iso")), std::path::PathBuf::from("/tmp/rip/movie.iso.mapfile") ); } /// `/dev/null` output (the benchmark sink) cannot host a sibling mapfile, so /// the mapfile is named from the disc and placed in the temp dir. The name /// is sanitized to `[A-Za-z0-9-_]` — every other character, including the /// spaces and punctuation that appear in real META/DL titles and the /// non-ASCII ones, becomes `_` — because this string is used verbatim as a /// filename. #[test] fn mapfile_for_dev_null_sanitizes_the_disc_name_into_a_temp_path() { let mut disc = make_test_disc(1_000, "VOLUME_ID"); // Keeps: alphanumeric, '-', '_'. Replaces: space, '!', non-ASCII. disc.meta_title = Some("A-B_c1 d!é".into()); assert_eq!( disc.mapfile_for(std::path::Path::new("/dev/null")), std::env::temp_dir().join("A-B_c1_d__.mapfile") ); // The UDF volume id is the fallback when the disc carries no META/DL // title. disc.meta_title = None; assert_eq!( disc.mapfile_for(std::path::Path::new("/dev/null")), std::env::temp_dir().join("VOLUME_ID.mapfile") ); } }