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.
291 lines
11 KiB
Rust
291 lines
11 KiB
Rust
//! 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 3–4).
|
||
/// 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 3–4). `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());
|
||
}
|
||
}
|