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 progress;
|
||||||
pub mod scsi;
|
pub mod scsi;
|
||||||
pub mod sector;
|
pub mod sector;
|
||||||
|
pub mod session;
|
||||||
pub(crate) mod speed;
|
pub(crate) mod speed;
|
||||||
pub(crate) mod udf;
|
pub(crate) mod udf;
|
||||||
pub(crate) mod unlock_bridge;
|
pub(crate) mod unlock_bridge;
|
||||||
@@ -139,6 +140,14 @@ pub use drive::capture::{
|
|||||||
};
|
};
|
||||||
pub use drive::{Drive, DriveStatus, find_drive};
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Errors ─────────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// All fallible APIs return `Result<T, Error>`. `Error` is a typed enum with a
|
// 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