Files
libfreemkv/src/session.rs
T
Matthew Jackson b79ff71b43 Fix read-fault misclassification, DTS AMODE channel table, and untestable guards
- resolve_fmts_key_map: distinguish a genuinely-not-FMTS disc from a
  transient live-drive read fault. read_filesystem now returns the new
  Error::UdfNotFilesystem for a deterministic tag/format mismatch (no AVDP,
  no partition descriptor, no FSD); resolve maps only UdfNotFilesystem (fs)
  and UdfNotFound (.tbl absent) to Ok(None), and PROPAGATES DiscRead / other
  I/O faults so a marginal AACS 2.1 disc fails loud instead of silently
  dropping forensic content under a base-Unit-Key-only map.

- DTS_AMODE_CH (mp4/audio.rs): extend 10→16 entries
  {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8} (the spec per-AMODE channel table / ETSI TS 102 114) so
  the spec-legal high AMODEs that now pass the decodability gate declare
  their true channelcount (AMODE 13→7, 14/15→8) instead of a truncated 6.

- session.rs resolve_keys "called before scan" guard is now testable:
  from_parts_for_test takes Option<Disc>; added a test that a disc-less
  session returns a clean DeviceNotReady Err rather than panicking.

- mp4/read.rs: a track with samples but a missing/malformed stts (mandatory
  per ISO/IEC 14496-12) is dropped rather than emitting all-zero timestamps,
  matching the existing stco/stsc guards; all-tracks-dropped → Mp4Invalid.

- Remove the inert MuxInput::Iso.key_map field (the Iso path re-derives its
  map inside build_iso_pipeline); the live path keeps Live.key_map.

All four fixes are mutation-verified.
2026-07-24 09:59:33 -07:00

758 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Disc session — one place that opens an optical drive and brings the SCSI
//! transport up, so the consumers (CLI, autorip) stop hand-rolling the
//! `open → wait_ready → init → probe_disc → identify → scan` preamble.
//!
//! The session owns the [`Drive`] by value (tray unlock stays guaranteed via
//! `Drive::drop`) and, after [`DiscSession::scan`], the resulting [`Disc`].
//! Lifecycle is intentionally SPLIT — `open` does transport mechanics only,
//! `identify` / `scan` are separate — so a consumer can fetch a poster off a
//! fast `identify` and update its UI before committing to a full `scan`.
//!
//! libfreemkv resolves no keys and reads no keydb: the consumer builds the
//! host credentials / key-source layer (from `freemkv_keysources`) and hands
//! them in via [`KeySpec`]; the session merely FORWARDS them into
//! [`ScanOptions`] at scan time. No cert derivation happens here.
use crate::aacs::trace::ResolutionTrace;
use crate::disc::{Disc, DiscId, DriveCredentials, ScanOptions};
use crate::drive::{Drive, find_drive};
use crate::error::{Error, Result};
use crate::keysource::{
KeySource, MIN_SAMPLE_UNITS, key_fetch, read_encrypted_units, resolve_and_apply_traced,
};
use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// A consumer-supplied factory for the ordered AACS key-source layer.
///
/// libfreemkv builds no key sources itself (the `freemkv_keysources` crate that
/// implements [`KeySource`] depends on libfreemkv, not the other way round), so
/// the consumer hands in a way to (re)build its sources. It is invoked once for
/// the up-front resolve and again per on-decrypt-miss fetch (the cold path), so
/// it stays `Send + Sync` without requiring `KeySource: Send`. Mirrors the
/// `make_sources` argument [`key_fetch`] already takes.
pub type KeySourceFactory = Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>;
/// The outcome of resolving a disc's base AACS unit keys: the structured
/// per-source [`ResolutionTrace`] (for the consumer to render) plus the
/// read-time [`KeyFetch`] built from the disc's public inputs.
///
/// `key_fetch` is `None` only for a disc that carries no AACS inputs (an
/// unencrypted / CSS / non-AACS disc); it is `Some` whenever the disc is AACS,
/// independent of whether a key actually resolved — the on-decrypt-miss fetch is
/// wired the same way regardless.
pub struct ResolvedKeys {
/// Per-source walk of the resolve, for the consumer to render (English-free
/// typed enums only; the app layer maps them to text).
pub trace: ResolutionTrace,
/// The read-time fetch closure, or `None` for a non-AACS disc.
pub key_fetch: Option<KeyFetch>,
}
/// Resolve and bank a keyless-scanned disc's BASE AACS unit keys, and build the
/// read-time [`KeyFetch`] — the one place the sampling / ordered-apply / banking
/// / fetch-construction glue lives, so the CLI and autorip stop hand-rolling it.
///
/// Steps, identical to what the consumers did inline:
/// 1. Take the disc's public AACS inputs ([`Disc::inputs`]); a non-AACS disc has
/// none, so this is a no-op returning an empty trace and no fetch.
/// 2. Sample up to [`MIN_SAMPLE_UNITS`] encrypted content units from the LARGEST
/// title via `reader` ([`read_encrypted_units`]) so a candidate key is
/// validated against real ciphertext. Skipped (no wasted read) when the
/// factory yields no sources — resolution is then a guaranteed miss anyway.
/// 3. Run the ordered sources first-valid-wins ([`resolve_and_apply_traced`]),
/// which banks the winning unit keys onto `disc`'s AACS state.
/// 4. Build the read-time [`KeyFetch`] from the disc's inputs (its per-call
/// samples are swapped in by the closure) using the same source factory.
///
/// The `reader` is whatever the disc lives behind — a live [`Drive`] or a
/// file-backed [`SectorSource`] from [`scan_iso`]; both implement
/// [`SectorSource`].
pub fn resolve_keys_for(
reader: &mut dyn SectorSource,
disc: &mut Disc,
sources: KeySourceFactory,
) -> ResolvedKeys {
// A disc with no captured AACS inputs is unencrypted / CSS / non-AACS —
// nothing to resolve, nothing to fetch.
let Some(mut inputs) = disc.inputs() else {
return ResolvedKeys {
trace: ResolutionTrace::new(),
key_fetch: None,
};
};
// Build the ordered sources once for the up-front resolve. Sampling reads the
// disc, so skip it when there is no source to validate against (a dropped /
// SSRF-rejected online-only source) — resolution is a miss regardless and the
// read would be pure waste.
let src_vec = sources();
inputs.samples = if src_vec.is_empty() {
Vec::new()
} else {
match disc.titles.iter().max_by_key(|t| t.size_bytes).cloned() {
Some(title) => read_encrypted_units(reader, &title, MIN_SAMPLE_UNITS),
None => Vec::new(),
}
};
// Ordered, first-valid-wins; banks the winning unit keys onto `disc`.
let (_resolved, trace) = resolve_and_apply_traced(&src_vec, &inputs, disc);
// Build the read-time fetch from the disc's public inputs (fresh, so it
// reflects any banked state); its per-fetch `samples` are filled by the
// closure. `inputs()` is still `Some` here (the disc is AACS).
let fetch_inputs = disc.inputs().unwrap_or(inputs);
let fetch = key_fetch(fetch_inputs, sources);
ResolvedKeys {
trace,
key_fetch: Some(fetch),
}
}
/// Which optical device a [`DiscSession`] should open.
pub enum DeviceTarget {
/// Open this exact device path (e.g. `/dev/sg0`).
Path(PathBuf),
/// Enumerate drives and pick one that currently has media
/// (see [`find_drive`]).
Autodetect,
}
/// Consumer-supplied key material for the live-drive AACS handshake.
///
/// libfreemkv does NOT read `keydb.cfg`, build a `KeydbSource`, or extract host
/// certs — that layer lives in the application (`freemkv_keysources`), which
/// depends on libfreemkv, not the other way round. The consumer builds the
/// credentials / key-source layer and passes them in here; [`DiscSession::scan`]
/// forwards them into [`ScanOptions`]. The `keydb_path` / `key_url` / `key_auth`
/// fields are carried purely for the CONSUMER's own bookkeeping — the library
/// ignores them.
#[derive(Default)]
pub struct KeySpec {
/// Consumer bookkeeping only — the library does not read it.
pub keydb_path: Option<PathBuf>,
/// Consumer bookkeeping only — the library does not read it.
pub key_url: Option<String>,
/// Consumer bookkeeping only — the library does not read it.
pub key_auth: Option<String>,
/// Host cert(s) for the live-drive handshake, pre-built by the consumer.
/// Forwarded to [`ScanOptions::credentials`] at scan time.
pub credentials: Option<DriveCredentials>,
/// Consumer-built key-source layer; the handshake collects host certs
/// across these. Moved into [`ScanOptions::key_sources`] at scan time.
pub key_sources: Vec<Box<dyn KeySource>>,
}
/// An opened optical drive plus the disc scanned off it.
///
/// Owns the [`Drive`] by value. Consumers that still need the raw drive (e.g.
/// to sample ciphertext for key validation, or to move it into a
/// `DiscStream`) reach it via [`Self::drive_mut`] / [`Self::into_drive`]; the
/// scanned [`Disc`] comes out via [`Self::disc`] / [`Self::take_disc`].
pub struct DiscSession {
/// The opened drive. `Some` from [`Self::open`] until
/// [`Self::stage_drive_as_reader`] (live-drive mux) or [`Self::into_drive`]
/// moves it out. The cached [`Self::device_path`] survives that move so the
/// mux driver can still name the device in an error without the drive.
drive: Option<Drive>,
/// The drive's device path, cached at [`Self::open`] so it outlives a
/// [`Self::stage_drive_as_reader`] that moves the drive into `reader`.
device: String,
spec: KeySpec,
disc: Option<Disc>,
/// Sector source for a later file/live mux to `.take()` (steps 34). The
/// file path stages a `FileSectorSource`; the live-drive path stages the
/// drive itself via [`Self::stage_drive_as_reader`].
reader: Option<Box<dyn SectorSource>>,
/// The read-time AACS fetch closure, built by [`Self::resolve_keys`] and
/// retained so a later mux (step 4) can install it into the decrypt
/// decorator. `None` until keys are resolved / for a non-AACS disc.
key_fetch: Option<KeyFetch>,
}
/// Overlay the session's consumer-supplied key material onto a caller's
/// [`ScanOptions`], without ever clobbering what the caller already set.
///
/// Pure (no drive I/O) so the KeySpec → ScanOptions derivation is unit-testable
/// without hardware. `credentials` is copied (it is `Clone`); `key_sources` is
/// MOVED out of the spec (trait objects are not `Clone`), leaving the spec's
/// vec empty once consumed.
fn forward_key_material(spec: &mut KeySpec, mut opts: ScanOptions) -> ScanOptions {
if opts.credentials.is_none() {
opts.credentials = spec.credentials.clone();
}
if opts.key_sources.is_empty() {
opts.key_sources = std::mem::take(&mut spec.key_sources);
}
opts
}
impl DiscSession {
/// Open a drive and bring the SCSI transport up.
///
/// Resolves the device (`Autodetect` → [`find_drive`]), opens it (FATAL —
/// the only hard failure here), then runs `wait_ready` → `init` →
/// `probe_disc`. Those three are ADVISORY exactly as every consumer treated
/// them: a failure is logged via `tracing` and discarded — the later
/// [`Self::scan`] is the authoritative gate. No scan, no identify, no key
/// resolution runs here.
pub fn open(target: DeviceTarget, spec: KeySpec) -> Result<DiscSession> {
let mut drive = match target {
DeviceTarget::Path(ref path) => Drive::open(path)?,
// Autodetect yields an already-opened drive; a missing drive is a
// typed `DeviceNotFound` the application maps to its own message.
DeviceTarget::Autodetect => find_drive().ok_or_else(|| Error::DeviceNotFound {
path: String::new(),
})?,
};
// Advisory bring-up — non-fatal in every consumer today. Preserve that:
// log and continue, never propagate. (The CLI printed these to stderr /
// discarded them; autorip `tracing::warn`'d them. The advisory SEMANTICS
// are what matter and are preserved identically; the sink is now here.)
if let Err(e) = drive.wait_ready() {
tracing::warn!(target: "freemkv::session", error = %e, "wait_ready advisory failed (continuing)");
}
if let Err(e) = drive.init() {
tracing::warn!(target: "freemkv::session", error = %e, "init advisory failed (continuing)");
}
if let Err(e) = drive.probe_disc() {
tracing::warn!(target: "freemkv::session", error = %e, "probe_disc advisory failed (continuing)");
}
let device = drive.device_path().to_string();
Ok(DiscSession {
drive: Some(drive),
device,
spec,
disc: None,
reader: None,
key_fetch: None,
})
}
/// Fast disc identification — name/format only, no playlist parse. Wraps
/// [`Disc::identify`].
pub fn identify(&mut self) -> Result<DiscId> {
Disc::identify(self.drive_mut())
}
/// Full structure scan. Forwards the session's [`KeySpec`] credentials /
/// key-sources into `opts` (without clobbering anything the caller already
/// set), runs [`Disc::scan`], stores the result, and returns a borrow.
pub fn scan(&mut self, opts: ScanOptions) -> Result<&Disc> {
let opts = forward_key_material(&mut self.spec, opts);
let disc = Disc::scan(self.drive.as_mut().expect("drive present for scan"), &opts)?;
self.disc = Some(disc);
Ok(self.disc.as_ref().expect("disc just stored"))
}
/// Resolve and bank the scanned disc's base AACS unit keys from the
/// consumer-supplied `sources`, and retain the read-time [`KeyFetch`] on the
/// session (see [`Self::key_fetch`]) for a later mux.
///
/// Samples ciphertext through the session's own reader — the staged file
/// reader if one is present, otherwise the live drive — so it works for both
/// a live-drive session and a file-backed one. Returns the structured
/// [`ResolutionTrace`] for the consumer to render; a non-AACS disc resolves
/// to an empty trace with no error. Requires [`Self::scan`] to have run.
pub fn resolve_keys(&mut self, sources: KeySourceFactory) -> Result<ResolutionTrace> {
// The disc must have been scanned so its AACS inputs are captured.
if self.disc.is_none() {
return Err(Error::DeviceNotReady {
path: self.device.clone(),
});
}
// Sample through the staged reader when present (file-backed), else the
// live drive. `self.reader` / `self.disc` / `self.drive` are disjoint
// fields, so the borrows below don't conflict.
let resolved = if let Some(reader) = self.reader.as_mut() {
let disc = self.disc.as_mut().expect("disc present (checked above)");
resolve_keys_for(reader.as_mut(), disc, sources)
} else {
let disc = self.disc.as_mut().expect("disc present (checked above)");
resolve_keys_for(
self.drive.as_mut().expect("drive present for key sampling"),
disc,
sources,
)
};
self.key_fetch = resolved.key_fetch;
Ok(resolved.trace)
}
/// The read-time AACS fetch closure retained by [`Self::resolve_keys`], for a
/// later mux (step 4) to install into the decrypt decorator. `None` before
/// keys are resolved, or for a non-AACS disc.
pub fn key_fetch(&self) -> Option<&KeyFetch> {
self.key_fetch.as_ref()
}
/// The scanned disc, if [`Self::scan`] has run.
pub fn disc(&self) -> Option<&Disc> {
self.disc.as_ref()
}
/// Mutable access to the scanned disc, if [`Self::scan`] has run.
pub fn disc_mut(&mut self) -> Option<&mut Disc> {
self.disc.as_mut()
}
/// Take ownership of the scanned disc out of the session, leaving `None`.
/// Consumers that need the owned `Disc` alongside a live `&mut Drive`
/// (key-resolution, per-title crack) take the disc, then borrow the drive.
pub fn take_disc(&mut self) -> Option<Disc> {
self.disc.take()
}
/// Shared access to the opened drive (identity, profile, path). Panics if the
/// drive has already been staged into the reader slot
/// ([`Self::stage_drive_as_reader`]) or moved out via [`Self::into_drive`] —
/// use [`Self::device_path`] for a name that survives those moves.
pub fn drive(&self) -> &Drive {
self.drive.as_ref().expect("drive present")
}
/// The opened drive's device path. Cached at [`Self::open`], so it remains
/// available after [`Self::stage_drive_as_reader`] moves the drive into the
/// reader slot (the mux driver names the device here without the drive).
pub fn device_path(&self) -> &str {
&self.device
}
/// Mutable access to the opened drive — for ciphertext sampling and other
/// direct reads consumers still perform.
pub fn drive_mut(&mut self) -> &mut Drive {
self.drive.as_mut().expect("drive present")
}
/// Lock the tray so the disc cannot eject mid-rip. Unlock is guaranteed by
/// `Drive::drop`. A no-op if the drive is no longer held by the session.
pub fn lock_tray(&mut self) {
if let Some(drive) = self.drive.as_mut() {
drive.lock_tray();
}
}
/// Consume the session, returning the owned drive (e.g. to move into a
/// `DiscStream` for a live-drive mux).
pub fn into_drive(self) -> Drive {
self.drive.expect("drive present")
}
/// Stage the owned drive as the session's boxed sector source so a live
/// single-pass mux can drive it through
/// [`MuxInput::Session`](crate::mux::MuxInput::Session). Moves the `Drive`
/// (itself a [`SectorSource`]) into the `reader` slot; the cached
/// [`Self::device_path`] keeps the device name available afterward. A no-op
/// if the drive was already staged or moved out.
pub fn stage_drive_as_reader(&mut self) {
if let Some(drive) = self.drive.take() {
self.reader = Some(Box::new(drive));
}
}
/// Consume the session, returning the sector source staged for a later mux
/// (steps 34). `None` until that path populates it.
pub fn into_reader(self) -> Option<Box<dyn SectorSource>> {
self.reader
}
/// Take the staged sector source out of the session by mutable borrow,
/// leaving `None` behind. Used by [`crate::mux::mux_stream`]'s
/// [`MuxInput::Session`](crate::mux::MuxInput::Session) arm, which drives
/// the mux from `&mut DiscSession` and so cannot consume the whole session.
/// A second call (or a call before the reader is staged) returns `None`, and
/// the driver maps that to a clean error rather than a panic (see Q2 of the
/// boundary-audit contract).
pub fn take_reader(&mut self) -> Option<Box<dyn SectorSource>> {
self.reader.take()
}
/// Test-only constructor: build a session over an INJECTED reader + already-
/// scanned disc WITHOUT opening a live [`Drive`]. `DiscSession::open` needs
/// real hardware, so this is the only way to exercise the
/// [`MuxInput::Session`](crate::mux::MuxInput::Session) mux arm (take_reader →
/// resolve_inline_base_map → DiscStream → with_key_map) and
/// [`Self::resolve_keys`]'s title-sampling branch against a synthetic reader.
///
/// The drive slot stays `None` (a `MuxInput::Session` mux never touches it —
/// it reads through the staged `reader`); `device` carries a sentinel path so
/// the driver's missing-reader error still has a name.
///
/// `disc` is an `Option` so a test can construct a session that has NOT been
/// scanned (`None`) to exercise the `resolve_keys` "called before scan" guard.
#[cfg(test)]
pub(crate) fn from_parts_for_test(
disc: Option<Disc>,
reader: Option<Box<dyn SectorSource>>,
key_fetch: Option<KeyFetch>,
) -> DiscSession {
DiscSession {
drive: None,
device: "test://session".to_string(),
spec: KeySpec::default(),
disc,
reader,
key_fetch,
}
}
}
/// Scan an ISO image's structure from a file path, returning the scanned
/// [`Disc`] together with a reusable [`SectorSource`] over the same file.
///
/// This is the file-backed counterpart to [`DiscSession::scan`]: it is the one
/// place that opens a [`FileSectorSource`], reads its capacity, and runs
/// [`Disc::scan_image`], so consumers (CLI, autorip) stop hand-rolling that
/// triple and stop constructing the low-level reader themselves. No SCSI, no
/// handshake, no key resolution — AACS resolution during the scan uses only
/// whatever `opts` already carries (mirroring how `Disc::scan_image` forwards
/// `ScanOptions`).
///
/// The returned reader is a fresh handle positioned at the start of the image;
/// callers that need to sample ciphertext (key resolution) or feed a mux can
/// reuse it directly rather than re-opening the file. `Disc::scan_image` reads
/// only through the same reader, and all reads are LBA-addressed, so the
/// handle is fully reusable afterward.
pub fn scan_iso(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorSource>)> {
let mut reader = FileSectorSource::open(path)?;
let capacity = reader.capacity_sectors();
let disc = Disc::scan_image(&mut reader, capacity, &opts)?;
Ok((disc, Box::new(reader)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::types::{HostCert, UnitKey};
use crate::keysource::ResolveCtx;
fn creds_with(n: usize) -> DriveCredentials {
DriveCredentials {
host_certs: (0..n)
.map(|_| HostCert {
private_key: [0u8; 20],
certificate: Vec::new(),
private_key_v2: None,
certificate_v2: None,
})
.collect(),
}
}
struct TestSource;
impl KeySource for TestSource {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>> {
Ok(Vec::new())
}
fn label(&self) -> &'static str {
"test-source"
}
}
#[test]
fn forwards_spec_credentials_into_empty_opts() {
let mut spec = KeySpec {
credentials: Some(creds_with(2)),
..Default::default()
};
let opts = forward_key_material(&mut spec, ScanOptions::default());
// Kills the "drop the forward" mutant.
assert_eq!(opts.credentials.map(|c| c.host_certs.len()), Some(2));
}
#[test]
fn does_not_clobber_caller_credentials() {
let mut spec = KeySpec {
credentials: Some(creds_with(2)),
..Default::default()
};
let opts = ScanOptions {
credentials: Some(creds_with(5)),
..Default::default()
};
let opts = forward_key_material(&mut spec, opts);
// Kills a mutant that flips `is_none()` → always-overwrite.
assert_eq!(opts.credentials.map(|c| c.host_certs.len()), Some(5));
// The unused spec creds stay put.
assert_eq!(spec.credentials.map(|c| c.host_certs.len()), Some(2));
}
#[test]
fn moves_spec_key_sources_into_empty_opts() {
let mut spec = KeySpec {
key_sources: vec![Box::new(TestSource)],
..Default::default()
};
let opts = forward_key_material(&mut spec, ScanOptions::default());
assert_eq!(opts.key_sources.len(), 1);
assert_eq!(opts.key_sources[0].label(), "test-source");
// Moved, not cloned — the spec is emptied (kills a copy-instead-of-move
// mutant, and confirms the take()).
assert!(spec.key_sources.is_empty());
}
#[test]
fn does_not_clobber_caller_key_sources() {
let mut spec = KeySpec {
key_sources: vec![Box::new(TestSource)],
..Default::default()
};
let opts = ScanOptions {
key_sources: vec![Box::new(TestSource), Box::new(TestSource)],
..Default::default()
};
let opts = forward_key_material(&mut spec, opts);
// Kills a mutant that flips `is_empty()` → always-overwrite.
assert_eq!(opts.key_sources.len(), 2);
// Caller's non-empty vec means the spec is left untouched.
assert_eq!(spec.key_sources.len(), 1);
}
#[test]
fn keyspec_default_is_all_empty() {
let spec = KeySpec::default();
assert!(spec.keydb_path.is_none());
assert!(spec.key_url.is_none());
assert!(spec.key_auth.is_none());
assert!(spec.credentials.is_none());
assert!(spec.key_sources.is_empty());
}
// ── resolve_keys_for: sampling → ordered apply → bank → fetch ─────────────
/// A no-op reader — the resolve tests use discs with no titles, so no
/// sampling read fires; this satisfies the `&mut dyn SectorSource` seam.
struct NullReader;
impl SectorSource for NullReader {
fn capacity_sectors(&self) -> u32 {
0
}
fn read_sectors(&mut self, _: u32, _: u16, _: &mut [u8], _: bool) -> Result<usize> {
Ok(0)
}
}
/// A source that hands back one terminal Unit Key.
struct HasUnitKey([u8; 16]);
impl KeySource for HasUnitKey {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>> {
Ok(vec![UnitKey::new(0, self.0)])
}
fn label(&self) -> &'static str {
"has-key"
}
}
/// A source with no key for this disc.
struct NoUnitKey;
impl KeySource for NoUnitKey {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>> {
Ok(Vec::new())
}
fn label(&self) -> &'static str {
"empty"
}
}
/// A minimal keyless AACS `Disc` — `inputs()` returns `Some`, so
/// `resolve_keys_for` proceeds to the sources. No titles (no sampling read).
fn aacs_disc() -> Disc {
Disc {
volume_id: "TEST".into(),
meta_title: None,
format: crate::DiscFormat::Uhd,
capacity_sectors: 0,
capacity_bytes: 0,
layers: 1,
titles: Vec::new(),
region: crate::disc::DiscRegion::Free,
aacs: Some(crate::disc::AacsState {
version: crate::aacs::mkb::AACS_MAJOR_UHD,
bus_encryption: false,
mkb_version: None,
disc_hash: "0xabc".into(),
key_source: crate::disc::KeyOrigin::KeyDb,
vuk: None,
unit_keys: Vec::new(),
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
}),
css: None,
encrypted: true,
aacs_error: None,
css_error: None,
content_format: crate::ContentFormat::BdTs,
}
}
fn factory_of<S: KeySource + 'static>(make: fn() -> S) -> KeySourceFactory {
Arc::new(move || vec![Box::new(make()) as Box<dyn KeySource>])
}
/// The happy path: a source's Unit Key is BANKED onto the disc's AACS state
/// (so `decrypt_keys()` now yields it) and a `KeyFetch` is retained.
///
/// Mutation guard: if the banking step (`resolve_and_apply_traced`) is
/// dropped, `decrypt_keys()` stays `None` and this assertion fails.
#[test]
fn resolve_keys_for_banks_unit_key_and_builds_fetch() {
use crate::decrypt::DecryptKeys;
const K: [u8; 16] = [0x5A; 16];
let mut disc = aacs_disc();
let mut reader = NullReader;
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey(K)));
match disc.decrypt_keys() {
DecryptKeys::Aacs { unit_keys, .. } => {
// CPS-unit number is positional index + 1 (idx 0 → unit 1).
assert_eq!(unit_keys, vec![(1u32, K)], "the source's key is banked");
}
_ => panic!("expected banked AACS keys"),
}
assert!(
resolved.key_fetch.is_some(),
"an AACS disc always retains a read-time fetch"
);
// The trace recorded exactly one source, which resolved.
assert_eq!(resolved.trace.keys.len(), 1);
}
/// A source with no key: nothing is banked (`decrypt_keys()` stays `None`),
/// but a `KeyFetch` is STILL built (the on-decrypt-miss path is wired
/// regardless of the up-front resolve succeeding).
#[test]
fn resolve_keys_for_no_key_leaves_disc_unkeyed_but_builds_fetch() {
use crate::decrypt::DecryptKeys;
let mut disc = aacs_disc();
let mut reader = NullReader;
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| NoUnitKey));
assert!(
matches!(disc.decrypt_keys(), DecryptKeys::None),
"no source key ⇒ disc stays unkeyed"
);
assert!(
resolved.key_fetch.is_some(),
"an AACS disc retains a fetch even when the up-front resolve misses"
);
}
/// A counting reader over zeros — records the highest LBA sampled so the test
/// can prove the LARGEST title's extent (not the small one) was read.
struct SamplingReader {
reads: u32,
max_lba: u32,
}
impl SectorSource for SamplingReader {
fn capacity_sectors(&self) -> u32 {
100_000
}
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result<usize> {
self.reads += 1;
self.max_lba = self.max_lba.max(lba);
let want = count as usize * 2048;
buf[..want].fill(0);
Ok(want)
}
}
/// `resolve_keys_for` samples the LARGEST title's ciphertext through the
/// reader when a source is configured (the `session.rs:90` sampling branch).
/// The other tests use a title-less disc (no sampling read), so this branch
/// was uncovered. With two titles and a non-empty source, the sampling read
/// fires against the LARGER title's extent.
#[test]
fn resolve_keys_for_samples_largest_title_through_reader() {
use crate::disc::{DiscTitle, Extent};
let mut disc = aacs_disc();
let mut small = DiscTitle::empty();
small.size_bytes = 1_000;
small.extents = vec![Extent {
start_lba: 100,
sector_count: 300,
}];
let mut large = DiscTitle::empty();
large.size_bytes = 9_000_000;
large.extents = vec![Extent {
start_lba: 9_000,
sector_count: 300,
}];
disc.titles = vec![small, large];
let mut reader = SamplingReader {
reads: 0,
max_lba: 0,
};
// Non-empty source ⇒ the sampling read is NOT skipped.
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16])));
assert!(
reader.reads > 0,
"the largest title was sampled via the reader"
);
assert!(
reader.max_lba >= 9_000,
"sampling read the LARGER title's extent (lba>=9000), not the small one \
(max_lba={})",
reader.max_lba
);
assert!(
resolved.key_fetch.is_some(),
"an AACS disc still retains a read-time fetch"
);
}
/// A non-AACS disc (CSS / unencrypted — `inputs()` is `None`): resolution is a
/// no-op. Empty trace, NO fetch, disc untouched. This is the out-of-the-box
/// CSS/None path that must keep working with no keydb.
#[test]
fn resolve_keys_for_non_aacs_disc_is_a_noop() {
use crate::decrypt::DecryptKeys;
let mut disc = aacs_disc();
disc.aacs = None; // now carries no AACS inputs
disc.encrypted = false;
let mut reader = NullReader;
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16])));
assert!(
resolved.trace.keys.is_empty() && resolved.trace.unlock.is_empty(),
"a non-AACS disc yields an empty trace"
);
assert!(
resolved.key_fetch.is_none(),
"a non-AACS disc has nothing to fetch"
);
assert!(
matches!(disc.decrypt_keys(), DecryptKeys::None),
"the disc is left untouched"
);
}
/// `resolve_keys` called before `scan` (disc slot still `None`) must return the
/// clean typed `DeviceNotReady` guard, never reach the `.expect("disc present
/// (checked above)")` below it and panic.
///
/// Mutation: change the `if self.disc.is_none()` guard to `.expect()`/panic
/// (e.g. drop the early return) → this test panics instead of getting an Err.
#[test]
fn resolve_keys_before_scan_is_clean_device_not_ready() {
let mut session = DiscSession::from_parts_for_test(None, None, None);
let err = session
.resolve_keys(factory_of(|| HasUnitKey([1; 16])))
.expect_err("resolve_keys before scan must error, not panic");
assert!(
matches!(err, Error::DeviceNotReady { .. }),
"expected DeviceNotReady, got {err:?}"
);
}
}