From 30f653f7f4f151e1b38e28e92cd154ddb0617fac Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:44:52 -0700 Subject: [PATCH] ld: public profile catalog API + emulation feature; clippy-clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the LibreDrive profile catalog as a curated public API on `ld`: `ld::profiles()` returns the `Profiles` object (with `.get(drive_id)`) and `ld::profile(filter)` finds one — the catalog of supported drives, queried without unlocking. The unlock mechanism (firmware blobs, upload sequence, CDB wire format) stays private; `LibreDrive` is pub(crate), reached only via `all_unlockers()`. The unlock-handshake wire format the bdemu test-emulator needs to impersonate a drive (`UNLOCK_MARKER`, `is_unlock_read_buffer`) is behind a non-default `emulation` feature; the `cdb` module compiles only under it. Rename the `ScsiError` error variant to `Scsi` (matches css), gate the test-only `load_bundled` and `SCSI_STATUS_CHECK_CONDITION`. Clippy-clean in both feature configs; 86/89 tests pass. --- Cargo.toml | 6 ++++ src/aacs/error.rs | 10 +++---- src/aacs/handshake.rs | 4 +-- src/ld/error.rs | 8 +++--- src/ld/mod.rs | 49 +++++++++++++++++++++++++------ src/ld/platform/mt1959/mod.rs | 6 ++-- src/ld/profile.rs | 54 +++++++++++++++++++++-------------- src/lib.rs | 18 ++++++++---- src/scsi.rs | 5 +++- 9 files changed, 109 insertions(+), 51 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1ea611f..dfd0678 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,12 @@ license = "AGPL-3.0-only" description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()." repository = "https://github.com/freemkv/freemkv-unlock" +[features] +# Off by default. Exposes ld's unlock-handshake wire format (`UNLOCK_MARKER`, +# `is_unlock_read_buffer`) for the bdemu test-emulator, which impersonates an +# ld-unlockable drive. Real clients never enable it and keep the generic API. +emulation = [] + [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/aacs/error.rs b/src/aacs/error.rs index 8db7a49..39fdd20 100644 --- a/src/aacs/error.rs +++ b/src/aacs/error.rs @@ -28,7 +28,7 @@ pub enum Error { /// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with /// `sense: None` is a transport-layer fault; a CHECK CONDITION carries the /// parsed [`ScsiSense`]. - ScsiError { + Scsi { /// CDB opcode that failed — diagnostic, carried for future logging. opcode: u8, status: u8, @@ -54,14 +54,14 @@ impl Error { Error::AacsVidRead => 7012, Error::HandshakeRejected => 7013, Error::VidUnavailable => 7014, - Error::ScsiError { .. } => 7099, + Error::Scsi { .. } => 7099, } } /// The parsed sense for a CHECK CONDITION SCSI error, else `None`. pub fn scsi_sense(&self) -> Option { match self { - Error::ScsiError { sense, .. } => *sense, + Error::Scsi { sense, .. } => *sense, _ => None, } } @@ -70,7 +70,7 @@ impl Error { pub fn is_scsi_transport_failure(&self) -> bool { matches!( self, - Error::ScsiError { status, sense: None, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE + Error::Scsi { status, sense: None, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE ) } } @@ -79,7 +79,7 @@ impl Error { /// at the transport level; sense parsed from the raw buffer when present). impl From for Error { fn from(e: crate::scsi::ScsiError) -> Self { - Error::ScsiError { + Error::Scsi { opcode: 0, status: e.status, sense: e.sense.map(|s| ScsiSense::from_buf(&s)), diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index 5b6fb74..b456332 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -1350,7 +1350,7 @@ mod tests { // A transport wedge mid-handshake must NOT be reported as a cert/key // rejection — the operator needs to see the real (replug) cause, not // be sent down a keydb/host-cert rabbit hole. - let transport = Error::ScsiError { + let transport = Error::Scsi { opcode: 0xA3, // SEND KEY status: SCSI_STATUS_TRANSPORT_FAILURE, sense: None, @@ -1363,7 +1363,7 @@ mod tests { // A genuine SCSI rejection (CHECK CONDITION) IS the drive saying no, so // it maps to the handshake-specific code as before. - let rejected = Error::ScsiError { + let rejected = Error::Scsi { opcode: 0xA3, status: SCSI_STATUS_CHECK_CONDITION, sense: Some(crate::scsi::ScsiSense { diff --git a/src/ld/error.rs b/src/ld/error.rs index 7716ab4..6f4e756 100644 --- a/src/ld/error.rs +++ b/src/ld/error.rs @@ -16,7 +16,7 @@ pub enum Error { SignatureMismatch { expected: [u8; 4], got: [u8; 4] }, /// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with /// `sense: None` is a transport-layer fault (bridge crash / disconnect). - ScsiError { + Scsi { opcode: u8, status: u8, sense: Option<[u8; 32]>, @@ -29,7 +29,7 @@ impl std::fmt::Display for Error { Error::ProfileParse => write!(f, "drive profile parse error"), Error::UnlockFailed => write!(f, "firmware unlock failed"), Error::SignatureMismatch { .. } => write!(f, "signature mismatch"), - Error::ScsiError { opcode, status, .. } => { + Error::Scsi { opcode, status, .. } => { write!(f, "SCSI error (opcode {opcode:#04x}, status {status:#04x})") } } @@ -44,7 +44,7 @@ impl Error { pub(crate) fn is_transport_failure(&self) -> bool { matches!( self, - Error::ScsiError { status, sense: None, .. } + Error::Scsi { status, sense: None, .. } if *status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE ) } @@ -54,7 +54,7 @@ impl Error { /// (the opcode is unknown at the transport level). impl From for Error { fn from(e: crate::scsi::ScsiError) -> Self { - Error::ScsiError { + Error::Scsi { opcode: 0, status: e.status, sense: e.sense, diff --git a/src/ld/mod.rs b/src/ld/mod.rs index 5148590..9541595 100644 --- a/src/ld/mod.rs +++ b/src/ld/mod.rs @@ -6,6 +6,11 @@ //! encryption AT THE DRIVE (the unlocked drive serves clear content) and //! reporting the OEM Volume ID. +// `cdb` carries ONLY the unlock-handshake wire format that the bdemu emulator +// needs (the real unlocker drives its CDBs from per-drive profile templates, not +// these constants). Compile it only when the `emulation` feature exposes it, so +// it never dead-codes in a normal build. +#[cfg(feature = "emulation")] mod cdb; mod error; mod platform; @@ -15,25 +20,51 @@ use crate::ld::error::Result; use crate::scsi::{DataDirection, ScsiTransport}; use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker}; -/// The LibreDrive unlocker. +// ── Public profile catalog ────────────────────────────────────────────────── +// +// The catalog of drives the LibreDrive unlocker recognizes is the one piece of +// ld worth exposing publicly: it answers "is this drive supported?" without +// unlocking, and the bdemu test-emulator reads it to impersonate a supported +// drive. The unlock *mechanism* (firmware blobs, upload sequence, CDB wire +// format) stays private — only the catalog and its match result are public. + +pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles}; + +/// The bundled LibreDrive profile catalog (parsed once, process-cached), or +/// `None` if the embedded JSON fails to parse (a build-time bug). Pair with +/// [`Profiles::get`] to look up a specific drive: +/// `freemkv_unlock::ld::profiles().and_then(|p| p.get(&drive_id))`. +pub fn profiles() -> Option<&'static Profiles> { + profile::bundled() +} + +/// The bundled profile matching a drive identity, if the drive is supported. +/// Convenience over [`profiles`] + [`Profiles::get`]. +pub fn profile(drive_id: &DriveId) -> Option { + profile::find_bundled(drive_id) +} + +/// The unlock-handshake wire format the bdemu test-emulator needs to impersonate +/// an ld-unlockable drive: the marker an unlocked drive returns and the +/// READ BUFFER mode/buf-id that constitutes an unlock request. Behind the +/// non-default `emulation` feature so real clients never see ld's wire format. +#[cfg(feature = "emulation")] +pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer}; + +/// The LibreDrive unlocker. `pub(crate)` — clients reach it only through +/// [`crate::all_unlockers`], never by name (the locked-design contract). /// /// Matches a drive against the bundled profile database and, on a hit, /// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration) /// handshake over the raw SCSI transport. -pub struct LibreDrive; +pub(crate) struct LibreDrive; impl LibreDrive { - pub fn new() -> Self { + pub(crate) fn new() -> Self { LibreDrive } } -impl Default for LibreDrive { - fn default() -> Self { - Self::new() - } -} - /// The firmware-unlocker name for a drive that has a bundled profile (for /// drive-info "is this drive supported?" display), or `None`. A pure profile /// lookup — does NOT touch the drive or unlock anything. diff --git a/src/ld/platform/mt1959/mod.rs b/src/ld/platform/mt1959/mod.rs index f4fd8cb..144f9d4 100644 --- a/src/ld/platform/mt1959/mod.rs +++ b/src/ld/platform/mt1959/mod.rs @@ -124,7 +124,7 @@ impl Mt1959 { let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8); let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?; if result.bytes_transferred != expected { - return Err(Error::ScsiError { + return Err(Error::Scsi { opcode: SCSI_READ_BUFFER, status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, sense: None, @@ -217,7 +217,7 @@ impl Mt1959 { return Ok(()); } } - Err(Error::ScsiError { + Err(Error::Scsi { opcode: SCSI_READ_BUFFER, status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, sense: None, @@ -327,7 +327,7 @@ impl Mt1959 { ) .is_err() { - return Err(Error::ScsiError { + return Err(Error::Scsi { opcode: SCSI_READ_BUFFER, status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, sense: None, diff --git a/src/ld/profile.rs b/src/ld/profile.rs index e7ef491..7b06895 100644 --- a/src/ld/profile.rs +++ b/src/ld/profile.rs @@ -3,9 +3,11 @@ use crate::ld::error::{Error, Result}; use serde::Deserialize; -/// Top-level profiles file — keyed by chipset + variant. +/// The LibreDrive profile catalog — the set of optical drives the firmware +/// unlocker recognizes, keyed by chipset + variant. Loaded from the bundled +/// JSON; the public entry point is [`crate::ld::profiles`]. #[derive(Debug, Deserialize)] -pub struct ProfilesFile { +pub struct Profiles { #[serde(default)] pub mt1959_a: Vec, #[serde(default)] @@ -14,6 +16,15 @@ pub struct ProfilesFile { pub renesas: Vec, } +impl Profiles { + /// The profile matching a drive identity, if this catalog supports that + /// drive. Two-pass per platform section: exact (incl. firmware date) then a + /// looser vendor/revision/vendor-specific match. See [`find_by_drive_id`]. + pub fn get(&self, drive_id: &crate::DriveId) -> Option { + find_by_drive_id(self, drive_id) + } +} + /// Drive identity — matched against INQUIRY data. #[derive(Debug, Clone, Deserialize)] pub struct Identity { @@ -235,12 +246,11 @@ where const BUNDLED_PROFILES: &str = include_str!("profiles.json"); -/// Parse the bundled profiles fresh into an owned [`ProfilesFile`]. -/// -/// Re-parses the embedded JSON (~800 KB) on every call; prefer -/// [`bundled`] for the hot path, which parses once and caches. This -/// owned form is kept for callers that need a mutable / independent copy. -pub fn load_bundled() -> Result { +/// Parse the bundled profiles fresh into an owned [`Profiles`]. Test-only — the +/// library hot path uses the cached [`bundled`]; tests use this owned form for +/// independent copies. +#[cfg(test)] +pub fn load_bundled() -> Result { load_from_str(BUNDLED_PROFILES) } @@ -250,9 +260,9 @@ pub fn load_bundled() -> Result { /// Returns `None` if the embedded JSON fails to parse (a build-time bug — /// the bundled blob is fixed at compile time, so the first successful call /// guarantees all later calls succeed too). -pub fn bundled() -> Option<&'static ProfilesFile> { +pub fn bundled() -> Option<&'static Profiles> { use std::sync::OnceLock; - static CACHE: OnceLock> = OnceLock::new(); + static CACHE: OnceLock> = OnceLock::new(); CACHE .get_or_init(|| load_from_str(BUNDLED_PROFILES).ok()) .as_ref() @@ -267,7 +277,7 @@ pub fn find_bundled(drive_id: &crate::DriveId) -> Option { find_by_drive_id(bundled()?, drive_id) } -fn load_from_str(data: &str) -> Result { +fn load_from_str(data: &str) -> Result { serde_json::from_str(data).map_err(|_| Error::ProfileParse) } @@ -281,7 +291,7 @@ fn load_from_str(data: &str) -> Result { /// on file still match a same-model profile. All comparisons are /// whitespace-trimmed. Returns the first section that yields a match. pub fn find_by_drive_id( - profiles: &ProfilesFile, + profiles: &Profiles, drive_id: &crate::DriveId, ) -> Option { let v = drive_id.vendor_id.trim(); @@ -374,11 +384,11 @@ mod tests { fn bundled_is_cached_and_matches_fresh_parse() { let cached = bundled().expect("bundled profiles parse"); let fresh = load_bundled().unwrap(); - // Same data either way (compare section sizes — ProfilesFile isn't Eq). + // Same data either way (compare section sizes — Profiles isn't Eq). assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len()); // Cached accessor returns a stable address across calls. - let a = bundled().unwrap() as *const ProfilesFile; - let b = bundled().unwrap() as *const ProfilesFile; + let a = bundled().unwrap() as *const Profiles; + let b = bundled().unwrap() as *const Profiles; assert_eq!(a, b); } @@ -449,7 +459,7 @@ mod tests { /// find_by_drive_id: exact match (including firmware_date) wins over loose match. /// Spec: two-pass — first an exact match including firmware_date, then looser. - /// Build two synthetic ProfilesFile entries that differ only by firmware_date, + /// Build two synthetic Profiles entries that differ only by firmware_date, /// and verify the correct one is selected. /// Mutation: doing only the loose pass would return the first entry regardless of date. #[test] @@ -483,7 +493,7 @@ mod tests { ] }) .to_string(); - let profiles: ProfilesFile = serde_json::from_str(&profiles_json).unwrap(); + let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap(); // "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides. let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000"); @@ -528,7 +538,7 @@ mod tests { ] }) .to_string(); - let profiles: ProfilesFile = serde_json::from_str(&profiles_json).unwrap(); + let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap(); // Drive with an unknown firmware date — no exact match, loose match should work. // "LOOSEDR " fills 8 bytes; "000000000000" is the unknown date. @@ -542,11 +552,11 @@ mod tests { } /// load_from_str (via load_bundled) returns ProfileParse on invalid JSON. - /// Mutation: returning an empty ProfilesFile instead of an error silently + /// Mutation: returning an empty Profiles instead of an error silently /// leaves the drive-profile database empty. #[test] fn load_from_str_returns_profile_parse_on_bad_json() { - let result: Result = + let result: Result = serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse); assert!(matches!(result, Err(Error::ProfileParse))); } @@ -586,7 +596,7 @@ mod tests { ] }) .to_string(); - let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap(); + let profiles: Profiles = serde_json::from_str(&json_str).unwrap(); let p = &profiles.mt1959_a[0]; // DriveProfile directly // All optional CDB fields must be None when absent from JSON. assert!( @@ -637,7 +647,7 @@ mod tests { ] }) .to_string(); - let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap(); + let profiles: Profiles = serde_json::from_str(&json_str).unwrap(); assert_eq!( profiles.mt1959_a[0].signature, [0u8; 4], "empty signature must deserialise as [0;4]" diff --git a/src/lib.rs b/src/lib.rs index 8a8f771..c3a249c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,13 @@ pub mod scsi; mod aacs; mod css; -mod ld; +// `ld` is public ONLY for its drive-profile catalog (`ld::profiles` / the +// `Profiles` object) and, under the `emulation` feature, the unlock-handshake +// wire format the bdemu test-emulator needs. The unlocker impl itself +// (`LibreDrive`) is `pub(crate)` — clients still reach unlockers only through +// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they +// stay fully private. +pub mod ld; use scsi::ScsiTransport; @@ -114,10 +120,12 @@ pub trait Unlocker: Send + Sync { ) -> std::result::Result; } -/// Name of the firmware unlocker that supports this drive (for drive-info "is -/// this drive supported?" display), or `None`. A pure profile lookup — does NOT -/// touch the drive or unlock anything. -pub fn firmware_unlocker_name(drive_id: &DriveId) -> Option<&'static str> { +/// Name of the unlocker that claims this drive by identity (for drive-info "is +/// this drive supported?" display), or `None`. A pure lookup — does NOT touch +/// the drive or unlock anything. Only the identity-keyed (drive-prep) unlocker +/// can answer from a `DriveId` alone; the disc-kind-keyed unlockers (AACS / CSS) +/// don't claim a drive sight-unseen, so they never match here. +pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> { ld::firmware_name(drive_id) } diff --git a/src/scsi.rs b/src/scsi.rs index 2585a8a..d710d9e 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -66,7 +66,10 @@ impl ScsiSense { /// SCSI status byte for a transport-layer failure (bridge crash / disconnect). pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF; -/// SCSI status byte CHECK CONDITION (a drive sense is available). +/// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the +/// status contract; currently referenced only by tests asserting the +/// transport-vs-check-condition distinction. +#[allow(dead_code)] pub(crate) const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02; // Common opcodes used by the unlocker modules.