Thirteen agents triaging src/labels and src/disc died on a saturated
machine, leaving 5,836 insertions across 28 files uncommitted in a
worktree. Recovered by 3-way apply onto twelve commits of drift; zero
conflicts. The diff was archived to freemkv-private first, because a
worktree is not a backup and this one had already nearly been lost.
One production change, and it is the right one: mpls_universal::parse
read every playlist off the disc AND converted the entries to labels in
a single function, so the conversion — stream-type mapping, dedup key,
the dense global counters — could only be reached through a synthetic
UDF image. Extracted to build_labels(&[Playlist]), which unit tests can
drive from already-parsed values. Behaviour-preserving: same iteration
order, same skip-on-error.
Two collisions resolved by hand:
A second mod pass_progress_tests, written independently against the
same survivors as the one committed in c610285. Kept mine — it covers
the distinct-counters case and the Progress blanket impl, which theirs
does not — but theirs had three clamp tests mine lacked: good_pct,
bad_pct and pending_pct also clamp an overshoot, and I had only tested
that for work_pct. Merged those in as one test and proved each of the
three clamps load-bearing by removing them individually.
An unused_parens warning in a new fixture.
Method note, recorded because it cost real time: git apply --3way
STAGES its result, so `git diff` reads empty and the tree looks
untouched. I nearly concluded the patch had silently failed. Worse, the
first attempt piped through `head -20`, so `echo exit=$?` reported
head's status rather than git's — the same mistake this audit has
already documented once. Check the real exit status, and check
--cached, not just the working tree.
6377 lines
268 KiB
Rust
6377 lines
268 KiB
Rust
//! Disc structure -- scan titles, streams, and sector ranges from a Blu-ray disc.
|
||
//!
|
||
//! This is the high-level API for disc content. The CLI calls this,
|
||
//! never parses MPLS/CLPI/UDF directly.
|
||
//!
|
||
//! Usage:
|
||
//! let disc = Disc::scan(&mut session)?;
|
||
//! for title in disc.titles() { ... }
|
||
//! for stream in title.streams() { ... }
|
||
|
||
mod bluray;
|
||
mod dvd;
|
||
pub(crate) mod dvd_audio_probe;
|
||
mod encrypt;
|
||
mod extract;
|
||
mod hddvd;
|
||
pub(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<String>,
|
||
/// Disc format (BD, UHD, DVD)
|
||
pub format: DiscFormat,
|
||
/// Disc capacity in sectors
|
||
pub capacity_sectors: u32,
|
||
/// Disc capacity in bytes
|
||
pub capacity_bytes: u64,
|
||
/// Number of layers (1 = single, 2 = dual)
|
||
pub layers: u8,
|
||
/// Titles sorted by duration (longest first), then playlist name
|
||
pub titles: Vec<DiscTitle>,
|
||
/// Disc region
|
||
pub region: DiscRegion,
|
||
/// AACS state -- None if disc is unencrypted or keys unavailable
|
||
pub aacs: Option<AacsState>,
|
||
/// CSS state -- None if not a CSS-encrypted DVD
|
||
pub css: Option<crate::css::CssState>,
|
||
/// Whether this disc requires decryption (AACS or CSS)
|
||
pub encrypted: bool,
|
||
/// AACS resolution error when `encrypted` is true and `aacs` is None.
|
||
/// Lets callers distinguish "no KEYDB found", "KEYDB failed to parse",
|
||
/// "disc hash not in KEYDB", etc. None when AACS resolution wasn't
|
||
/// attempted (unencrypted disc) or succeeded.
|
||
pub aacs_error: Option<crate::error::Error>,
|
||
/// CSS crack failure: `Some(Error::CssKeyMissing)` when the scan SAW
|
||
/// scrambled sectors but could NOT recover a title key (the
|
||
/// known-plaintext attack found no crackable crib, or the scrambled
|
||
/// region was unreadable). `css` is `None` in that case — but the disc is
|
||
/// genuinely encrypted, so callers MUST surface this hard error rather
|
||
/// than treat `css.is_none()` as "unencrypted" and mux scrambled MPEG as
|
||
/// plaintext garbage. `None` when no scrambled sector was seen (genuinely
|
||
/// unencrypted) or a key was recovered (`css.is_some()`). The CSS analogue
|
||
/// of [`Self::aacs_error`].
|
||
///
|
||
/// 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<crate::error::Error>,
|
||
/// Content format (BD transport stream vs DVD program stream)
|
||
pub content_format: ContentFormat,
|
||
}
|
||
|
||
/// Content format — determines how sectors are interpreted downstream.
|
||
#[derive(Debug, Clone, Copy, PartialEq, 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<BdRegion>),
|
||
/// DVD regions (1-8 or combination)
|
||
Dvd(Vec<u8>),
|
||
}
|
||
|
||
/// Blu-ray region codes.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum BdRegion {
|
||
/// Region A/1 -- Americas, East Asia (Japan, Korea, Southeast Asia)
|
||
A,
|
||
/// Region B/2 -- Europe, Africa, Australia, Middle East
|
||
B,
|
||
/// Region C/3 -- Central/South Asia, China, Russia
|
||
C,
|
||
}
|
||
|
||
/// A title (one MPLS playlist).
|
||
#[derive(Debug, Clone)]
|
||
pub struct DiscTitle {
|
||
/// Playlist filename (e.g. "00800.mpls")
|
||
pub playlist: String,
|
||
/// Playlist number (e.g. 800)
|
||
pub playlist_id: u16,
|
||
/// Duration in seconds
|
||
pub duration_secs: f64,
|
||
/// Total size in bytes
|
||
pub size_bytes: u64,
|
||
/// Clip references in playback order
|
||
pub clips: Vec<Clip>,
|
||
/// All streams (video, audio, subtitle, etc.)
|
||
pub streams: Vec<Stream>,
|
||
/// Chapter points
|
||
pub chapters: Vec<Chapter>,
|
||
/// Sector extents for ripping (clip LBA ranges)
|
||
pub extents: Vec<Extent>,
|
||
/// Content format for this title
|
||
pub content_format: ContentFormat,
|
||
/// Codec initialization data per stream (SPS/PPS, etc).
|
||
/// Index matches `streams`. None for streams without codec init data.
|
||
pub codec_privates: Vec<Option<Vec<u8>>>,
|
||
}
|
||
|
||
/// A clip reference within a title.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Clip {
|
||
/// Clip filename without extension (e.g. "00001")
|
||
pub clip_id: String,
|
||
/// In-time in 45kHz ticks
|
||
pub in_time: u32,
|
||
/// Out-time in 45kHz ticks
|
||
pub out_time: u32,
|
||
/// Duration in seconds
|
||
pub duration_secs: f64,
|
||
/// Source packet count (from CLPI, 0 if unavailable)
|
||
pub source_packets: u32,
|
||
}
|
||
|
||
/// A stream within a title.
|
||
#[derive(Debug, Clone)]
|
||
pub enum Stream {
|
||
Video(VideoStream),
|
||
Audio(AudioStream),
|
||
Subtitle(SubtitleStream),
|
||
}
|
||
|
||
/// A video stream.
|
||
#[derive(Debug, Clone)]
|
||
pub struct VideoStream {
|
||
/// MPEG-TS packet ID
|
||
pub pid: u16,
|
||
/// Codec (HEVC, H.264, VC-1, MPEG-2)
|
||
pub codec: Codec,
|
||
/// Resolution
|
||
pub resolution: Resolution,
|
||
/// Frame rate
|
||
pub frame_rate: FrameRate,
|
||
/// HDR format
|
||
pub hdr: HdrFormat,
|
||
/// Color space
|
||
pub color_space: ColorSpace,
|
||
/// Intended display aspect ratio as `(num, den)` when the coded pixels are
|
||
/// **anamorphic** (display shape ≠ pixel grid) — e.g. DVD 720x576 shown as
|
||
/// 16:9 → `Some((16, 9))`. `None` means square pixels: the display aspect
|
||
/// equals the pixel dimensions (HD/UHD, BD). Consumed by the MKV muxer to
|
||
/// write DisplayWidth/DisplayHeight; passthrough muxers (TS/M2TS) ignore it
|
||
/// because the aspect already lives in the elementary stream.
|
||
pub display_aspect: Option<(u32, u32)>,
|
||
/// Whether this is a secondary stream (PiP, Dolby Vision EL)
|
||
pub secondary: bool,
|
||
/// Extra label (e.g. "Dolby Vision EL")
|
||
pub label: String,
|
||
/// CICP colour signalling (matrix, transfer, primaries, full_range) MEASURED
|
||
/// from the bitstream — HEVC/H.264 VUI `colour_description` or MPEG-2
|
||
/// `sequence_display_extension`. `Some(...)` takes precedence over the
|
||
/// coarse `color_space` enum (a playlist nibble / PAL-NTSC guess); `None`
|
||
/// means the bitstream did not state it, so the enum-derived triplet is used.
|
||
/// Codes are ITU-T H.273 (CICP); `range` is 1 = limited/TV, 2 = full.
|
||
pub measured_cicp: Option<MeasuredCicp>,
|
||
}
|
||
|
||
/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
|
||
/// view** — the paired substream of the AVC base view. Set by the BD scan
|
||
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
|
||
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
|
||
/// per-frame `BlockAdditional`). Single source of truth for the contract.
|
||
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";
|
||
|
||
impl VideoStream {
|
||
/// Whether this video stream is the MVC dependent (right-eye) view — the
|
||
/// 3D substream that the muxer merges into the base track as a per-frame
|
||
/// `BlockAdditional` rather than emitting as an independent track.
|
||
pub fn is_mvc_dependent(&self) -> bool {
|
||
self.label == MVC_DEPENDENT_LABEL
|
||
}
|
||
}
|
||
|
||
/// Measured CICP colour signalling read directly from a video elementary stream
|
||
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct MeasuredCicp {
|
||
/// MatrixCoefficients (ITU-T H.273 Table 4).
|
||
pub matrix: u8,
|
||
/// TransferCharacteristics (ITU-T H.273 Table 3).
|
||
pub transfer: u8,
|
||
/// ColourPrimaries (ITU-T H.273 Table 2).
|
||
pub primaries: u8,
|
||
/// Range: 1 = limited (studio/TV), 2 = full. Matroska Colour/Range values.
|
||
pub range: u8,
|
||
}
|
||
|
||
/// An audio stream.
|
||
#[derive(Debug, Clone)]
|
||
pub struct AudioStream {
|
||
/// MPEG-TS packet ID
|
||
pub pid: u16,
|
||
/// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
|
||
pub codec: Codec,
|
||
/// Channel layout
|
||
pub channels: AudioChannels,
|
||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||
pub language: String,
|
||
/// Sample rate
|
||
pub sample_rate: SampleRate,
|
||
/// Whether this is a secondary stream (commentary)
|
||
pub secondary: bool,
|
||
/// Stream purpose (commentary / descriptive / score / IME / normal).
|
||
/// Callers translate this to display text in their own locale.
|
||
pub purpose: LabelPurpose,
|
||
/// Codec / variant text (e.g. "Dolby TrueHD 5.1", "(US)").
|
||
/// NEVER contains English purpose words — see `purpose` for that.
|
||
pub label: String,
|
||
}
|
||
|
||
/// A subtitle stream.
|
||
#[derive(Debug, Clone)]
|
||
pub struct SubtitleStream {
|
||
/// MPEG-TS packet ID
|
||
pub pid: u16,
|
||
/// Codec (PGS)
|
||
pub codec: Codec,
|
||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||
pub language: String,
|
||
/// Whether this is a forced subtitle
|
||
pub forced: bool,
|
||
/// Subtitle qualifier (SDH / descriptive service / forced / none).
|
||
/// Callers translate this to display text in their own locale.
|
||
pub qualifier: LabelQualifier,
|
||
/// Pre-formatted codec private data (e.g. VobSub .idx palette header)
|
||
pub codec_data: Option<Vec<u8>>,
|
||
}
|
||
|
||
/// Video/audio codec.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum Codec {
|
||
// Video
|
||
Hevc,
|
||
H264,
|
||
Vc1,
|
||
Mpeg2,
|
||
Mpeg1,
|
||
Av1,
|
||
// Audio
|
||
TrueHd,
|
||
DtsHdMa,
|
||
DtsHdHr,
|
||
Dts,
|
||
Ac3,
|
||
Ac3Plus,
|
||
Lpcm,
|
||
Aac,
|
||
Mp2,
|
||
Mp3,
|
||
Flac,
|
||
Opus,
|
||
// Subtitle
|
||
Pgs,
|
||
DvdSub,
|
||
Srt,
|
||
Ssa,
|
||
// Unknown
|
||
Unknown(u8),
|
||
}
|
||
|
||
/// Video resolution.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Resolution {
|
||
/// 480i (720x480 interlaced) — NTSC DVD
|
||
R480i,
|
||
/// 480p (720x480 progressive)
|
||
R480p,
|
||
/// 576i (720x576 interlaced) — PAL DVD
|
||
R576i,
|
||
/// 576p (720x576 progressive)
|
||
R576p,
|
||
/// 720p (1280x720 progressive) — some Blu-rays
|
||
R720p,
|
||
/// 1080i (1920x1080 interlaced) — broadcast, some BD
|
||
R1080i,
|
||
/// 1080p (1920x1080 progressive) — standard Blu-ray
|
||
R1080p,
|
||
/// 2160p (3840x2160 progressive) — 4K UHD Blu-ray
|
||
R2160p,
|
||
/// 4320p (7680x4320 progressive) — 8K, future-proof
|
||
R4320p,
|
||
/// Unknown resolution
|
||
Unknown,
|
||
}
|
||
|
||
/// Video frame rate.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum FrameRate {
|
||
/// 23.976 fps — film-based BD/UHD (NTSC pulldown)
|
||
F23_976,
|
||
/// 24.000 fps — true film rate
|
||
F24,
|
||
/// 25.000 fps — PAL standard
|
||
F25,
|
||
/// 29.970 fps — NTSC standard
|
||
F29_97,
|
||
/// 30.000 fps
|
||
F30,
|
||
/// 50.000 fps — PAL high frame rate
|
||
F50,
|
||
/// 59.940 fps — NTSC high frame rate
|
||
F59_94,
|
||
/// 60.000 fps
|
||
F60,
|
||
/// Unknown frame rate
|
||
Unknown,
|
||
}
|
||
|
||
/// Audio channel layout.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum AudioChannels {
|
||
/// 1.0 mono
|
||
Mono,
|
||
/// 2.0 stereo
|
||
Stereo,
|
||
/// 2.1 (stereo + LFE)
|
||
Stereo21,
|
||
/// 4.0 quadraphonic
|
||
Quad,
|
||
/// 5.0 surround (no LFE)
|
||
Surround50,
|
||
/// 5.1 surround — standard BD/DVD surround
|
||
Surround51,
|
||
/// 6.1 surround (DTS-ES, Dolby EX)
|
||
Surround61,
|
||
/// 7.1 surround — UHD Atmos beds, DTS:X
|
||
Surround71,
|
||
/// Unknown channel layout
|
||
Unknown,
|
||
}
|
||
|
||
/// Audio sample rate.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SampleRate {
|
||
/// 44.1 kHz — CD audio (rare on disc)
|
||
S44_1,
|
||
/// 48 kHz — standard BD/DVD/UHD audio
|
||
S48,
|
||
/// 88.2 kHz — 44.1 kHz-family high-res TrueHD (music BD)
|
||
S88_2,
|
||
/// 96 kHz — high-res BD audio
|
||
S96,
|
||
/// 176.4 kHz — 44.1 kHz-family high-res TrueHD (music BD)
|
||
S176_4,
|
||
/// 192 kHz — highest BD audio (LPCM)
|
||
S192,
|
||
/// 48/96 kHz combo (secondary audio resampled)
|
||
S48_96,
|
||
/// 48/192 kHz combo (secondary audio resampled)
|
||
S48_192,
|
||
/// Unknown sample rate
|
||
Unknown,
|
||
}
|
||
|
||
/// HDR format.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum HdrFormat {
|
||
Sdr,
|
||
Hdr10,
|
||
Hdr10Plus,
|
||
DolbyVision,
|
||
Hlg,
|
||
}
|
||
|
||
/// Color space.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum ColorSpace {
|
||
Bt709,
|
||
Bt2020,
|
||
/// SD PAL/576-line colorimetry (ITU-R BT.470 System B/G — primaries 5,
|
||
/// transfer 5, matrix 5). DVDs are SD, not HD: stamping BT.709 mis-tags
|
||
/// their colour.
|
||
Bt470bg,
|
||
/// SD NTSC/480-line colorimetry (SMPTE 170M / BT.601-525 — primaries 6,
|
||
/// transfer 6, matrix 6).
|
||
Smpte170m,
|
||
Unknown,
|
||
}
|
||
|
||
/// A chapter point within a title.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Chapter {
|
||
/// Chapter start time in seconds
|
||
pub time_secs: f64,
|
||
/// Chapter name — a bare 1-based index ("1", "2", …). The library
|
||
/// emits no localized prose; consuming apps prepend any "Chapter "
|
||
/// prefix in the user's language.
|
||
pub name: String,
|
||
}
|
||
|
||
/// Default chapter name for the 0-based chapter index `i`: the bare
|
||
/// 1-based ordinal as a string. Keeps chapter labelling language-neutral
|
||
/// (apps localize) and gives BD and DVD a single source of truth.
|
||
pub(crate) fn chapter_name(i: usize) -> String {
|
||
(i + 1).to_string()
|
||
}
|
||
|
||
/// A contiguous range of sectors on disc.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Extent {
|
||
pub start_lba: u32,
|
||
pub sector_count: u32,
|
||
}
|
||
|
||
/// Union a set of extents into sorted, merged, disjoint `(start_lba,
|
||
/// sector_count)` ranges — the pure, testable core of
|
||
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
|
||
fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u32)> {
|
||
let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect();
|
||
ranges.sort_by_key(|r| r.0);
|
||
crate::udf::merge_ranges(&ranges)
|
||
}
|
||
|
||
/// Correct a title's TrueHD audio-stream metadata by probing the first
|
||
/// decrypted access units — channel count, real sample rate, and Atmos
|
||
/// detection in a single major-sync read. The MPLS descriptors declare the BASE
|
||
/// layout (often 5.1 / a container-guessed rate) even for a 7.1/Atmos TrueHD
|
||
/// track; the truth is in the MLP major sync. `reader` must yield DECRYPTED
|
||
/// sectors (the m2ts is AACS-encrypted, so this can only run at mux time, not
|
||
/// scan). Reads a bounded window of the title's first extent.
|
||
///
|
||
/// Corrections, each individually guarded so a malformed field never writes a
|
||
/// wrong header:
|
||
/// - **Channels**: from the presentation channel masks (as before).
|
||
/// - **Sample rate**: from the whitelisted rate nibble; left untouched on an
|
||
/// unknown rate or no major sync.
|
||
/// - **Atmos**: when a 4th substream is detected AND the stream still carries
|
||
/// the basic descriptor label, the label is promoted to the Atmos form;
|
||
/// richer editorial labels (e.g. an existing "Dolby Atmos") are left intact.
|
||
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
|
||
use crate::mux::codec::truehd::{
|
||
truehd_channels, truehd_sample_rate_hz, truehd_sync_info_from_stream,
|
||
};
|
||
|
||
let pids: Vec<u16> = title
|
||
.streams
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Stream::Audio(a) if matches!(a.codec, Codec::TrueHd) => Some(a.pid),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
if pids.is_empty() {
|
||
return;
|
||
}
|
||
let Some(ext) = title.extents.first() else {
|
||
return;
|
||
};
|
||
// Bounded probe: up to 8 MiB from the start of the title — enough for the
|
||
// first interleaved TrueHD major sync of each stream.
|
||
const PROBE_SECTORS: u32 = 4096;
|
||
let n = ext.sector_count.min(PROBE_SECTORS) as u16;
|
||
if n == 0 {
|
||
return;
|
||
}
|
||
let mut buf = vec![0u8; n as usize * 2048];
|
||
// Anchor the AACS unit-alignment gate to the title's encrypted-region start
|
||
// before probing. Without this a `DecryptingSectorSource` falls back to an
|
||
// absolute `start_lba % 3` gate; a non-3-aligned `ext.start_lba` then trips
|
||
// DecryptFailed on the very first probe read, so the TrueHD channel count is
|
||
// never corrected and Atmos / 7.1 is silently understated as 5.1. No-op for
|
||
// CSS / unencrypted sources (set_unit_base default is a no-op).
|
||
reader.set_unit_base(ext.start_lba);
|
||
if reader
|
||
.read_sectors(ext.start_lba, n, &mut buf, true)
|
||
.is_err()
|
||
{
|
||
return;
|
||
}
|
||
|
||
let mut demux = crate::mux::ts::TsDemuxer::new(&pids);
|
||
let mut payloads: std::collections::HashMap<u16, Vec<u8>> = std::collections::HashMap::new();
|
||
for pes in demux.feed(&buf).into_iter().chain(demux.flush()) {
|
||
payloads
|
||
.entry(pes.pid)
|
||
.or_default()
|
||
.extend_from_slice(&pes.data);
|
||
}
|
||
|
||
for s in title.streams.iter_mut() {
|
||
let Stream::Audio(a) = s else { continue };
|
||
if !matches!(a.codec, Codec::TrueHd) {
|
||
continue;
|
||
}
|
||
let Some(payload) = payloads.get(&a.pid) else {
|
||
continue;
|
||
};
|
||
// One major-sync read yields channels, sample rate and the Atmos signal.
|
||
let Some(info) = truehd_sync_info_from_stream(payload) else {
|
||
continue;
|
||
};
|
||
|
||
// Whether the label is still the plain descriptor (no richer editorial
|
||
// label). Captured against the CURRENT channels before any correction so
|
||
// a label promotion only happens when nothing editorial is present.
|
||
let was_basic =
|
||
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
|
||
|
||
// (1) Channels — only when the major sync resolves a different layout.
|
||
if let Some(count) = truehd_channels(info.format_info) {
|
||
let new_ch = AudioChannels::from_count(count);
|
||
if new_ch != AudioChannels::Unknown && new_ch != a.channels {
|
||
a.channels = new_ch;
|
||
}
|
||
}
|
||
|
||
// (2) Sample rate — whitelisted rates only; an unknown nibble or a rate
|
||
// that maps to no enum variant leaves the container value untouched
|
||
// (never write a wrong SamplingFrequency).
|
||
if let Some(hz) = truehd_sample_rate_hz(info.format_info) {
|
||
let new_sr = SampleRate::from_hz(hz);
|
||
if new_sr != SampleRate::Unknown && new_sr != a.sample_rate {
|
||
a.sample_rate = new_sr;
|
||
}
|
||
}
|
||
|
||
// (3) Label — refresh to the corrected channels; promote to the Atmos
|
||
// form only when the stream carried the basic descriptor (no editorial
|
||
// Atmos already) AND a 4th substream was positively detected.
|
||
if was_basic {
|
||
a.label = if info.is_atmos == Some(true) {
|
||
crate::labels::generate_audio_label_atmos(&a.codec, &a.channels, a.secondary)
|
||
} else {
|
||
crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary)
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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<u64> {
|
||
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<u32>, Option<f64>) {
|
||
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<LocatedRange> = raw
|
||
.iter()
|
||
.map(|(pos, size)| {
|
||
let lba = pos / SECTOR_BYTES_U64;
|
||
let count = (size / SECTOR_BYTES_U64) as u32;
|
||
let duration_ms = if bps > 0.0 {
|
||
(*size as f64) / bps * MILLIS_PER_SEC
|
||
} else {
|
||
0.0
|
||
};
|
||
let (chapter, time_offset_secs) = range_chapter(lba as u32, title);
|
||
LocatedRange {
|
||
lba,
|
||
count,
|
||
duration_ms,
|
||
chapter,
|
||
time_offset_secs,
|
||
}
|
||
})
|
||
.collect();
|
||
ranges.sort_by(|a, b| {
|
||
b.duration_ms
|
||
.partial_cmp(&a.duration_ms)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
let largest_gap_ms = ranges.first().map(|r| r.duration_ms).unwrap_or(0.0);
|
||
let truncated = ranges.len().saturating_sub(MAX_LOCATED) as u32;
|
||
ranges.truncate(MAX_LOCATED);
|
||
// At-risk movie time = duration of the ranges that intersect the title
|
||
// extents (the others are menus/extras → no movie impact).
|
||
let main_at_risk_ms = if bps > 0.0 {
|
||
bytes_bad_in_title(title, raw) as f64 * MILLIS_PER_SEC / bps
|
||
} else {
|
||
0.0
|
||
};
|
||
LocatedProgress {
|
||
ranges,
|
||
num_ranges,
|
||
truncated,
|
||
main_at_risk_ms,
|
||
largest_gap_ms,
|
||
}
|
||
}
|
||
|
||
// ─── 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<Self, ()> {
|
||
for (id, v) in ColorSpace::ALL_CS {
|
||
if *id == s {
|
||
return Ok(*v);
|
||
}
|
||
}
|
||
// Also accept display names (e.g. "BT.2020").
|
||
for (_id, v) in ColorSpace::ALL_CS {
|
||
if ColorSpace::name(v) == s {
|
||
return Ok(*v);
|
||
}
|
||
}
|
||
Ok(ColorSpace::Unknown)
|
||
}
|
||
}
|
||
|
||
// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
|
||
//
|
||
// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
|
||
// and id() all derive from this one table — no string appears twice.
|
||
|
||
macro_rules! enum_str {
|
||
($name:ident, $default:expr, [ $( ($s:expr, $v:expr) ),* $(,)? ]) => {
|
||
impl $name {
|
||
const ALL: &[(&'static str, $name)] = &[ $( ($s, $v), )* ];
|
||
}
|
||
impl std::fmt::Display for $name {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
for (s, v) in $name::ALL {
|
||
if v == self { return f.write_str(s); }
|
||
}
|
||
// The only variant not in ALL is the Unknown fallback (kept out
|
||
// of ALL so FromStr("unknown") round-trips to it via $default
|
||
// without ALL gaining a duplicate key). Display it visibly as
|
||
// "unknown" rather than an empty string, which produced blank
|
||
// metadata in labels and logs.
|
||
f.write_str("unknown")
|
||
}
|
||
}
|
||
impl std::str::FromStr for $name {
|
||
type Err = ();
|
||
fn from_str(s: &str) -> std::result::Result<Self, ()> {
|
||
for (k, v) in $name::ALL {
|
||
if *k == s { return Ok(*v); }
|
||
}
|
||
Ok($default)
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
enum_str!(
|
||
Resolution,
|
||
Resolution::Unknown,
|
||
[
|
||
("480i", Resolution::R480i),
|
||
("480p", Resolution::R480p),
|
||
("576i", Resolution::R576i),
|
||
("576p", Resolution::R576p),
|
||
("720p", Resolution::R720p),
|
||
("1080i", Resolution::R1080i),
|
||
("1080p", Resolution::R1080p),
|
||
("2160p", Resolution::R2160p),
|
||
("4320p", Resolution::R4320p),
|
||
]
|
||
);
|
||
|
||
enum_str!(
|
||
FrameRate,
|
||
FrameRate::Unknown,
|
||
[
|
||
("23.976", FrameRate::F23_976),
|
||
("24", FrameRate::F24),
|
||
("25", FrameRate::F25),
|
||
("29.97", FrameRate::F29_97),
|
||
("30", FrameRate::F30),
|
||
("50", FrameRate::F50),
|
||
("59.94", FrameRate::F59_94),
|
||
("60", FrameRate::F60),
|
||
]
|
||
);
|
||
|
||
enum_str!(
|
||
AudioChannels,
|
||
AudioChannels::Unknown,
|
||
[
|
||
("mono", AudioChannels::Mono),
|
||
("stereo", AudioChannels::Stereo),
|
||
("2.1", AudioChannels::Stereo21),
|
||
("4.0", AudioChannels::Quad),
|
||
("5.0", AudioChannels::Surround50),
|
||
("5.1", AudioChannels::Surround51),
|
||
("6.1", AudioChannels::Surround61),
|
||
("7.1", AudioChannels::Surround71),
|
||
]
|
||
);
|
||
|
||
enum_str!(
|
||
SampleRate,
|
||
SampleRate::Unknown,
|
||
[
|
||
("44.1kHz", SampleRate::S44_1),
|
||
("48kHz", SampleRate::S48),
|
||
("88.2kHz", SampleRate::S88_2),
|
||
("96kHz", SampleRate::S96),
|
||
("176.4kHz", SampleRate::S176_4),
|
||
("192kHz", SampleRate::S192),
|
||
("48/96kHz", SampleRate::S48_96),
|
||
("48/192kHz", SampleRate::S48_192),
|
||
]
|
||
);
|
||
|
||
impl std::str::FromStr for Codec {
|
||
type Err = ();
|
||
fn from_str(s: &str) -> std::result::Result<Self, ()> {
|
||
for (id, _, v) in Codec::ALL_CODECS {
|
||
if *id == s {
|
||
return Ok(*v);
|
||
}
|
||
}
|
||
Ok(Codec::Unknown(0))
|
||
}
|
||
}
|
||
|
||
impl std::str::FromStr for HdrFormat {
|
||
type Err = ();
|
||
fn from_str(s: &str) -> std::result::Result<Self, ()> {
|
||
for (id, v) in HdrFormat::ALL_HDR {
|
||
if *id == s {
|
||
return Ok(*v);
|
||
}
|
||
}
|
||
// Also accept display names
|
||
for (_id, v) in HdrFormat::ALL_HDR {
|
||
if HdrFormat::name(v) == s {
|
||
return Ok(*v);
|
||
}
|
||
}
|
||
// An unrecognised string is an error, not silently SDR. ("sdr"/"SDR"
|
||
// already matched above.) Callers that want SDR-on-unknown opt in
|
||
// explicitly with `.unwrap_or(HdrFormat::Sdr)` (e.g. mux/meta.rs).
|
||
Err(())
|
||
}
|
||
}
|
||
|
||
impl DiscTitle {
|
||
/// Empty DiscTitle with no streams.
|
||
pub fn empty() -> Self {
|
||
Self {
|
||
playlist: String::new(),
|
||
playlist_id: 0,
|
||
duration_secs: 0.0,
|
||
size_bytes: 0,
|
||
clips: Vec::new(),
|
||
streams: Vec::new(),
|
||
chapters: Vec::new(),
|
||
extents: Vec::new(),
|
||
content_format: ContentFormat::BdTs,
|
||
codec_privates: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Duration formatted as "Xh Ym"
|
||
pub fn duration_display(&self) -> String {
|
||
let hrs = (self.duration_secs / 3600.0) as u32;
|
||
let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
|
||
format!("{hrs}h {mins:02}m")
|
||
}
|
||
|
||
/// Size in GB
|
||
pub fn size_gb(&self) -> f64 {
|
||
self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
|
||
}
|
||
|
||
/// Total sectors across all extents
|
||
pub fn total_sectors(&self) -> u64 {
|
||
self.extents.iter().map(|e| e.sector_count as u64).sum()
|
||
}
|
||
|
||
/// 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<Item = &AudioStream> {
|
||
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<Item = &SubtitleStream> {
|
||
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<Item = &VideoStream> {
|
||
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<u32>,
|
||
/// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
|
||
pub disc_hash: String,
|
||
/// How keys were resolved
|
||
pub key_source: KeyOrigin,
|
||
/// Volume Unique Key (16 bytes). `None` when keys were resolved
|
||
/// via the [`KeyOrigin::KeyDbUnitKeys`] path — that source delivers
|
||
/// pre-decrypted unit keys without a VUK to derive them from.
|
||
pub vuk: Option<[u8; 16]>,
|
||
/// Decrypted unit keys (CPS unit number, key)
|
||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
|
||
pub read_data_key: Option<[u8; 16]>,
|
||
/// Volume ID (16 bytes) -- from SCSI handshake
|
||
pub volume_id: [u8; 16],
|
||
/// Raw `Unit_Key_RO.inf` bytes (encrypted unit keys + CPS map). Stashed at
|
||
/// scan so an external resolver (key-resolver) can derive the unit keys
|
||
/// from a VUK without re-reading the disc. Empty when not captured.
|
||
pub uk_ro: Vec<u8>,
|
||
/// Raw MKB bytes (`MKB_RO.inf`). Stashed at scan so an external resolver can
|
||
/// walk it (device/processing key → media key). Empty when not captured.
|
||
pub mkb: Vec<u8>,
|
||
}
|
||
|
||
// Redacting `Debug`: `AacsState` is crate-root re-exported and reachable via the
|
||
// public `Disc.aacs` field; it carries VUK / unit keys / read-data (bus) key /
|
||
// volume id / raw .inf + MKB. Print only non-secret shape; redact every
|
||
// key/secret field. Guarded by `aacs_state_and_key_debug_are_redacted`.
|
||
impl std::fmt::Debug for AacsState {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("AacsState")
|
||
.field("version", &self.version)
|
||
.field("bus_encryption", &self.bus_encryption)
|
||
.field("mkb_version", &self.mkb_version)
|
||
.field("disc_hash", &self.disc_hash)
|
||
.field("key_source", &self.key_source)
|
||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||
.field("unit_keys_len", &self.unit_keys.len())
|
||
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
|
||
.field("volume_id", &"<redacted>")
|
||
.field("uk_ro_len", &self.uk_ro.len())
|
||
.field("mkb_len", &self.mkb.len())
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
/// How AACS keys were resolved. Variants are ordered root-of-trust →
|
||
/// per-disc-leaf, matching the resolver's path-try order: the resolver
|
||
/// attempts derivation from the strongest input it has first and falls
|
||
/// back toward pre-computed per-disc material.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum KeyOrigin {
|
||
/// MKB + device keys → subset-difference tree → VUK
|
||
DeviceKey,
|
||
/// MKB + processing keys → media key → VUK
|
||
ProcessingKey,
|
||
/// Media key + Volume ID from KEYDB → derived VUK
|
||
KeyDbDerived,
|
||
/// VUK found directly in KEYDB by disc hash
|
||
KeyDb,
|
||
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
|
||
/// No VUK present in the entry — `AacsState::vuk` is `None`.
|
||
KeyDbUnitKeys,
|
||
/// Unit key supplied directly by the caller (the external Unit Key path).
|
||
/// No keydb, no derivation — `AacsState::vuk` is `None`.
|
||
ExternalUk,
|
||
}
|
||
|
||
// No `KeyOrigin::name()`: the library holds ZERO user-facing English (CLAUDE.md).
|
||
// `KeyOrigin` is a typed enum; applications map its variants to display text
|
||
// (see freemkv's `disc_info::key_origin_label`).
|
||
|
||
// ─── Disc scanning ──────────────────────────────────────────────────────────
|
||
|
||
/// AACS host credentials for the live-drive authenticated handshake.
|
||
///
|
||
/// Optional and source-agnostic: an ISO scan has no handshake at all, and a
|
||
/// live drive without supplied credentials simply skips cert auth. The
|
||
/// caller supplies the host cert(s) from wherever it likes — today the keydb's
|
||
/// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key
|
||
/// source: a locked drive needs the cert to unlock even when the decryption key
|
||
/// comes from an online service.
|
||
#[derive(Default, Clone)]
|
||
pub struct DriveCredentials {
|
||
/// Host certificate(s) + private key(s) for the SCSI AACS handshake.
|
||
pub host_certs: Vec<crate::aacs::types::HostCert>,
|
||
}
|
||
|
||
/// Options for disc scanning.
|
||
///
|
||
/// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key
|
||
/// out-of-band (a key source) and applies it via [`Disc::decrypt_with`]. The
|
||
/// only scan input is the optional drive credentials for the live-drive
|
||
/// authenticated handshake.
|
||
#[derive(Default)]
|
||
pub struct ScanOptions {
|
||
/// Host credentials for the live-drive AACS handshake. `None` for ISO
|
||
/// scans, or a live drive where cert auth should be skipped.
|
||
///
|
||
/// Host certs may ALSO be supplied through [`Self::key_sources`]: the
|
||
/// handshake unifies certs from both, so the app can pass its already-built
|
||
/// keysource layer rather than (or in addition to) pre-extracting certs into
|
||
/// `DriveCredentials`. Either route is keysource-served — certs are never
|
||
/// compiled into the library.
|
||
pub credentials: Option<DriveCredentials>,
|
||
/// The application's key-source layer. The handshake collects host certs
|
||
/// across these (via [`crate::KeySource::host_certs`]) for the OEM/AACS
|
||
/// cert-auth route, unioned with [`Self::credentials`]. Empty by default —
|
||
/// an ISO scan supplies none, and a live-drive caller that pre-extracted
|
||
/// certs into `credentials` may leave it empty too. The library still
|
||
/// resolves NO keys from these at scan time; they are consulted only for
|
||
/// their host certs here (key *resolution* stays out-of-band via
|
||
/// `Disc::decrypt_with`).
|
||
pub key_sources: Vec<Box<dyn crate::KeySource>>,
|
||
/// Optional cooperative-cancellation token. When set, long scan-time
|
||
/// loops (notably the CSS known-plaintext crack, which can scan up to
|
||
/// 50_000 sectors on a live DVD) poll it and bail out cleanly so a
|
||
/// scan-phase watchdog or operator Stop is never stuck behind a hang.
|
||
pub halt: Option<crate::halt::Halt>,
|
||
/// Read the PGS subtitle streams during the scan to detect forced-narrative
|
||
/// tracks from their content (the `forced_on_flag`), matching what the mux
|
||
/// derives during a rip. OFF by default — it reads the clip's PGS content,
|
||
/// which is slow, so only callers that want authoritative forced flags in the
|
||
/// scanned title (e.g. `freemkv info`) opt in. The rip path leaves it off:
|
||
/// the muxer detects forced during muxing without a second read.
|
||
pub probe_forced_subtitles: bool,
|
||
}
|
||
|
||
/// Quick disc identification — name, format, capacity. No title/stream parsing.
|
||
#[derive(Debug)]
|
||
pub struct DiscId {
|
||
/// UDF Volume Identifier (always present, e.g. "SAMPLE_FILM")
|
||
pub volume_id: String,
|
||
/// Disc title from META/DL/bdmt_eng.xml (e.g. "Sample Film")
|
||
pub meta_title: Option<String>,
|
||
/// Disc format (BD, UHD, DVD) — UHD vs BD requires full scan to confirm
|
||
pub format: DiscFormat,
|
||
/// Disc capacity in sectors
|
||
pub capacity_sectors: u32,
|
||
/// Whether AACS directory exists (disc is likely encrypted)
|
||
pub encrypted: bool,
|
||
/// Number of layers
|
||
pub layers: u8,
|
||
}
|
||
|
||
impl DiscId {
|
||
/// Best available name: meta_title, then formatted volume_id.
|
||
pub fn name(&self) -> &str {
|
||
self.meta_title.as_deref().unwrap_or(&self.volume_id)
|
||
}
|
||
}
|
||
|
||
impl Disc {
|
||
/// Fast disc identification — reads only UDF metadata for name and format.
|
||
/// No AACS handshake, no playlist parsing, no CLPI, no labels.
|
||
/// Typically completes in 2-3 seconds on USB drives.
|
||
pub fn identify(session: &mut Drive) -> Result<DiscId> {
|
||
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
||
|
||
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
|
||
// Authoritative here — the same MKB-driven detector the full scan uses
|
||
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
|
||
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
|
||
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
|
||
let encrypted =
|
||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||
|
||
Ok(DiscId {
|
||
volume_id: udf_fs.volume_id,
|
||
meta_title,
|
||
format,
|
||
capacity_sectors: capacity,
|
||
encrypted,
|
||
layers,
|
||
})
|
||
}
|
||
|
||
/// Disc capacity in GB
|
||
pub fn capacity_gb(&self) -> f64 {
|
||
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
|
||
}
|
||
|
||
/// Read UDF filesystem and set up buffered reader with metadata prefetched.
|
||
/// Shared setup for both identify() and scan().
|
||
fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> {
|
||
let capacity = Self::read_capacity(session).unwrap_or_else(|e| {
|
||
// A READ CAPACITY failure (transient drive spin-up, SCSI error)
|
||
// must not be silently treated as a 0-sector disc: capacity=0
|
||
// skews the layer heuristic (always 1 layer) and title ordering.
|
||
// Recovery is unchanged (we still proceed with 0), but surface it.
|
||
tracing::warn!(
|
||
target: "freemkv::scan",
|
||
error = %e,
|
||
"READ CAPACITY failed; treating disc capacity as 0 sectors (layer count and title ordering may be wrong)"
|
||
);
|
||
0
|
||
});
|
||
let batch = detect_max_batch_sectors(session.device_path());
|
||
let mut buffered = udf::BufferedSectorReader::new(session, batch);
|
||
let udf_fs = udf::read_filesystem(&mut buffered)?;
|
||
buffered.prefetch(udf_fs.metadata_start(), udf_fs.metadata_sectors());
|
||
Ok((capacity, buffered, udf_fs))
|
||
}
|
||
|
||
/// Scan a disc — parse filesystem, playlists, streams, and set up
|
||
/// AACS decryption. This is the main entry point; after `scan()` the
|
||
/// Disc is ready (titles populated with streams, AACS inputs
|
||
/// captured, content readable and decryptable transparently).
|
||
///
|
||
/// One pipeline, one order:
|
||
/// 1. Read capacity + UDF filesystem
|
||
/// 2. AACS handshake + key resolution
|
||
/// 3. Parse playlists + streams
|
||
/// 4. Apply labels
|
||
///
|
||
/// The session must be open and unlocked (`Drive::open` handles this).
|
||
/// All disc reads use standard READ(10) via UDF — no vendor SCSI commands.
|
||
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
|
||
// AACS handshake (Blu-ray/UHD). Acquires the Volume ID via the
|
||
// cert-based mutual-auth handshake (the OEM route); drive unlock
|
||
// itself runs separately behind the pluggable `Unlocker` seam.
|
||
// AACS is Blu-ray/UHD only. A DVD uses CSS — skip the AACS handshake
|
||
// entirely (the drive already classified the disc as DVD at init), so a
|
||
// DVD never issues AACS OEM-VID / cert SCSI against the drive before the
|
||
// CSS bus-auth runs.
|
||
let (handshake, handshake_error) = if session.disc_is_dvd() {
|
||
(None, None)
|
||
} else {
|
||
tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
|
||
Self::do_handshake(session, opts)
|
||
};
|
||
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
|
||
|
||
// Request max read speed — removes riplock on DVD
|
||
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
|
||
session.set_speed(0xFFFF);
|
||
|
||
// 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<Self> {
|
||
let udf_fs = udf::read_filesystem(reader)?;
|
||
let mut disc = Self::scan_with(reader, capacity, None, None, opts, udf_fs)?;
|
||
|
||
// CSS for a raw (still-scrambled) DVD image: recover the title key from
|
||
// the scrambled movie data itself (known-plaintext attack), same as the
|
||
// live-drive path — but with no SCSI auth/unlock (an image is already
|
||
// readable). This lets the CLI mux a RAW CSS ISO, not only a
|
||
// pre-decrypted one. A pre-decrypted image has its scramble flags clear,
|
||
// so `crack_key` finds no crackable sector and the disc stays in the
|
||
// clear. AACS images go through KEYDB VUK lookup, not here.
|
||
//
|
||
// Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
|
||
// `.evo` images are ALSO MPEG-PS but are AACS, not CSS — they must not
|
||
// enter the CSS crack path. A CSS DVD's IFO (which defines the titles
|
||
// this branch reads) is unscrambled, so `detect_format` reliably sets
|
||
// `Dvd` from the SD-resolution titles even on a still-scrambled image.
|
||
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
|
||
// 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<u8>, Vec<u8>, 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<Vec<u8>> {
|
||
const START_BYTES: usize = 16 * 1024 * 1024;
|
||
const MAX_BYTES: usize = 64 * 1024 * 1024;
|
||
let mut want = START_BYTES;
|
||
loop {
|
||
let buf = crate::aacs::read_first(
|
||
&crate::aacs::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<u8>, Vec<u8>, u8)> {
|
||
// Preserve the underlying open error (`Error::IoError`, E5000, carrying
|
||
// the OS errno) instead of collapsing ENOENT/EPERM/etc. into
|
||
// `Error::AacsNoKeys` (E7000). A missing or unreadable ISO is an I/O
|
||
// fault, not a key-resolution failure; callers that dispatch on
|
||
// `.code()` must be able to tell the two apart.
|
||
let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)?;
|
||
let udf_fs = udf::read_filesystem(&mut reader)?;
|
||
Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
|
||
}
|
||
|
||
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The
|
||
/// out-of-band Unit Key path fetches the disc's key files from the drive,
|
||
/// resolves a key from them however it likes, then applies it via
|
||
/// [`Disc::decrypt_with`]. These files are plaintext UDF metadata — no
|
||
/// AACS handshake or keys are required to read them.
|
||
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
||
let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
|
||
Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
|
||
}
|
||
|
||
/// Core scan pipeline — works with any SectorSource.
|
||
///
|
||
/// `handshake_error` is plumbed from `do_handshake` so failures
|
||
/// (cert rejected, raw-read unsupported, VID read failed) are
|
||
/// preserved as `disc.aacs_error` for callers to render. When key
|
||
/// resolution succeeds despite the handshake failure (built-in
|
||
/// keys + disc-hash lookup hit) the error is dropped.
|
||
fn scan_with(
|
||
reader: &mut dyn SectorSource,
|
||
capacity: u32,
|
||
handshake: Option<HandshakeResult>,
|
||
handshake_error: Option<Error>,
|
||
opts: &ScanOptions,
|
||
udf_fs: udf::UdfFs,
|
||
) -> Result<Self> {
|
||
let scan_with_t0 = std::time::Instant::now();
|
||
tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
|
||
// 2. Resolve encryption (AACS, CSS, or none)
|
||
let encrypted =
|
||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||
|
||
let (aacs, aacs_error) = if !encrypted {
|
||
(None, None)
|
||
} else {
|
||
// Lookup-free: capture the disc's AACS inputs (MKB, VID,
|
||
// Unit_Key_RO.inf) but resolve NO key. The caller resolves a Key
|
||
// from a key source and applies it via `Disc::decrypt_with`. The
|
||
// disc reports "encrypted, no keys" until then.
|
||
match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
|
||
Ok(state) => (Some(state), None),
|
||
// A handshake failure (no VID) is more actionable than the
|
||
// generic capture error, so surface it when present.
|
||
Err(e) => (None, Some(handshake_error.unwrap_or(e))),
|
||
}
|
||
};
|
||
|
||
// 3. Titles + container — dispatched by on-disc tree. HD-DVD and DVD are
|
||
// tree-level peers, each with its own enumerator; FMTS shares the BD
|
||
// tree (a `.fmts` stream variant). Disc FORMAT is a separate axis
|
||
// derived below from the AACS MKB generation, not the tree.
|
||
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
|
||
(
|
||
Self::scan_bluray_titles(reader, &udf_fs),
|
||
ContentFormat::BdTs,
|
||
)
|
||
} else if udf_fs.find_dir("/HVDVD_TS").is_some() {
|
||
(
|
||
Self::scan_hddvd_titles(reader, &udf_fs),
|
||
ContentFormat::MpegPs,
|
||
)
|
||
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
||
(
|
||
Self::scan_dvd_titles(reader, &udf_fs),
|
||
ContentFormat::MpegPs,
|
||
)
|
||
} else {
|
||
(Vec::new(), ContentFormat::BdTs)
|
||
};
|
||
// Title ordering: titles[0] should be the canonical main feature.
|
||
//
|
||
// Naive "longest duration first" misranks branching UHDs (see
|
||
// `canonical_title_order` for the full rationale). Sort the
|
||
// titles so the consumer-side `-t 1` / autorip's main-feature
|
||
// picker / `disc.titles.first()` all converge on the actual
|
||
// movie instead of the virtual play-all composite.
|
||
let capacity_bytes = capacity as u64 * 2048;
|
||
titles.sort_by(|a, b| Self::canonical_title_order(a, b, capacity_bytes));
|
||
|
||
// 4. Metadata + labels
|
||
let meta_title = Self::read_meta_title(reader, &udf_fs);
|
||
crate::labels::apply(reader, &udf_fs, &mut titles);
|
||
|
||
// Optional content-based forced-subtitle detection. `info` opts in so its
|
||
// forced flags match what the muxer derives during a rip (both use the
|
||
// one shared PGS classifier); the rip path leaves it off — the muxer
|
||
// detects forced while muxing, without a second read of the clip.
|
||
if opts.probe_forced_subtitles {
|
||
// 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<u32> {
|
||
let cdb = [
|
||
crate::scsi::SCSI_READ_CAPACITY,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
];
|
||
let mut buf = [0u8; 8];
|
||
session.scsi_execute(
|
||
&cdb,
|
||
crate::scsi::DataDirection::FromDevice,
|
||
&mut buf,
|
||
5_000,
|
||
)?;
|
||
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||
// `last_lba + 1` = sector count. Guard the 0xFFFF_FFFF sentinel
|
||
// (capacity exceeds 32 bits) so it surfaces as an error instead of
|
||
// wrapping to 0 in release — mirrors the public `decode_read_capacity`.
|
||
lba.checked_add(1)
|
||
.ok_or(crate::error::Error::DiscCapacityOverflow)
|
||
}
|
||
}
|
||
|
||
/// A decryption key handed to libfreemkv by the caller.
|
||
///
|
||
/// libfreemkv is **lookup-free**: it never reads a keydb, never talks to a key
|
||
/// server, never searches paths. The application resolves a key from whatever
|
||
/// source it likes (a local keydb, an online key service, a mapfile cache) and
|
||
/// hands it in here; libfreemkv uses it to decrypt, deriving any remaining
|
||
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
|
||
///
|
||
/// `#[non_exhaustive]`: AACS is a derivation chain
|
||
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Each variant is an entry
|
||
/// point at one level of that chain; [`Disc::decrypt_with`] derives down from
|
||
/// it to the per-CPS unit keys. New levels can be added without breaking
|
||
/// callers.
|
||
#[derive(Clone)]
|
||
#[non_exhaustive]
|
||
pub enum Key {
|
||
/// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB
|
||
/// (subset-difference tree) to find the one that applies → media key →
|
||
/// VUK → unit keys. A source hands in its FULL device-key set, because
|
||
/// choosing which one applies *is* the MKB walk (derivation), and all
|
||
/// derivation lives here — never in a source.
|
||
Device(Vec<crate::aacs::types::DeviceKey>),
|
||
/// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
|
||
/// → media key → VUK → unit keys.
|
||
Processing(Vec<[u8; 16]>),
|
||
/// Media key candidate(s) (Km). A source hands its full pool because an MK
|
||
/// is MKB-scoped (shared across a pressing/MKB family) — picking the one
|
||
/// that applies is `km_verifies` against this disc's MKB, which is
|
||
/// derivation, so it lives here. libfreemkv verifies, then derives the VUK
|
||
/// via the Volume ID and the per-CPS-unit keys.
|
||
Media(Vec<[u8; 16]>),
|
||
/// Volume Unique Key (VK / VUK). libfreemkv decrypts `Unit_Key_RO.inf`
|
||
/// into the per-CPS-unit keys. NOT terminal — the chain continues to the
|
||
/// unit keys.
|
||
Volume([u8; 16]),
|
||
/// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
|
||
/// (keydb / key server) resolved these, or they were cached in the mapfile
|
||
/// at sweep; libfreemkv decrypts directly with no further derivation. This
|
||
/// is the terminal level every other variant derives down into.
|
||
Unit(Vec<(u32, [u8; 16])>),
|
||
}
|
||
|
||
// Redacting `Debug`: `Key` is crate-root re-exported and is the key-transport
|
||
// type crossing `Disc::decrypt_with`; every variant carries raw key material.
|
||
// Print only the variant name and count — never bytes. Guarded by
|
||
// `aacs_state_and_key_debug_are_redacted`.
|
||
impl std::fmt::Debug for Key {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Key::Device(v) => write!(f, "Key::Device(<{} redacted>)", v.len()),
|
||
Key::Processing(v) => write!(f, "Key::Processing(<{} redacted>)", v.len()),
|
||
Key::Media(v) => write!(f, "Key::Media(<{} redacted>)", v.len()),
|
||
Key::Volume(_) => f.write_str("Key::Volume(<redacted>)"),
|
||
Key::Unit(v) => write!(f, "Key::Unit(<{} redacted>)", v.len()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// True if `unit_keys` covers EVERY supplied scrambled content `sample` — the
|
||
/// validation gate for [`Disc::decrypt_with`]. Conservative: a sample that is
|
||
/// not AACS-scrambled proves nothing, and with no scrambled sample at all there
|
||
/// is nothing to disprove against, so it returns `true` (accept).
|
||
///
|
||
/// It returns `false` when ANY scrambled sample cannot be restored to clear
|
||
/// MPEG-TS by ANY unit key in the set. That covers two distinct failure shapes:
|
||
/// 1. a wholly wrong key (a keydb VK that does not match this disc) — no
|
||
/// sample decrypts; and
|
||
/// 2. a *partially* applicable key set on a multi-CPS-unit disc — the resolved
|
||
/// keys cover CPS unit 0 but not CPS unit 1. Accepting on the first sample
|
||
/// that decrypts (the old behaviour) would commit such a set, after which
|
||
/// CPS-unit-1 sectors pass through as raw encrypted bytes into the ISO/MKV
|
||
/// with no error surfaced anywhere. Requiring every scrambled sample to
|
||
/// decrypt rejects the incomplete set so the caller falls through to the
|
||
/// next candidate (and ultimately surfaces a key error rather than silently
|
||
/// writing ciphertext).
|
||
///
|
||
/// Reuses the ecosystem's single `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<u8>],
|
||
format: ContentFormat,
|
||
) -> bool {
|
||
use crate::aacs::content::{
|
||
ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, is_clean,
|
||
};
|
||
let scrambled: Vec<&[u8]> = samples
|
||
.iter()
|
||
.map(|s| s.as_slice())
|
||
.filter(|s| aacs_unit_needs_decrypt(s, format))
|
||
.collect();
|
||
if scrambled.is_empty() {
|
||
return true; // nothing to disprove against — accept
|
||
}
|
||
if unit_keys.is_empty() {
|
||
return false;
|
||
}
|
||
let mut probe = vec![0u8; ALIGNED_UNIT_LEN];
|
||
let total = (scrambled.len() as u64) * (unit_keys.len() as u64);
|
||
let mut tried = 0u64;
|
||
let mut hb = crate::progress::Heartbeat::new("scan_key_trial");
|
||
// Every scrambled sample must be covered by SOME unit key. A single sample
|
||
// that no key descrambles means the key set is incomplete (wrong key, or a
|
||
// CPS unit left uncovered) — reject so the wrong/partial set never commits.
|
||
for sample in scrambled {
|
||
let mut covered = false;
|
||
for (_, k) in unit_keys {
|
||
// Pure-CPU inner loop: only consult the clock every 256 trials.
|
||
hb.tick_cpu(tried, total);
|
||
tried += 1;
|
||
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
|
||
// bus layer (AACS 2.0) first, then the CPS unit key, then the structural
|
||
// proof — the composed form of the old `decrypt_unit_full`.
|
||
if let Some(rdk) = read_data_key {
|
||
decrypt_bus(&mut probe, rdk);
|
||
}
|
||
decrypt_unit(&mut probe, k);
|
||
if is_clean(&probe, format) {
|
||
covered = true;
|
||
break;
|
||
}
|
||
}
|
||
if !covered {
|
||
return false;
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
impl Disc {
|
||
/// Get the resolved decryption keys for this disc.
|
||
/// Used by disc-to-ISO and other full-disc operations.
|
||
pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
|
||
if let Some(ref aacs) = self.aacs {
|
||
// An AACS state with NO unit keys is "encrypted, no keys" — e.g.
|
||
// the VID-only state from out-of-band resolution before a Unit Key
|
||
// is supplied. Report None so callers treat it as missing keys
|
||
// (not a usable, empty key set).
|
||
if aacs.unit_keys.is_empty() {
|
||
return crate::decrypt::DecryptKeys::None;
|
||
}
|
||
crate::decrypt::DecryptKeys::Aacs {
|
||
unit_keys: aacs.unit_keys.clone(),
|
||
read_data_key: aacs.read_data_key,
|
||
format: self.content_format,
|
||
}
|
||
} else if let Some(ref css) = self.css {
|
||
crate::decrypt::DecryptKeys::Css {
|
||
title_key: css.title_key,
|
||
}
|
||
} else {
|
||
crate::decrypt::DecryptKeys::None
|
||
}
|
||
}
|
||
|
||
/// 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<crate::decrypt::AacsKeyMap> {
|
||
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<crate::keysource::DiscInputs> {
|
||
self.aacs.as_ref().map(|a| crate::keysource::DiscInputs {
|
||
disc_hash: a.disc_hash.clone(),
|
||
volume_id: a.volume_id,
|
||
version: a.version,
|
||
mkb: a.mkb.clone(),
|
||
unit_key_ro: a.uk_ro.clone(),
|
||
// Content samples need the disc reader, which scan does not retain;
|
||
// the caller fills these for sources that validate against ciphertext.
|
||
samples: Vec::new(),
|
||
// Human title: prefer the UDF/ISO volume identifier, fall back to the
|
||
// BDMV display name. Identity only — a key service may catalog it.
|
||
volume_label: {
|
||
let v = self.volume_id.trim();
|
||
if v.is_empty() {
|
||
self.meta_title.clone()
|
||
} else {
|
||
Some(v.to_string())
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
/// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
|
||
/// decryption state. **Lookup-free**: no keydb, no network — the caller
|
||
/// (an application, via a key source) does all resolution and hands the key
|
||
/// in here. For [`Key::Unit`] this is the deferred-mux / resume path: the
|
||
/// unit keys came from a key source or the mapfile cache, and libfreemkv
|
||
/// decrypts directly (see [`Self::inject_unit_keys`]).
|
||
/// `samples` are encrypted on-disc aligned units (each 6144 bytes), supplied
|
||
/// by the caller for content validation. A wrong key — a keydb VK that does
|
||
/// not match this disc, a stale UK — can still *derive* a non-empty (garbage)
|
||
/// unit-key set, so before the key touches disc state we confirm it actually
|
||
/// de-scrambles real ciphertext. This is the single home of key validation:
|
||
/// every caller loops a key source's candidates through `decrypt_with` and a
|
||
/// rejected key (`Err(AacsKeyRejected)`) transparently falls through to the
|
||
/// next. Pass `&[]` when no content sample is available (resume / mapfile
|
||
/// cache) — validation is then skipped and the key is applied as-is.
|
||
pub fn decrypt_with(&mut self, key: Key, samples: &[Vec<u8>]) -> Result<()> {
|
||
// The AACS 2.x bus key, needed to de-scramble a sample for validation;
|
||
// captured before any mutable borrow. None for AACS 1.0 and file-backed
|
||
// ISO units (bus encryption was already removed at read time).
|
||
let read_data_key = self.aacs.as_ref().and_then(|a| a.read_data_key);
|
||
|
||
// Resolve the supplied key DOWN to candidate unit keys WITHOUT
|
||
// committing them, so a wrong higher-level key can be rejected before it
|
||
// poisons disc state.
|
||
let (candidate_unit_keys, candidate_vuk) = if let Key::Unit(keys) = key {
|
||
// Terminal — the source / mapfile already holds the final UKs.
|
||
(keys, None)
|
||
} else {
|
||
// Every higher level derives DOWN to the unit keys, reusing the
|
||
// version-dispatched resolver — the single home for all AACS
|
||
// derivation (1.0 / 2.0 / 2.1 / 2.x). It needs the AACS inputs
|
||
// (Unit_Key_RO.inf, MKB, VID) stashed on the disc at scan time.
|
||
let aacs = self.aacs.as_ref().ok_or(crate::error::Error::AacsNoKeys)?;
|
||
if aacs.uk_ro.is_empty() {
|
||
// Scan captured no Unit_Key_RO.inf — nothing to derive into.
|
||
return Err(crate::error::Error::AacsNoKeys);
|
||
}
|
||
|
||
// Map the supplied Key -> raw material. The source handed in material
|
||
// at exactly one level; choosing/applying it is the resolver's job.
|
||
let mut supplied = crate::aacs::provider::SuppliedKey {
|
||
device_keys: Vec::new(),
|
||
processing_keys: Vec::new(),
|
||
media_keys: Vec::new(),
|
||
disc_entry: None,
|
||
};
|
||
match key {
|
||
Key::Device(dks) => supplied.device_keys = dks,
|
||
Key::Processing(pks) => supplied.processing_keys = pks,
|
||
Key::Media(mks) => supplied.media_keys = mks,
|
||
Key::Volume(vuk) => {
|
||
supplied.disc_entry = Some(crate::aacs::types::DiscEntry {
|
||
disc_hash: aacs.disc_hash.clone(),
|
||
title: String::new(),
|
||
media_key: None,
|
||
disc_id: None,
|
||
vuk: Some(vuk),
|
||
unit_keys: Vec::new(),
|
||
});
|
||
}
|
||
Key::Unit(_) => unreachable!("Key::Unit handled above"),
|
||
}
|
||
|
||
// Snapshot inputs (releases the &self borrow before the &mut below).
|
||
let volume_id = aacs.volume_id;
|
||
let mkb = aacs.mkb.clone();
|
||
let uk_ro = aacs.uk_ro.clone();
|
||
let version_u8 = aacs.version;
|
||
|
||
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
|
||
let ctx = crate::aacs::resolve::ResolveContext {
|
||
unit_key_ro: &uk_ro,
|
||
content_cert: None,
|
||
volume_id: &volume_id,
|
||
providers: &provider_refs,
|
||
mkb: if mkb.is_empty() { None } else { Some(&mkb) },
|
||
};
|
||
|
||
// Version dispatch — V10 uses the classical resolver at 48-byte
|
||
// stride; V20/V21 share the 64-byte stride, so try the classical V20
|
||
// paths first and fall back to the 2.1 variant chain. The
|
||
// reason-preserving wrapper threads the no-key cause out so the
|
||
// decrypt gate can report E7017 (had derivation material but no VID)
|
||
// vs E7022 (no usable material) instead of a flat AacsKeyRejected.
|
||
let resolved = crate::aacs::resolve::resolve_keys_with_reason(&ctx, version_u8)
|
||
.map_err(|_reason| crate::error::Error::AacsKeyRejected)?;
|
||
|
||
if resolved.unit_keys.is_empty() {
|
||
return Err(crate::error::Error::AacsKeyRejected);
|
||
}
|
||
(resolved.unit_keys, resolved.vuk)
|
||
};
|
||
|
||
// VALIDATE against real ciphertext. Conservative: reject only when a
|
||
// supplied sample is AACS-scrambled and NO candidate unit key can
|
||
// de-scramble it. With no samples (or only clear ones) there is nothing
|
||
// to disprove against, so the key is accepted as-is — keeping the
|
||
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
|
||
if !aligned_unit_keys_validate(
|
||
&candidate_unit_keys,
|
||
read_data_key.as_ref(),
|
||
samples,
|
||
self.content_format,
|
||
) {
|
||
return Err(crate::error::Error::AacsKeyRejected);
|
||
}
|
||
|
||
// Commit — only now does the key touch disc state.
|
||
match self.aacs.as_mut() {
|
||
Some(a) => {
|
||
a.unit_keys = candidate_unit_keys;
|
||
if candidate_vuk.is_some() {
|
||
a.vuk = candidate_vuk;
|
||
}
|
||
a.key_source = KeyOrigin::ExternalUk;
|
||
}
|
||
// A Unit key for an AACS disc whose scan built no state (keyless
|
||
// scan, no keydb): synthesize a minimal ExternalUk state.
|
||
None => self.inject_unit_keys(candidate_unit_keys),
|
||
}
|
||
// A prior scan-time resolution error (e.g. keyless scan) is now moot.
|
||
self.aacs_error = None;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// 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/<name>/...` / `/sys/class/scsi_generic/<name>/...`,
|
||
/// which only exist on Linux and only for `/`-delimited node paths. A Windows
|
||
/// `\\.\CdRom0` / `\\.\D:` path has no forward slash and no sysfs node, so the
|
||
/// probe cannot run and the caller must fall back to the optical default.
|
||
fn sysfs_batch_probe_supported(device_path: &str) -> bool {
|
||
cfg!(target_os = "linux") && device_path.contains('/')
|
||
}
|
||
|
||
/// Detect the maximum transfer size in sectors for a device.
|
||
pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||
// The sysfs probe below is Linux-only. Non-sysfs platforms (Windows in
|
||
// particular) use `\\.\`-form device paths (e.g. `\\.\CdRom0`, `\\.\D:`)
|
||
// that have no forward slash, so the Linux name-parsing below would treat
|
||
// the whole path as the device name, find no `/sys` node, and fall through
|
||
// to the block default (8192 sectors = 16 MiB) — far over the optical cap.
|
||
// Every device we open on a non-sysfs platform here is an optical drive,
|
||
// so return the optical default directly.
|
||
if !sysfs_batch_probe_supported(device_path) {
|
||
return DEFAULT_BATCH_SECTORS_OPTICAL;
|
||
}
|
||
|
||
let dev_name = device_path.rsplit('/').next().unwrap_or("");
|
||
if dev_name.is_empty() {
|
||
return DEFAULT_BATCH_SECTORS_OPTICAL;
|
||
}
|
||
|
||
// Check whether THIS device (not any device on the host) is an
|
||
// optical drive: read the SCSI peripheral type of the target node
|
||
// only. Type 0x05 (decimal 5) = CD/DVD. A previous version scanned
|
||
// every /sys/class/scsi_device entry and returned true if any was
|
||
// optical, misclassifying a block device as optical on a host that
|
||
// also has an optical drive.
|
||
let is_optical = {
|
||
// For an sg node the type lives at scsi_generic/<sg>/device/type;
|
||
// for a block node (sr0/sdX) at /sys/block/<name>/device/type.
|
||
let type_path = if dev_name.starts_with("sg") {
|
||
format!("/sys/class/scsi_generic/{dev_name}/device/type")
|
||
} else {
|
||
format!("/sys/block/{dev_name}/device/type")
|
||
};
|
||
std::fs::read_to_string(&type_path)
|
||
.ok()
|
||
.map(|c| c.trim().parse::<u32>() == Ok(5))
|
||
.unwrap_or(false)
|
||
};
|
||
|
||
if is_optical {
|
||
// For sg devices, find the corresponding block device name
|
||
let block_name = if dev_name.starts_with("sg") {
|
||
let block_dir = format!("/sys/class/scsi_generic/{dev_name}/device/block");
|
||
std::fs::read_dir(&block_dir)
|
||
.ok()
|
||
.and_then(|mut entries| entries.next())
|
||
.and_then(|e| e.ok())
|
||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||
} else {
|
||
Some(dev_name.to_string())
|
||
};
|
||
|
||
if let Some(bname) = block_name {
|
||
let sysfs_path = format!("/sys/block/{bname}/queue/max_hw_sectors_kb");
|
||
if let Ok(content) = std::fs::read_to_string(&sysfs_path)
|
||
&& let Ok(kb) = content.trim().parse::<u32>()
|
||
{
|
||
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
|
||
let sectors = (kb / 2).min(u16::MAX as u32) as u16;
|
||
// Align down to 3 (one aligned unit)
|
||
let aligned = (sectors / 3) * 3;
|
||
if aligned >= MIN_BATCH_SECTORS {
|
||
return aligned.min(MAX_BATCH_SECTORS);
|
||
}
|
||
}
|
||
}
|
||
DEFAULT_BATCH_SECTORS_OPTICAL
|
||
} else {
|
||
DEFAULT_BATCH_SECTORS_BLOCK
|
||
}
|
||
}
|
||
|
||
// ─── Format helpers ────────────────────────────────────────────────────────
|
||
|
||
// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// ── 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<u32> = 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<DiscTitle> = Vec::new();
|
||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
|
||
}
|
||
|
||
/// An AACS MKB Type-and-Version record (0x10) carrying `raw_type` — the only
|
||
/// record [`Disc::detect_disc_format`] reads to decide BD/UHD/FMTS.
|
||
fn mkb_type_record(raw_type: u32) -> Vec<u8> {
|
||
let mut v = vec![0x10, 0x00, 0x00, 0x0c]; // record type 0x10, rec_len 12
|
||
v.extend_from_slice(&raw_type.to_be_bytes()); // MKBType @ body offset 0
|
||
v.extend_from_slice(&0u32.to_be_bytes()); // version @ body offset 4
|
||
v
|
||
}
|
||
|
||
/// FORMAT derives from the AACS MKB generation, not the tree or filesystem:
|
||
/// 2.1 → FMTS, 2.0 → UHD, 1.0 → BD — all from the MKB Type record.
|
||
#[test]
|
||
fn detect_format_from_mkb_generation() {
|
||
use crate::udf::fixture::*;
|
||
for (raw, expected) in [
|
||
(0x4815_1003u32, DiscFormat::Fmts),
|
||
(0x4814_1003u32, DiscFormat::Uhd),
|
||
(0x0004_1003u32, DiscFormat::BluRay),
|
||
] {
|
||
let mut disc = MemDisc::new();
|
||
let root = DirSpec {
|
||
name: String::new(),
|
||
icb_lba: 10,
|
||
dir_data_lba: 11,
|
||
files: Vec::new(),
|
||
subdirs: vec![
|
||
DirSpec {
|
||
name: "BDMV".into(),
|
||
icb_lba: 12,
|
||
dir_data_lba: 13,
|
||
files: Vec::new(),
|
||
subdirs: vec![],
|
||
},
|
||
DirSpec {
|
||
name: "AACS".into(),
|
||
icb_lba: 14,
|
||
dir_data_lba: 15,
|
||
files: vec![file_with(
|
||
"MKB_RO.inf",
|
||
16,
|
||
5000,
|
||
mkb_type_record(raw),
|
||
true,
|
||
)],
|
||
subdirs: vec![],
|
||
},
|
||
],
|
||
};
|
||
build_udf_skeleton(&mut disc, 10);
|
||
lay_dir(&mut disc, &root);
|
||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||
assert_eq!(
|
||
Disc::detect_disc_format(&mut disc, &udf, &[]),
|
||
expected,
|
||
"MKB type {raw:#010x}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// HD-DVD is a tree-level format — recognized from `HVDVD_TS/`, no MKB.
|
||
#[test]
|
||
fn detect_format_hddvd_from_tree() {
|
||
use crate::udf::fixture::*;
|
||
let mut disc = MemDisc::new();
|
||
let root = DirSpec {
|
||
name: String::new(),
|
||
icb_lba: 10,
|
||
dir_data_lba: 11,
|
||
files: Vec::new(),
|
||
subdirs: vec![DirSpec {
|
||
name: "HVDVD_TS".into(),
|
||
icb_lba: 20,
|
||
dir_data_lba: 21,
|
||
files: Vec::new(),
|
||
subdirs: vec![],
|
||
}],
|
||
};
|
||
build_udf_skeleton(&mut disc, 10);
|
||
lay_dir(&mut disc, &root);
|
||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||
assert_eq!(
|
||
Disc::detect_disc_format(&mut disc, &udf, &[]),
|
||
DiscFormat::HdDvd
|
||
);
|
||
}
|
||
|
||
/// Title selection is by largest physical size, NOT clip count or duration.
|
||
/// Real-disc shape (Fast Five): a 57 GB / 11-clip feature must outrank both a
|
||
/// small 1-clip bonus reel and a long-but-tiny decoy "play-all" (91 reused
|
||
/// clips, 1h31m, 0.4 GB). The old clip-count-ascending key put the bonus t1.
|
||
#[test]
|
||
fn canonical_title_order_picks_largest_feature() {
|
||
fn title_sized(size_bytes: u64, duration_secs: f64, n_clips: usize) -> DiscTitle {
|
||
DiscTitle {
|
||
playlist: String::new(),
|
||
playlist_id: 0,
|
||
duration_secs,
|
||
size_bytes,
|
||
clips: (0..n_clips)
|
||
.map(|i| Clip {
|
||
clip_id: format!("{i:05}"),
|
||
in_time: 0,
|
||
out_time: 0,
|
||
duration_secs: 0.0,
|
||
source_packets: 0,
|
||
})
|
||
.collect(),
|
||
streams: Vec::new(),
|
||
chapters: Vec::new(),
|
||
extents: Vec::new(),
|
||
content_format: ContentFormat::BdTs,
|
||
codec_privates: Vec::new(),
|
||
}
|
||
}
|
||
let capacity = 66_000_000_000u64;
|
||
let feature = title_sized(57_000_000_000, 7860.0, 11); // 2h11m, 11 chapters
|
||
let bonus = title_sized(1_200_000_000, 600.0, 1); // 10m, 1 clip
|
||
let decoy = title_sized(400_000_000, 5460.0, 91); // 1h31m but tiny (reused)
|
||
let mut v = vec![bonus, decoy, feature];
|
||
v.sort_by(|a, b| Disc::canonical_title_order(a, b, capacity));
|
||
assert_eq!(
|
||
v[0].size_bytes, 57_000_000_000,
|
||
"the largest real title is the main feature"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn content_format_default_bdts() {
|
||
let t = title_with_video(Codec::H264, Resolution::R1080p);
|
||
assert_eq!(t.content_format, ContentFormat::BdTs);
|
||
}
|
||
|
||
#[test]
|
||
fn content_format_dvd_mpegps() {
|
||
let t = DiscTitle {
|
||
content_format: ContentFormat::MpegPs,
|
||
..title_with_video(Codec::Mpeg2, Resolution::R480i)
|
||
};
|
||
assert_eq!(t.content_format, ContentFormat::MpegPs);
|
||
}
|
||
|
||
#[test]
|
||
fn disc_capacity_gb() {
|
||
// Single-layer BD-25: ~12,219,392 sectors
|
||
let disc = Disc {
|
||
volume_id: String::new(),
|
||
meta_title: None,
|
||
format: DiscFormat::BluRay,
|
||
capacity_sectors: 12_219_392,
|
||
capacity_bytes: 12_219_392u64 * 2048,
|
||
layers: 1,
|
||
titles: Vec::new(),
|
||
region: DiscRegion::Free,
|
||
aacs: None,
|
||
css: None,
|
||
encrypted: false,
|
||
aacs_error: None,
|
||
css_error: None,
|
||
content_format: ContentFormat::BdTs,
|
||
};
|
||
let gb = disc.capacity_gb();
|
||
// 12,219,392 * 2048 / 1073741824 = ~23.3 GB
|
||
assert!((gb - 23.3).abs() < 0.1, "expected ~23.3 GB, got {}", gb);
|
||
|
||
// Zero sectors
|
||
let disc_zero = Disc {
|
||
capacity_sectors: 0,
|
||
capacity_bytes: 0,
|
||
..disc
|
||
};
|
||
assert_eq!(disc_zero.capacity_gb(), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn disc_title_duration_display_edge_cases() {
|
||
let mut t = DiscTitle::empty();
|
||
|
||
// 0 seconds
|
||
t.duration_secs = 0.0;
|
||
assert_eq!(t.duration_display(), "0h 00m");
|
||
|
||
// 1 second
|
||
t.duration_secs = 1.0;
|
||
assert_eq!(t.duration_display(), "0h 00m");
|
||
|
||
// 59 minutes
|
||
t.duration_secs = 59.0 * 60.0;
|
||
assert_eq!(t.duration_display(), "0h 59m");
|
||
|
||
// 24 hours
|
||
t.duration_secs = 24.0 * 3600.0;
|
||
assert_eq!(t.duration_display(), "24h 00m");
|
||
}
|
||
|
||
fn make_test_disc(sectors: u32, name: &str) -> Disc {
|
||
Disc {
|
||
volume_id: name.into(),
|
||
meta_title: Some(name.into()),
|
||
format: DiscFormat::Uhd,
|
||
capacity_sectors: sectors,
|
||
capacity_bytes: sectors as u64 * 2048,
|
||
layers: 1,
|
||
titles: Vec::new(),
|
||
region: DiscRegion::Free,
|
||
aacs: None,
|
||
css: None,
|
||
encrypted: false,
|
||
aacs_error: None,
|
||
css_error: None,
|
||
content_format: ContentFormat::BdTs,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() {
|
||
// Regression (E8005 deferred-mux loop): a keyed AACS disc swept WITHOUT a
|
||
// keydb scans to aacs=None + aacs_error=KeydbLoad, but its UK is persisted
|
||
// in the mapfile. At remux the UK is recovered and injected — that MUST
|
||
// yield usable decrypt keys. Before the fix, inject_unit_keys no-op'd
|
||
// (no aacs to mutate), decrypt_keys stayed None, and the mux deferred
|
||
// forever with "No keys available (E8005)" despite holding the UK.
|
||
let mut disc = make_test_disc(1000, "UHD");
|
||
disc.encrypted = true;
|
||
disc.aacs_error = Some(crate::error::Error::KeydbLoad {
|
||
path: "<no keydb in search paths>".into(),
|
||
});
|
||
assert!(
|
||
matches!(disc.decrypt_keys(), crate::decrypt::DecryptKeys::None),
|
||
"precondition: encrypted disc with no aacs state => no decrypt keys"
|
||
);
|
||
|
||
let uk = vec![(0u32, [0x11u8; 16])];
|
||
disc.inject_unit_keys(uk.clone());
|
||
|
||
match disc.decrypt_keys() {
|
||
crate::decrypt::DecryptKeys::Aacs {
|
||
unit_keys,
|
||
read_data_key,
|
||
..
|
||
} => {
|
||
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
|
||
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
|
||
}
|
||
_ => panic!("expected Aacs decrypt keys after injecting a UK"),
|
||
}
|
||
assert!(
|
||
disc.aacs_error.is_none(),
|
||
"stale KeydbLoad must be cleared once a UK is in hand"
|
||
);
|
||
assert_eq!(
|
||
disc.aacs.as_ref().unwrap().key_source,
|
||
KeyOrigin::ExternalUk
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn inject_unit_keys_labels_fmts_as_uhd_family() {
|
||
// FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK
|
||
// on an FMTS disc must synthesize the UHD version + bus encryption, not
|
||
// mislabel it AACS 1.0 / bus-off (which would break FMTS decryption on
|
||
// the mapfile-recovered-UK path).
|
||
let mut disc = make_test_disc(1000, "FMTS");
|
||
disc.format = DiscFormat::Fmts;
|
||
disc.encrypted = true;
|
||
disc.inject_unit_keys(vec![(0u32, [0x22u8; 16])]);
|
||
let aacs = disc.aacs.as_ref().expect("aacs state synthesized");
|
||
assert_eq!(
|
||
aacs.version,
|
||
crate::aacs::mkb::AACS_MAJOR_UHD,
|
||
"FMTS is AACS 2.x (UHD major), not BD"
|
||
);
|
||
assert!(aacs.bus_encryption, "FMTS is bus-encrypted like UHD");
|
||
}
|
||
|
||
/// Build an AacsState carrying the given unit keys (other fields are inert
|
||
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
|
||
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
|
||
AacsState {
|
||
version: 2,
|
||
bus_encryption: true,
|
||
mkb_version: None,
|
||
disc_hash: String::new(),
|
||
key_source: KeyOrigin::DeviceKey,
|
||
vuk: None,
|
||
unit_keys,
|
||
read_data_key: None,
|
||
volume_id: [0u8; 16],
|
||
uk_ro: Vec::new(),
|
||
mkb: Vec::new(),
|
||
}
|
||
}
|
||
|
||
// ── ensure_decryptable: the system-wide decrypt verdict matrix ──────────
|
||
//
|
||
// This is the single gate every copy/mux entry point calls. The cases below
|
||
// are the full truth table: only "decryption needed AND unavailable AND not
|
||
// --raw" may error; every legit non-error case (raw / unencrypted / a
|
||
// resolved key) must proceed.
|
||
|
||
fn css_state() -> crate::css::CssState {
|
||
crate::css::CssState {
|
||
title_key: [0u8; 5],
|
||
crack_span: None,
|
||
}
|
||
}
|
||
|
||
/// AACS-encrypted disc, decryption requested, no unit key resolved → the
|
||
/// gate must fail with NoDiscKey (this is the headline bug: a pass-through
|
||
/// `DecryptingSectorSource` would otherwise write ciphertext at exit 0).
|
||
#[test]
|
||
fn ensure_decryptable_aacs_no_key_errors() {
|
||
let mut disc = make_test_disc(1000, "UHD");
|
||
disc.encrypted = true;
|
||
disc.aacs = Some(aacs_with(Vec::new())); // present but no unit keys → None
|
||
assert!(matches!(
|
||
disc.decrypt_keys(),
|
||
crate::decrypt::DecryptKeys::None
|
||
));
|
||
let err = disc
|
||
.ensure_decryptable(false)
|
||
.expect_err("AACS disc, no key, !raw must error");
|
||
assert_eq!(
|
||
err.code(),
|
||
crate::error::Error::NoDiscKey {
|
||
disc_hash: String::new()
|
||
}
|
||
.code()
|
||
);
|
||
}
|
||
|
||
/// E7017 vs E7022 split (rc.6 WS1). When key resolution HAD derivation
|
||
/// material (device / processing keys) but no Volume ID was available to
|
||
/// derive the unit key, the captured `aacs_error` is `AacsVidUnavailable`
|
||
/// — the gate must surface THAT (E7017), not the generic `NoDiscKey`
|
||
/// (E7022). When there was no usable key material at all, the reason is
|
||
/// absent and the gate keeps `NoDiscKey` (E7022). Both branches proven here.
|
||
#[test]
|
||
fn ensure_decryptable_aacs_vid_unavailable_vs_no_key() {
|
||
// Branch 1 — derivation material present, but no VID: E7017.
|
||
// The resolver classifies a device-keys-but-zero-VID context as
|
||
// `VidUnavailable`; that reason rides on `aacs_error`.
|
||
let supplied = crate::aacs::provider::SuppliedKey {
|
||
device_keys: vec![crate::aacs::types::DeviceKey {
|
||
key: [0x11; 16],
|
||
node: 1,
|
||
uv: 1,
|
||
u_mask_shift: 0,
|
||
}],
|
||
processing_keys: Vec::new(),
|
||
media_keys: Vec::new(),
|
||
disc_entry: None,
|
||
};
|
||
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
|
||
// A minimal but parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys)
|
||
// so resolution proceeds to the path-try logic and fails for lack of a
|
||
// VID — not because the .inf failed to parse.
|
||
let mut uk_ro = vec![0u8; 40];
|
||
uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); // uk_pos = 32
|
||
// num_unit_keys = 0 (BE16) at uk_pos -> parses to an empty key file.
|
||
let ctx = crate::aacs::resolve::ResolveContext {
|
||
unit_key_ro: &uk_ro,
|
||
content_cert: None,
|
||
volume_id: &[0u8; 16], // the "no VID" sentinel
|
||
providers: &provider_refs,
|
||
mkb: None,
|
||
};
|
||
assert_eq!(
|
||
crate::aacs::resolve::resolve_keys_with_reason(&ctx, 2).err(),
|
||
Some(crate::aacs::resolve::ResolveFailure::VidUnavailable),
|
||
"device keys + zero VID must classify as VidUnavailable"
|
||
);
|
||
|
||
let mut disc_e7017 = make_test_disc(1000, "UHD");
|
||
disc_e7017.encrypted = true;
|
||
disc_e7017.aacs = Some(aacs_with(Vec::new())); // present but no unit keys
|
||
disc_e7017.aacs_error = Some(crate::error::Error::AacsVidUnavailable);
|
||
let err = disc_e7017
|
||
.ensure_decryptable(false)
|
||
.expect_err("AACS disc, material-but-no-VID, !raw must error");
|
||
assert_eq!(
|
||
err.code(),
|
||
crate::error::Error::AacsVidUnavailable.code(),
|
||
"material-but-no-VID must surface E7017 (AacsVidUnavailable), not E7022"
|
||
);
|
||
|
||
// Branch 2 — no key material at all: classified NoMaterial, gate E7022.
|
||
let supplied_none = crate::aacs::provider::SuppliedKey {
|
||
device_keys: Vec::new(),
|
||
processing_keys: Vec::new(),
|
||
media_keys: Vec::new(),
|
||
disc_entry: None,
|
||
};
|
||
let provider_refs_none: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied_none];
|
||
let ctx_none = crate::aacs::resolve::ResolveContext {
|
||
unit_key_ro: &uk_ro,
|
||
content_cert: None,
|
||
volume_id: &[0u8; 16],
|
||
providers: &provider_refs_none,
|
||
mkb: None,
|
||
};
|
||
assert_eq!(
|
||
crate::aacs::resolve::resolve_keys_with_reason(&ctx_none, 2).err(),
|
||
Some(crate::aacs::resolve::ResolveFailure::NoMaterial),
|
||
"no key material must classify as NoMaterial"
|
||
);
|
||
|
||
let mut disc_e7022 = make_test_disc(1000, "UHD");
|
||
disc_e7022.encrypted = true;
|
||
disc_e7022.aacs = Some(aacs_with(Vec::new()));
|
||
disc_e7022.aacs_error = None; // no reason captured → generic no-key
|
||
let err = disc_e7022
|
||
.ensure_decryptable(false)
|
||
.expect_err("AACS disc, no material, !raw must error");
|
||
assert_eq!(
|
||
err.code(),
|
||
crate::error::Error::NoDiscKey {
|
||
disc_hash: String::new()
|
||
}
|
||
.code(),
|
||
"no-material must keep E7022 (NoDiscKey)"
|
||
);
|
||
}
|
||
|
||
/// Same AACS-no-key disc under `--raw` (raw=true) must PROCEED — the user
|
||
/// asked for the encrypted image and needs no key.
|
||
#[test]
|
||
fn ensure_decryptable_aacs_no_key_raw_proceeds() {
|
||
let mut disc = make_test_disc(1000, "UHD");
|
||
disc.encrypted = true;
|
||
disc.aacs = Some(aacs_with(Vec::new()));
|
||
assert!(disc.ensure_decryptable(true).is_ok(), "--raw must proceed");
|
||
}
|
||
|
||
/// AACS disc WITH a resolved unit key → proceed (decrypt_keys is Aacs).
|
||
#[test]
|
||
fn ensure_decryptable_aacs_with_key_proceeds() {
|
||
let mut disc = make_test_disc(1000, "UHD");
|
||
disc.encrypted = true;
|
||
disc.aacs = Some(aacs_with(vec![(0, [0x11u8; 16])]));
|
||
assert!(disc.ensure_decryptable(false).is_ok());
|
||
}
|
||
|
||
/// A genuinely unencrypted disc has `None` keys legitimately — the gate must
|
||
/// NOT false-error. This is the "is the source encrypted?" guard: the answer
|
||
/// is the scan-captured disc state, not the keys.
|
||
#[test]
|
||
fn ensure_decryptable_unencrypted_proceeds() {
|
||
let disc = make_test_disc(1000, "BD"); // aacs/css/css_error all None
|
||
assert!(matches!(
|
||
disc.decrypt_keys(),
|
||
crate::decrypt::DecryptKeys::None
|
||
));
|
||
assert!(
|
||
disc.ensure_decryptable(false).is_ok(),
|
||
"unencrypted disc with None keys must proceed, not false-error"
|
||
);
|
||
}
|
||
|
||
/// CSS scrambled-but-uncracked (the keyless crack failed): `css` is None but
|
||
/// `css_error` is Some — the disc IS encrypted. The gate must fail 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<usize> {
|
||
let n = count as usize * 2048;
|
||
buf[..n].fill(0); // clear sectors: scramble flag never set
|
||
let _ = self.clear_range;
|
||
Ok(n)
|
||
}
|
||
fn capacity_sectors(&self) -> u32 {
|
||
self.clear_range.1
|
||
}
|
||
}
|
||
|
||
/// Build a multi-VTS CSS disc: `css` cracked from the main feature's span
|
||
/// `[main_lba, main_end)`, plus a clear stub title living in a DISJOINT VTS.
|
||
fn css_disc_with_clear_stub() -> (Disc, usize) {
|
||
let mut disc = make_test_disc(100_000, "DVD");
|
||
disc.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<u8> {
|
||
let uk_pos = 32usize;
|
||
let keys_start = uk_pos + 48;
|
||
let stride = 64usize;
|
||
let mut data = vec![0u8; keys_start + enc_keys.len().max(1) * stride];
|
||
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||
data[uk_pos..uk_pos + 2].copy_from_slice(&(enc_keys.len() as u16).to_be_bytes());
|
||
for (i, k) in enc_keys.iter().enumerate() {
|
||
let off = keys_start + i * stride;
|
||
data[off..off + 16].copy_from_slice(k);
|
||
}
|
||
data
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_with_volume_derives_per_cps_unit_keys() {
|
||
// A Volume key (VUK) is NOT terminal — the lib must decrypt
|
||
// Unit_Key_RO.inf into ONE unit key per CPS unit. Oracle = the lib's
|
||
// own decrypt_unit_key, so this pins the derive-down WIRING (Volume →
|
||
// per-CPS Unit), not the cipher.
|
||
let vuk = [0x5au8; 16];
|
||
let enc0 = [0x12u8; 16];
|
||
let enc1 = [0x34u8; 16];
|
||
let exp0 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc0);
|
||
let exp1 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc1);
|
||
|
||
let mut disc = make_test_disc(1000, "UHD");
|
||
disc.encrypted = true;
|
||
let mut a = aacs_with(Vec::new());
|
||
a.uk_ro = uk_ro_v20(&[enc0, enc1]);
|
||
disc.aacs = Some(a);
|
||
|
||
disc.decrypt_with(Key::Volume(vuk), &[]).unwrap();
|
||
match disc.decrypt_keys() {
|
||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||
assert_eq!(
|
||
unit_keys,
|
||
vec![(1u32, exp0), (2u32, exp1)],
|
||
"VUK must decrypt EACH CPS unit's encrypted key (does not stop at VK)"
|
||
);
|
||
}
|
||
_ => panic!("expected Aacs decrypt keys after Volume-key derive-down"),
|
||
}
|
||
assert_eq!(
|
||
disc.aacs.as_ref().unwrap().key_source,
|
||
KeyOrigin::ExternalUk
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_with_higher_key_without_inputs_errors() {
|
||
// A non-Unit key needs the AACS inputs (Unit_Key_RO.inf) stashed at
|
||
// scan. Without them the lib cannot derive — surfaces AacsNoKeys, not a
|
||
// panic and not a silent keyless "success".
|
||
let mut disc = make_test_disc(1000, "UHD");
|
||
disc.encrypted = true;
|
||
disc.aacs = Some(aacs_with(Vec::new())); // uk_ro empty
|
||
assert!(matches!(
|
||
disc.decrypt_with(Key::Volume([0x11u8; 16]), &[])
|
||
.unwrap_err(),
|
||
crate::error::Error::AacsNoKeys
|
||
));
|
||
|
||
// No AACS state at all → same.
|
||
let mut disc2 = make_test_disc(1000, "UHD");
|
||
disc2.encrypted = true;
|
||
assert!(matches!(
|
||
disc2
|
||
.decrypt_with(Key::Media(vec![[0x22u8; 16]]), &[])
|
||
.unwrap_err(),
|
||
crate::error::Error::AacsNoKeys
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_with_volume_yielding_no_units_is_rejected() {
|
||
// A key that produces zero unit keys (here: an empty key-storage area)
|
||
// is a rejection, not a silent empty success.
|
||
let mut disc = make_test_disc(1000, "UHD");
|
||
disc.encrypted = true;
|
||
let mut a = aacs_with(Vec::new());
|
||
a.uk_ro = uk_ro_v20(&[]); // num_uk = 0
|
||
disc.aacs = Some(a);
|
||
assert!(matches!(
|
||
disc.decrypt_with(Key::Volume([0x11u8; 16]), &[])
|
||
.unwrap_err(),
|
||
crate::error::Error::AacsKeyRejected
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_with_unit_key_yields_decrypt_keys() {
|
||
// The public lookup-free entry point: hand libfreemkv a Key::Unit and
|
||
// decrypt_keys() must return usable AACS state (same path as the
|
||
// deferred-mux resume — autorip resolves the UK and passes it in).
|
||
let mut disc = make_test_disc(1000, "UHD");
|
||
disc.encrypted = true;
|
||
let uk = vec![(0u32, [0x44u8; 16])];
|
||
disc.decrypt_with(Key::Unit(uk.clone()), &[]).unwrap();
|
||
match disc.decrypt_keys() {
|
||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||
assert_eq!(unit_keys, uk);
|
||
}
|
||
_ => panic!("expected Aacs decrypt keys after decrypt_with(Key::Unit)"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unit_key_validation_gates_on_real_ciphertext() {
|
||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||
|
||
// No samples -> nothing to disprove against -> accept (sample-less paths
|
||
// like resume / mapfile must be unaffected).
|
||
assert!(super::aligned_unit_keys_validate(
|
||
&[(0, [0x11u8; 16])],
|
||
None,
|
||
&[],
|
||
ContentFormat::BdTs
|
||
));
|
||
|
||
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
|
||
// accept even with an arbitrary key.
|
||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||
let mut off = 4;
|
||
while off < ALIGNED_UNIT_LEN {
|
||
clear[off] = 0x47;
|
||
off += 192;
|
||
}
|
||
assert!(crate::aacs::content::is_clean(
|
||
&clear,
|
||
crate::disc::ContentFormat::BdTs
|
||
));
|
||
assert!(super::aligned_unit_keys_validate(
|
||
&[(0, [0x11u8; 16])],
|
||
None,
|
||
&[clear.clone()],
|
||
ContentFormat::BdTs
|
||
));
|
||
|
||
// A genuinely scrambled unit the RIGHT key restores to clear TS.
|
||
let uk = [0x5au8; 16];
|
||
let enc = encrypt_unit_for_test(&clear, &uk);
|
||
assert!(
|
||
!crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs),
|
||
"encrypted unit must read scrambled"
|
||
);
|
||
|
||
// Right key -> de-scrambles -> accept (NO false reject of a good key).
|
||
assert!(super::aligned_unit_keys_validate(
|
||
&[(7, uk)],
|
||
None,
|
||
&[enc.clone()],
|
||
ContentFormat::BdTs
|
||
));
|
||
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
|
||
assert!(!super::aligned_unit_keys_validate(
|
||
&[(7, [0x00u8; 16])],
|
||
None,
|
||
&[enc.clone()],
|
||
ContentFormat::BdTs
|
||
));
|
||
// Empty key set against a scrambled sample -> reject.
|
||
assert!(!super::aligned_unit_keys_validate(
|
||
&[],
|
||
None,
|
||
&[enc],
|
||
ContentFormat::BdTs
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn unit_key_validation_rejects_partial_cps_unit_coverage() {
|
||
// Regression: a multi-CPS-unit disc. CPS unit 0's body is scrambled
|
||
// under uk0; CPS unit 1's body under uk1. A resolved key set that
|
||
// covers only CPS unit 0 used to pass validation (the old gate accepted
|
||
// on the FIRST sample any key decrypted), committing an incomplete set —
|
||
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
||
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
||
// that leaves any scrambled sample uncovered.
|
||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||
|
||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||
let mut off = 4;
|
||
while off < ALIGNED_UNIT_LEN {
|
||
clear[off] = 0x47;
|
||
off += 192;
|
||
}
|
||
|
||
let uk0 = [0x11u8; 16];
|
||
let uk1 = [0x22u8; 16];
|
||
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
||
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
||
assert!(!crate::aacs::content::is_clean(
|
||
&sample0,
|
||
crate::disc::ContentFormat::BdTs
|
||
));
|
||
assert!(!crate::aacs::content::is_clean(
|
||
&sample1,
|
||
crate::disc::ContentFormat::BdTs
|
||
));
|
||
|
||
let samples = vec![sample0.clone(), sample1.clone()];
|
||
|
||
// Partial key set (CPS unit 0 only) against samples from BOTH units ->
|
||
// reject. This is the bug fix: previously this returned true.
|
||
assert!(!super::aligned_unit_keys_validate(
|
||
&[(0, uk0)],
|
||
None,
|
||
&samples,
|
||
ContentFormat::BdTs
|
||
));
|
||
|
||
// Complete key set (both CPS units) -> accept.
|
||
assert!(super::aligned_unit_keys_validate(
|
||
&[(0, uk0), (1, uk1)],
|
||
None,
|
||
&samples,
|
||
ContentFormat::BdTs
|
||
));
|
||
|
||
// Order-independent: covering key present anywhere in the set is fine.
|
||
assert!(super::aligned_unit_keys_validate(
|
||
&[(1, uk1), (0, uk0)],
|
||
None,
|
||
&samples,
|
||
ContentFormat::BdTs
|
||
));
|
||
}
|
||
|
||
/// Inverse of `decrypt_unit` for one 6144-byte unit: produce on-disc
|
||
/// ciphertext that `decrypt_unit(uk)` restores to `clear`. Mirrors the AACS
|
||
/// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the
|
||
/// body with the fixed AACS IV.
|
||
fn encrypt_unit_for_test(clear: &[u8], uk: &[u8; 16]) -> Vec<u8> {
|
||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||
use crate::aacs::crypto::AACS_IV;
|
||
use aes::Aes128;
|
||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
|
||
// Flag the unit encrypted (CPI bits on byte 0) before key derivation so
|
||
// the recovered plaintext header matches and `decrypt_unit`'s CPI gate
|
||
// attempts the decrypt.
|
||
unit[0] |= 0xC0;
|
||
let mut header = [0u8; 16];
|
||
header.copy_from_slice(&unit[..16]);
|
||
let cipher = Aes128::new(GenericArray::from_slice(uk));
|
||
let mut blk = GenericArray::clone_from_slice(&header);
|
||
cipher.encrypt_block(&mut blk);
|
||
let mut dk = [0u8; 16];
|
||
for i in 0..16 {
|
||
dk[i] = blk[i] ^ header[i];
|
||
}
|
||
let bc = Aes128::new(GenericArray::from_slice(&dk));
|
||
let mut prev = AACS_IV;
|
||
let mut i = 16;
|
||
while i + 16 <= ALIGNED_UNIT_LEN {
|
||
let mut b = [0u8; 16];
|
||
for j in 0..16 {
|
||
b[j] = unit[i + j] ^ prev[j];
|
||
}
|
||
let mut g = GenericArray::clone_from_slice(&b);
|
||
bc.encrypt_block(&mut g);
|
||
for j in 0..16 {
|
||
unit[i + j] = g[j];
|
||
}
|
||
prev.copy_from_slice(&unit[i..i + 16]);
|
||
i += 16;
|
||
}
|
||
unit
|
||
}
|
||
|
||
#[test]
|
||
fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() {
|
||
// Unencrypted disc: nothing to inject into, stays None.
|
||
let mut plain = make_test_disc(1000, "PLAIN");
|
||
plain.inject_unit_keys(vec![(0, [0x22; 16])]);
|
||
assert!(plain.aacs.is_none());
|
||
assert!(matches!(
|
||
plain.decrypt_keys(),
|
||
crate::decrypt::DecryptKeys::None
|
||
));
|
||
|
||
// Encrypted CSS (DVD): an AACS UK must NOT synthesize an AACS state.
|
||
let mut dvd = make_test_disc(1000, "DVD");
|
||
dvd.format = DiscFormat::Dvd;
|
||
dvd.encrypted = true;
|
||
dvd.css = Some(crate::css::CssState {
|
||
title_key: [0u8; 5],
|
||
crack_span: None,
|
||
});
|
||
dvd.inject_unit_keys(vec![(0, [0x33; 16])]);
|
||
assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state");
|
||
}
|
||
|
||
/// Records the LBAs read; returns all-zero (unscrambled) sectors so any
|
||
/// re-crack attempt finds no key and falls back, while we observe WHETHER
|
||
/// the title's extents were read at all.
|
||
struct RecordingSource {
|
||
reads: std::cell::RefCell<Vec<u32>>,
|
||
}
|
||
impl SectorSource for RecordingSource {
|
||
fn read_sectors(
|
||
&mut self,
|
||
lba: u32,
|
||
count: u16,
|
||
buf: &mut [u8],
|
||
_recovery: bool,
|
||
) -> Result<usize> {
|
||
self.reads.borrow_mut().push(lba);
|
||
let n = (count as usize * 2048).min(buf.len());
|
||
for b in buf[..n].iter_mut() {
|
||
*b = 0;
|
||
}
|
||
Ok(n)
|
||
}
|
||
}
|
||
|
||
fn css_disc_with_two_vts() -> Disc {
|
||
// Title 0 (cracked VTS) at LBA 100..200; title 1 (other VTS) at
|
||
// 5000..5100. The cracked key's span is title 0's extents.
|
||
let mut t0 = title_with_video(Codec::Mpeg2, Resolution::R480p);
|
||
t0.extents = vec![Extent {
|
||
start_lba: 100,
|
||
sector_count: 100,
|
||
}];
|
||
let mut t1 = title_with_video(Codec::Mpeg2, Resolution::R480p);
|
||
t1.playlist = "00801.mpls".into();
|
||
t1.extents = vec![Extent {
|
||
start_lba: 5000,
|
||
sector_count: 100,
|
||
}];
|
||
let mut disc = make_test_disc(6000, "DVD");
|
||
disc.format = DiscFormat::Dvd;
|
||
disc.content_format = ContentFormat::MpegPs;
|
||
disc.encrypted = true;
|
||
disc.titles = vec![t0, t1];
|
||
disc.css = Some(crate::css::CssState {
|
||
title_key: [0xAB; 5],
|
||
crack_span: Some((100, 200)),
|
||
});
|
||
disc
|
||
}
|
||
|
||
/// 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<Vec<u32>>,
|
||
}
|
||
impl SectorSource for CssMapReader {
|
||
fn read_sectors(
|
||
&mut self,
|
||
lba: u32,
|
||
count: u16,
|
||
buf: &mut [u8],
|
||
_recovery: bool,
|
||
) -> Result<usize> {
|
||
self.reads.borrow_mut().push(lba);
|
||
let n = (count as usize * 2048).min(buf.len());
|
||
for 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<Extent>) -> 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<usize> {
|
||
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<Vec<(u32, u16)>>,
|
||
data: Vec<u8>,
|
||
}
|
||
impl SectorSource for ThdSpyReader {
|
||
fn read_sectors(
|
||
&mut self,
|
||
lba: u32,
|
||
count: u16,
|
||
buf: &mut [u8],
|
||
_recovery: bool,
|
||
) -> Result<usize> {
|
||
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<u8> {
|
||
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<u8> {
|
||
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<Extent>) -> 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<Chapter> {
|
||
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::<HdrFormat>(),
|
||
Ok(HdrFormat::DolbyVision)
|
||
);
|
||
assert_eq!("HDR10+".parse::<HdrFormat>(), Ok(HdrFormat::Hdr10Plus));
|
||
assert_eq!("HLG".parse::<HdrFormat>(), Ok(HdrFormat::Hlg));
|
||
// An unrecognised string is an error, never a silent SDR.
|
||
assert_eq!("not-an-hdr-format".parse::<HdrFormat>(), 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::<ColorSpace>(), Ok(ColorSpace::Bt2020));
|
||
assert_eq!("BT.470BG".parse::<ColorSpace>(), Ok(ColorSpace::Bt470bg));
|
||
assert_eq!(
|
||
"SMPTE 170M".parse::<ColorSpace>(),
|
||
Ok(ColorSpace::Smpte170m)
|
||
);
|
||
assert_eq!("bt2020".parse::<ColorSpace>(), Ok(ColorSpace::Bt2020));
|
||
assert_eq!("nonsense".parse::<ColorSpace>(), 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<u16> = 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<u16> = title.subtitle_streams().map(|s| s.pid).collect();
|
||
assert_eq!(subs, vec![0x1200]);
|
||
let video: Vec<u16> = 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 `<output>.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 `<path>.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")
|
||
);
|
||
}
|
||
}
|