From 7ed798e38604d5bb181b26354df2485512f4a1b3 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:37:20 -0700 Subject: [PATCH] libfreemkv: add DiscSession (drive open/bring-up hoist, step 1) New src/session.rs providing DiscSession, DeviceTarget, and KeySpec, re-exported from lib.rs. DiscSession::open runs the drive open + advisory wait_ready/init/probe_disc bring-up (owning the Drive by value); identify()/scan() are split so consumers keep their own UI/TMDB sequencing. KeySpec carries consumer-built credentials + key_sources which scan() forwards into ScanOptions without clobbering caller-set fields (the library derives no certs and reads no keydb). Unit tests cover the KeySpec->ScanOptions forwarding. --- src/lib.rs | 9 ++ src/session.rs | 290 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 src/session.rs diff --git a/src/lib.rs b/src/lib.rs index ce4ed19..532f84d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -125,6 +125,7 @@ pub(crate) mod platform; pub mod progress; pub mod scsi; pub mod sector; +pub mod session; pub(crate) mod speed; pub(crate) mod udf; pub(crate) mod unlock_bridge; @@ -139,6 +140,14 @@ pub use drive::capture::{ }; pub use drive::{Drive, DriveStatus, find_drive}; +// ─── Disc session (drive open + SCSI bring-up hoist) ───────────────────────── +// +// One entry point that opens a drive and brings the transport up, so consumers +// stop hand-rolling `open → wait_ready → init → probe_disc → identify → scan`. +// Owns the `Drive` by value; forwards consumer-built key material into +// `ScanOptions` (the library derives no certs — see `KeySpec`). +pub use session::{DeviceTarget, DiscSession, KeySpec}; + // ─── Errors ───────────────────────────────────────────────────────────────── // // All fallible APIs return `Result`. `Error` is a typed enum with a diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..93d5325 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,290 @@ +//! 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::disc::{Disc, DiscId, DriveCredentials, ScanOptions}; +use crate::drive::{Drive, find_drive}; +use crate::error::{Error, Result}; +use crate::keysource::KeySource; +use crate::sector::SectorSource; +use std::path::PathBuf; + +/// 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, + /// Consumer bookkeeping only — the library does not read it. + pub key_url: Option, + /// Consumer bookkeeping only — the library does not read it. + pub key_auth: Option, + /// Host cert(s) for the live-drive handshake, pre-built by the consumer. + /// Forwarded to [`ScanOptions::credentials`] at scan time. + pub credentials: Option, + /// Consumer-built key-source layer; the handshake collects host certs + /// across these. Moved into [`ScanOptions::key_sources`] at scan time. + pub key_sources: Vec>, +} + +/// 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 { + drive: Drive, + spec: KeySpec, + disc: Option, + /// Sector source for a later file/live mux to `.take()` (steps 3–4). + /// Unpopulated in the current step; shapes the struct for the mux hoist. + reader: Option>, +} + +/// 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 { + 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)"); + } + + Ok(DiscSession { + drive, + spec, + disc: None, + reader: None, + }) + } + + /// Fast disc identification — name/format only, no playlist parse. Wraps + /// [`Disc::identify`]. + pub fn identify(&mut self) -> Result { + Disc::identify(&mut self.drive) + } + + /// 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(&mut self.drive, &opts)?; + self.disc = Some(disc); + Ok(self.disc.as_ref().expect("disc just stored")) + } + + /// 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 { + self.disc.take() + } + + /// Shared access to the opened drive (identity, profile, path). + pub fn drive(&self) -> &Drive { + &self.drive + } + + /// Mutable access to the opened drive — for ciphertext sampling and other + /// direct reads consumers still perform. + pub fn drive_mut(&mut self) -> &mut Drive { + &mut self.drive + } + + /// Lock the tray so the disc cannot eject mid-rip. Unlock is guaranteed by + /// `Drive::drop`. + pub fn lock_tray(&mut self) { + self.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 + } + + /// Consume the session, returning the sector source staged for a later mux + /// (steps 3–4). `None` until that path populates it. + pub fn into_reader(self) -> Option> { + self.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> { + 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()); + } +}