Files
libfreemkv/src/session.rs
T
Matthew Jackson 7ed798e386 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.
2026-07-23 23:37:20 -07:00

291 lines
11 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::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());
}
}