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.
This commit is contained in:
Matthew Jackson
2026-07-23 23:37:20 -07:00
parent b9568242df
commit 7ed798e386
2 changed files with 299 additions and 0 deletions
+9
View File
@@ -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<T, Error>`. `Error` is a typed enum with a
+290
View File
@@ -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<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 {
drive: Drive,
spec: KeySpec,
disc: Option<Disc>,
/// Sector source for a later file/live mux to `.take()` (steps 34).
/// Unpopulated in the current step; shapes the struct for the mux hoist.
reader: Option<Box<dyn SectorSource>>,
}
/// 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)");
}
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<DiscId> {
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<Disc> {
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 34). `None` until that path populates it.
pub fn into_reader(self) -> Option<Box<dyn SectorSource>> {
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<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());
}
}