ld: public profile catalog API + emulation feature; clippy-clean
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.
This commit is contained in:
@@ -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"
|
||||
|
||||
+5
-5
@@ -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<ScsiSense> {
|
||||
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<crate::scsi::ScsiError> 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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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<crate::scsi::ScsiError> for Error {
|
||||
fn from(e: crate::scsi::ScsiError) -> Self {
|
||||
Error::ScsiError {
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: e.status,
|
||||
sense: e.sense,
|
||||
|
||||
+40
-9
@@ -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<ProfileMatch> {
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+32
-22
@@ -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<DriveProfile>,
|
||||
#[serde(default)]
|
||||
@@ -14,6 +16,15 @@ pub struct ProfilesFile {
|
||||
pub renesas: Vec<DriveProfile>,
|
||||
}
|
||||
|
||||
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<ProfileMatch> {
|
||||
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<ProfilesFile> {
|
||||
/// 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<Profiles> {
|
||||
load_from_str(BUNDLED_PROFILES)
|
||||
}
|
||||
|
||||
@@ -250,9 +260,9 @@ pub fn load_bundled() -> Result<ProfilesFile> {
|
||||
/// 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<Option<ProfilesFile>> = OnceLock::new();
|
||||
static CACHE: OnceLock<Option<Profiles>> = 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<ProfileMatch> {
|
||||
find_by_drive_id(bundled()?, drive_id)
|
||||
}
|
||||
|
||||
fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
fn load_from_str(data: &str) -> Result<Profiles> {
|
||||
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
|
||||
}
|
||||
|
||||
@@ -281,7 +291,7 @@ fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
/// 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<ProfileMatch> {
|
||||
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<ProfilesFile> =
|
||||
let result: Result<Profiles> =
|
||||
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]"
|
||||
|
||||
+13
-5
@@ -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<Unlocked, UnlockError>;
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user