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:
@@ -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
@@ -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 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user