Extract drive unlock behind pluggable Unlocker seam

libfreemkv must stay firmware-clean for crates.io. Move ALL drive-unlock
knowledge — firmware blobs, WRITE_BUFFER/MODE SELECT upload, unlock CDBs,
the MT1959 variant-A/B handshake, the 800 KB profiles.json database, and
the DriveProfile parsing — out into the freemkv-unlock-ld crate.

libfreemkv now keeps only the seam:
  - Unlocker trait (name/matches/unlock) + a process-wide ordered registry
    (register_unlocker / route_unlock) in src/unlock.rs
  - Drive::init() walks the registry; the first unlocker whose matches(id)
    is true runs unlock(scsi, id); if none match the drive is left in
    stock mode and the host-cert AACS handshake (the OEM route) carries
    the disc.

The unlocker issues its own CDBs through the public ScsiTransport::execute,
so libfreemkv knows nothing about how unlocking happens.

Removed:
  - profiles.json
  - src/platform/mt1959/{mod,variant_a,variant_b}.rs
  - src/profile.rs (DriveProfile, ProfilesFile, find_by_drive_id, ...)
  - the PlatformDriver trait

Because the Unlocker seam reports only success/failure (no extended-access
marker), VID acquisition is now always via the cert-based handshake; the
per-drive OEM-VID-CDB shortcut and Drive::is_unlocked() (now const false)
are removed/neutralized. Disc-speed calibration moved into the unlocker's
unlock(); Drive::probe_disc() is a no-op.

git grep over src/ is firmware-blob/profiles/WRITE_BUFFER/mt1959-free.
All tests pass on Rust 1.86 (precommit green).
This commit is contained in:
Matthew Jackson
2026-06-22 10:31:51 -07:00
parent f74979bdb4
commit 6dc62bcd84
11 changed files with 291 additions and 5978 deletions
-4325
View File
File diff suppressed because it is too large Load Diff
+14 -156
View File
@@ -14,115 +14,19 @@ pub(super) struct HandshakeResult {
} }
impl Disc { impl Disc {
/// Acquire the Volume ID. Tries the per-drive OEM CDB path first
/// when the drive reports `is_unlocked()` (extended-access state),
/// and falls back to the cert-based AACS mutual-auth handshake
/// otherwise.
///
/// The OEM path is a single READ_BUFFER CDB built from the drive
/// profile's `read_vid_cdb` template. The response carries a 3-byte
/// header (validated against `00 22 00`) followed by the 16-byte
/// VID at bytes [4..20]. Crucially, no AGID setup is required —
/// the drive's runtime firmware serves the VID directly when in
/// extended-access state.
///
/// The cert path is the standard AACS spec flow: ECDH key
/// agreement, bus-key derivation, then `REPORT_DISC_STRUCTURE`
/// format 0x80 to retrieve VID under bus-key MAC.
pub(super) fn read_vid(
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> Result<[u8; 16]> {
if session.is_unlocked() {
let profile = session
.drive_profile()
.ok_or(Error::DriveProfileMissing)?
.clone();
return Self::read_vid_oem(session, &profile);
}
Self::read_vid_cert(session, opts)
}
/// OEM VID retrieval — issues the per-drive READ_BUFFER CDB and
/// parses the response.
///
/// Response layout (36 bytes):
/// * [0..3] 3-byte response signature; expected `00 22 00`
/// * [3] reserved
/// * [4..20] 16-byte Volume ID
/// * [20..36] reserved / per-drive padding
fn read_vid_oem(
session: &mut crate::drive::Drive,
profile: &crate::profile::DriveProfile,
) -> Result<[u8; 16]> {
const RESPONSE_LEN: usize = 36;
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
let cdb = profile.read_vid_cdb.ok_or(Error::VidCdbUnavailable)?;
let mut buf = vec![0u8; RESPONSE_LEN];
let result = session.scsi_execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)?;
if result.bytes_transferred < RESPONSE_LEN {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_short_response",
bytes_transferred = result.bytes_transferred,
"OEM VID CDB returned short response"
);
return Err(Error::AacsVidRead);
}
if buf[0..3] != EXPECTED_HEADER {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_bad_header",
header_0 = buf[0],
header_1 = buf[1],
header_2 = buf[2],
"OEM VID response header mismatch"
);
return Err(Error::AacsVidRead);
}
let mut vid = [0u8; 16];
vid.copy_from_slice(&buf[4..20]);
tracing::debug!(
target: "freemkv::disc",
phase = "oem_vid_ok",
"OEM VID retrieved"
);
Ok(vid)
}
/// Cert-based VID retrieval — runs the full AACS mutual-auth
/// handshake and extracts VID from the bus-key-MAC'd
/// `REPORT_DISC_STRUCTURE` response.
fn read_vid_cert(session: &mut crate::drive::Drive, opts: &ScanOptions) -> Result<[u8; 16]> {
match Self::do_handshake_cert(session, opts) {
(Some(h), _) => Ok(h.volume_id),
(None, Some(e)) => Err(e),
(None, None) => Err(Error::AacsVidUnavailable),
}
}
/// SCSI handshake — drives the VID-acquisition flow and returns /// SCSI handshake — drives the VID-acquisition flow and returns
/// a structured `HandshakeResult` for downstream key resolution. /// a structured `HandshakeResult` for downstream key resolution.
/// Prefers the OEM path when `Drive::is_unlocked()` is true and
/// falls back to cert-based mutual auth otherwise.
/// ///
/// The OEM path produces only VID (no bus-key, so no /// Drive unlock now lives behind the pluggable
/// `read_data_key`); the cert path can produce both. AACS 2.0 /// [`crate::unlock::Unlocker`] seam, which reports no extended-access
/// content that needs read_data_key for bus decryption requires /// marker back to libfreemkv. VID is therefore always acquired via the
/// the cert path. /// cert-based mutual-auth handshake (the OEM route); the cert path also
/// yields `read_data_key`, required for AACS 2.0 bus decryption.
/// ///
/// Returns `(handshake, error)`: /// Returns `(handshake, error)`:
/// * `(Some(_), None)` — VID acquired /// * `(Some(_), None)` — VID acquired
/// * `(None, Some(_))` — specific failure mode; only /// * `(None, Some(_))` — specific failure mode
/// `AacsHostCertRejected` and `AacsVidUnavailable` are returned /// (`AacsHostCertRejected` or `AacsVidUnavailable`)
/// here (the OEM-path `DriveProfileMissing` / `VidCdbUnavailable`
/// errors are caught internally and fall through to cert auth)
/// * `(None, None)` — handshake not attempted (no keydb; /// * `(None, None)` — handshake not attempted (no keydb;
/// resolution will proceed with VID=zero and rely on path 1 /// resolution will proceed with VID=zero and rely on path 1
/// disc-hash → VUK lookup) /// disc-hash → VUK lookup)
@@ -132,7 +36,10 @@ impl Disc {
) -> (Option<HandshakeResult>, Option<Error>) { ) -> (Option<HandshakeResult>, Option<Error>) {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::scan", phase = "do_handshake", "begin"); tracing::info!(target: "freemkv::scan", phase = "do_handshake", "begin");
let (result, err) = Self::do_handshake_inner(session, opts); // Drive unlock moved behind the pluggable `Unlocker` seam, which
// reports no extended-access marker — so VID always comes via the
// cert-based handshake (the OEM route).
let (result, err) = Self::do_handshake_cert(session, opts);
tracing::info!( tracing::info!(
target: "freemkv::scan", target: "freemkv::scan",
phase = "do_handshake", phase = "do_handshake",
@@ -144,55 +51,7 @@ impl Disc {
(result, err) (result, err)
} }
fn do_handshake_inner( /// Cert-based AACS handshake — the OEM route for VID acquisition.
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> (Option<HandshakeResult>, Option<Error>) {
let unlocked = session.is_unlocked();
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_entry",
unlocked,
"do_handshake entered"
);
if unlocked {
// Try OEM VID retrieval first. If the drive's profile
// doesn't carry the CDB template, or the response is
// malformed, fall through to cert-based auth.
match Self::read_vid(session, opts) {
Ok(volume_id) => {
return (
Some(HandshakeResult {
volume_id,
read_data_key: None,
}),
None,
);
}
Err(Error::DriveProfileMissing) | Err(Error::VidCdbUnavailable) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_oem_unavailable",
"OEM VID path unavailable for this drive; trying cert handshake"
);
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_oem_failed",
error_code = e.code(),
"OEM VID retrieval failed; trying cert handshake"
);
}
}
}
Self::do_handshake_cert(session, opts)
}
/// Cert-based AACS handshake. The legacy auth path; still used as
/// the fallback when the OEM VID path isn't available or fails.
fn do_handshake_cert( fn do_handshake_cert(
session: &mut crate::drive::Drive, session: &mut crate::drive::Drive,
opts: &ScanOptions, opts: &ScanOptions,
@@ -201,9 +60,8 @@ impl Disc {
// Host certs come from the caller's DriveCredentials (e.g. the keydb's // Host certs come from the caller's DriveCredentials (e.g. the keydb's
// host_certs(), sourced app-side) — the library does not load a keydb. // host_certs(), sourced app-side) — the library does not load a keydb.
// Absent ⇒ no cert auth: an unlocked / LibreDrive drive already returned // Absent ⇒ no cert auth: resolution proceeds with VID=zero and relies
// a Volume ID via the OEM path before reaching here, so this is the // on the path-1 disc-hash → VUK lookup.
// locked-drive-without-credentials case.
let host_certs: &[aacs::HostCert] = match &opts.credentials { let host_certs: &[aacs::HostCert] = match &opts.credentials {
Some(c) if !c.host_certs.is_empty() => &c.host_certs, Some(c) if !c.host_certs.is_empty() => &c.host_certs,
_ => { _ => {
+7 -8
View File
@@ -1113,8 +1113,8 @@ impl KeyOrigin {
/// AACS host credentials for the live-drive authenticated handshake. /// AACS host credentials for the live-drive authenticated handshake.
/// ///
/// Optional and source-agnostic: an unlocked / LibreDrive drive uses the OEM /// Optional and source-agnostic: an ISO scan has no handshake at all, and a
/// Volume-ID path and needs none, and an ISO scan has no handshake at all. The /// live drive without supplied credentials simply skips cert auth. The
/// caller supplies the host cert(s) from wherever it likes — today the keydb's /// caller supplies the host cert(s) from wherever it likes — today the keydb's
/// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key /// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key
/// source: a locked drive needs the cert to unlock even when the decryption key /// source: a locked drive needs the cert to unlock even when the decryption key
@@ -1133,8 +1133,8 @@ pub struct DriveCredentials {
/// authenticated handshake. /// authenticated handshake.
#[derive(Default)] #[derive(Default)]
pub struct ScanOptions { pub struct ScanOptions {
/// Host credentials for the live-drive AACS handshake. `None` for an /// Host credentials for the live-drive AACS handshake. `None` for ISO
/// unlocked / LibreDrive drive (OEM Volume-ID path) and for ISO scans. /// scans, or a live drive where cert auth should be skipped.
pub credentials: Option<DriveCredentials>, pub credentials: Option<DriveCredentials>,
/// Optional cooperative-cancellation token. When set, long scan-time /// Optional cooperative-cancellation token. When set, long scan-time
/// loops (notably the CSS known-plaintext crack, which can scan up to /// loops (notably the CSS known-plaintext crack, which can scan up to
@@ -1226,10 +1226,9 @@ impl Disc {
/// The session must be open and unlocked (`Drive::open` handles this). /// The session must be open and unlocked (`Drive::open` handles this).
/// All disc reads use standard READ(10) via UDF — no vendor SCSI commands. /// All disc reads use standard READ(10) via UDF — no vendor SCSI commands.
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> { pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
// AACS handshake (Blu-ray/UHD). Routes through Disc::read_vid, // AACS handshake (Blu-ray/UHD). Acquires the Volume ID via the
// which prefers the per-drive OEM CDB path when the drive is // cert-based mutual-auth handshake (the OEM route); drive unlock
// in the extended-access state and falls back to cert-based // itself runs separately behind the pluggable `Unlocker` seam.
// mutual auth otherwise.
tracing::info!(target: "freemkv::scan", "phase: AACS handshake"); tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
let (handshake, handshake_error) = Self::do_handshake(session, opts); let (handshake, handshake_error) = Self::do_handshake(session, opts);
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done"); tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
+78 -99
View File
@@ -1,8 +1,8 @@
//! Drive session — open, identify, and read from optical drives. //! Drive session — open, identify, and read from optical drives.
//! //!
//! A `Drive` is opened from a device path, identifies itself via INQUIRY, //! A `Drive` is opened from a device path, identifies itself via INQUIRY,
//! optionally unlocks/initializes via a platform driver, and reads sectors. //! optionally unlocks/initializes via a registered [`crate::unlock::Unlocker`],
//! `probe_disc()` primes the firmware's per-region speed table. //! and reads sectors.
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) { pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
match e { match e {
@@ -27,9 +27,6 @@ pub(crate) mod windows;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::event::Event; use crate::event::Event;
use crate::identity::DriveId; use crate::identity::DriveId;
use crate::platform::PlatformDriver;
use crate::platform::mt1959::Mt1959;
use crate::profile::{self, DriveProfile};
use crate::scsi::ScsiTransport; use crate::scsi::ScsiTransport;
use crate::sector::SectorSource; use crate::sector::SectorSource;
use std::path::Path; use std::path::Path;
@@ -62,9 +59,15 @@ const SCSI_REPORT_KEY: u8 = 0xA4;
/// Optical disc drive session -- open, identify, unlock, and read. /// Optical disc drive session -- open, identify, unlock, and read.
pub struct Drive { pub struct Drive {
scsi: Box<dyn ScsiTransport>, scsi: Box<dyn ScsiTransport>,
driver: Option<Box<dyn PlatformDriver>>, /// Name of the [`crate::unlock::Unlocker`] that handled this drive at
pub profile: Option<DriveProfile>, /// `init()`, if any matched. `None` means no unlocker matched and the
pub platform: Option<profile::Platform>, /// drive runs in stock mode (host-cert AACS handshake carries discs).
unlocker_name: Option<String>,
/// True once `init()` has run (whether or not an unlocker matched).
init_ran: bool,
/// Lazily-computed registry-match name for `platform_name()`'s `&str`
/// return before `init()` has run.
matched_name_cache: std::sync::OnceLock<String>,
pub drive_id: DriveId, pub drive_id: DriveId,
device_path: String, device_path: String,
/// Halt flag — when set, Drive::read() bails at the next check point. /// Halt flag — when set, Drive::read() bails at the next check point.
@@ -88,7 +91,6 @@ impl Drive {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "open", device = %device.display(), "begin"); tracing::info!(target: "freemkv::drive", phase = "open", device = %device.display(), "begin");
let mut transport = crate::scsi::open(device)?; let mut transport = crate::scsi::open(device)?;
let profiles = profile::load_bundled()?;
let drive_id = DriveId::from_drive(transport.as_mut())?; let drive_id = DriveId::from_drive(transport.as_mut())?;
tracing::info!( tracing::info!(
target: "freemkv::drive", target: "freemkv::drive",
@@ -100,24 +102,14 @@ impl Drive {
"end" "end"
); );
let m = profile::find_by_drive_id(&profiles, &drive_id);
let (driver, platform, profile) = match m {
Some(m) => (
create_driver(m.platform, &m.profile).ok(),
Some(m.platform),
Some(m.profile),
),
None => (None, None, None),
};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let block_dev_fd = open_block_device_for_sg(device); let block_dev_fd = open_block_device_for_sg(device);
Ok(Drive { Ok(Drive {
scsi: transport, scsi: transport,
driver, unlocker_name: None,
platform, init_ran: false,
profile, matched_name_cache: std::sync::OnceLock::new(),
drive_id, drive_id,
device_path: device.to_string_lossy().to_string(), device_path: device.to_string_lossy().to_string(),
halt: Arc::new(AtomicBool::new(false)), halt: Arc::new(AtomicBool::new(false)),
@@ -135,9 +127,9 @@ impl Drive {
fn from_transport_for_test(scsi: Box<dyn ScsiTransport>) -> Self { fn from_transport_for_test(scsi: Box<dyn ScsiTransport>) -> Self {
Drive { Drive {
scsi, scsi,
driver: None, unlocker_name: None,
profile: None, init_ran: false,
platform: None, matched_name_cache: std::sync::OnceLock::new(),
drive_id: DriveId { drive_id: DriveId {
vendor_id: String::new(), vendor_id: String::new(),
product_id: String::new(), product_id: String::new(),
@@ -212,16 +204,11 @@ impl Drive {
self.unlock_tray(); self.unlock_tray();
} }
/// Whether this drive has a known profile (unlock parameters available). /// Whether a registered unlocker matches this drive (i.e. it can be
/// firmware-unlocked). Queried against the unlock registry by identity;
/// does not require `init()` to have run.
pub fn has_profile(&self) -> bool { pub fn has_profile(&self) -> bool {
self.profile.is_some() crate::unlock::matching_name(&self.drive_id).is_some()
}
/// Borrow the matched drive profile, if any. Used by callers that
/// need to issue per-drive OEM CDB templates (e.g. the OEM VID
/// retrieval path in `disc::encrypt`).
pub fn drive_profile(&self) -> Option<&DriveProfile> {
self.profile.as_ref()
} }
/// Access the SCSI transport for direct commands (used by CSS/AACS auth). /// Access the SCSI transport for direct commands (used by CSS/AACS auth).
@@ -330,11 +317,17 @@ impl Drive {
} }
} }
/// Name of the unlocker handling this drive. After `init()` this is the
/// unlocker that ran; before `init()` it reflects the registry match by
/// identity. `"Unknown"` when no unlocker matches.
pub fn platform_name(&self) -> &str { pub fn platform_name(&self) -> &str {
match self.platform { if let Some(ref n) = self.unlocker_name {
Some(ref p) => p.name(), return n;
None => "Unknown",
} }
// Cache the registry match so we can hand out a `&str` borrow.
self.matched_name_cache.get_or_init(|| {
crate::unlock::matching_name(&self.drive_id).unwrap_or_else(|| "Unknown".to_string())
})
} }
pub fn device_path(&self) -> &str { pub fn device_path(&self) -> &str {
@@ -395,20 +388,28 @@ impl Drive {
tracing::info!(target: "freemkv::drive", phase = "init", "begin"); tracing::info!(target: "freemkv::drive", phase = "init", "begin");
if self.disc_is_dvd() { if self.disc_is_dvd() {
tracing::info!(target: "freemkv::drive", phase = "init", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no unlock)"); tracing::info!(target: "freemkv::drive", phase = "init", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no unlock)");
self.init_ran = true;
return Ok(()); return Ok(());
} }
let r = match self.driver { // Walk the unlock registry: the first unlocker whose identity
Some(ref mut d) => d.init(self.scsi.as_mut()), // matches runs; none matching leaves the drive in stock mode so the
None => Err(Error::UnsupportedDrive { // host-cert AACS handshake (the OEM route) carries the disc.
vendor_id: self.drive_id.vendor_id.trim().to_string(), let r = crate::unlock::route_unlock(self.scsi.as_mut(), &self.drive_id);
product_id: self.drive_id.product_id.trim().to_string(), self.init_ran = true;
product_revision: self.drive_id.product_revision.trim().to_string(), let r = match r {
}), Ok(Some(name)) => {
self.unlocker_name = Some(name);
Ok(())
}
// No unlocker matched: not an error — fall through to OEM route.
Ok(None) => Ok(()),
Err(e) => Err(e),
}; };
tracing::info!( tracing::info!(
target: "freemkv::drive", target: "freemkv::drive",
phase = "init", phase = "init",
ok = r.is_ok(), ok = r.is_ok(),
unlocker = self.unlocker_name.as_deref().unwrap_or("none"),
elapsed_ms = t0.elapsed().as_millis() as u64, elapsed_ms = t0.elapsed().as_millis() as u64,
"end" "end"
); );
@@ -427,22 +428,15 @@ impl Drive {
tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)"); tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)");
return Ok(()); return Ok(());
} }
let r = match self.driver { // Disc-speed calibration is firmware-specific and now lives inside
Some(ref mut d) => d.probe_disc(self.scsi.as_mut()), // the unlocker's `unlock()` (run at `init()`). Nothing to do here.
None => Err(Error::UnsupportedDrive {
vendor_id: self.drive_id.vendor_id.trim().to_string(),
product_id: self.drive_id.product_id.trim().to_string(),
product_revision: self.drive_id.product_revision.trim().to_string(),
}),
};
tracing::info!( tracing::info!(
target: "freemkv::drive", target: "freemkv::drive",
phase = "probe_disc", phase = "probe_disc",
ok = r.is_ok(),
elapsed_ms = t0.elapsed().as_millis() as u64, elapsed_ms = t0.elapsed().as_millis() as u64,
"end" "end (calibration handled by unlocker at init)"
); );
r Ok(())
} }
/// Query a specific GET CONFIGURATION feature by code. /// Query a specific GET CONFIGURATION feature by code.
@@ -573,31 +567,21 @@ impl Drive {
} }
pub fn is_ready(&self) -> bool { pub fn is_ready(&self) -> bool {
match self.driver { // Ready once init() has run and an unlocker handled the drive.
Some(ref d) => d.is_ready(), self.init_ran && self.unlocker_name.is_some()
None => false,
}
} }
/// True if the drive is currently in the extended-access state. /// Whether libfreemkv should take the OEM extended-access read path.
/// ///
/// Detected by the platform driver during `init()` from the unlock /// The pluggable [`crate::unlock::Unlocker`] seam reports only
/// response's mode markers. When true: /// success/failure from `unlock()` — it carries no extended-access
/// - SCSI READ_10 returns plaintext sectors (no AACS bus /// marker back into libfreemkv. With no marker channel, libfreemkv
/// encryption applied) /// always uses the standard host-certificate AACS handshake to acquire
/// - VID retrieval works via the per-drive OEM CDB in /// the Volume ID (the OEM route), so this is always `false`. A firmware
/// [`DriveProfile`] without the cert-based AACS handshake /// unlocker still removes riplock / enables BD-UHD reads at `init()`;
/// - Disc-side Host Revocation List enforcement is effectively /// VID acquisition just stays on the cert path.
/// bypassed by the alternate data path
///
/// AACS layer code branches on this: if true, issue the OEM
/// `read_vid_cdb` to retrieve VID directly; if false, fall back
/// to the cert-based mutual-auth handshake.
pub fn is_unlocked(&self) -> bool { pub fn is_unlocked(&self) -> bool {
match self.driver { false
Some(ref d) => d.is_unlocked(),
None => false,
}
} }
/// Read sectors from the disc. Single-shot — no inline retries, no /// Read sectors from the disc. Single-shot — no inline retries, no
@@ -999,19 +983,6 @@ pub(crate) fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
} }
} }
fn create_driver(
platform: profile::Platform,
profile: &DriveProfile,
) -> Result<Box<dyn PlatformDriver>> {
match platform {
profile::Platform::Mt1959A => Ok(Box::new(Mt1959::new(profile.clone(), false))),
profile::Platform::Mt1959B => Ok(Box::new(Mt1959::new(profile.clone(), true))),
profile::Platform::Renesas => Err(Error::PlatformNotImplemented {
platform: "renesas".to_string(),
}),
}
}
#[cfg(test)] #[cfg(test)]
mod halt_tests { mod halt_tests {
use super::*; use super::*;
@@ -1533,23 +1504,31 @@ mod command_tests {
assert_eq!(d.read_buffer(0x02, 0xF1, 16), None); assert_eq!(d.read_buffer(0x02, 0xF1, 16), None);
} }
// ── No-driver paths: init/probe surface UnsupportedDrive ──────── // ── No-unlocker paths: init/probe succeed (OEM fallback) ────────
#[test] #[test]
fn init_without_driver_is_unsupported_drive() { fn init_without_unlocker_is_ok_oem_fallback() {
// from_transport_for_test has no platform driver; init() must // The test transport's identity matches no registered unlocker, so
// return UnsupportedDrive, not panic or silently succeed. // route_unlock returns None. init() must succeed (leaving the drive
// in stock mode for the host-cert handshake), not error — the OEM
// route is the no-match fallback, not a failure.
let mut d = drive_with(vec![]); let mut d = drive_with(vec![]);
assert!(matches!(d.init(), Err(Error::UnsupportedDrive { .. }))); assert!(
d.init().is_ok(),
"no-match init must succeed (OEM fallback)"
);
assert!(
!d.is_ready(),
"no unlocker ran → not in unlocked-ready state"
);
} }
#[test] #[test]
fn probe_disc_without_driver_is_unsupported_drive() { fn probe_disc_without_unlocker_is_ok_noop() {
// Disc-speed calibration moved into the unlocker (run at init).
// With no unlocker, probe_disc is a successful no-op.
let mut d = drive_with(vec![]); let mut d = drive_with(vec![]);
assert!(matches!( assert!(d.probe_disc().is_ok());
d.probe_disc(),
Err(Error::UnsupportedDrive { .. })
));
} }
// ── decode_read_capacity additional boundaries ────────────────── // ── decode_read_capacity additional boundaries ──────────────────
+15 -7
View File
@@ -1,7 +1,9 @@
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD. //! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
//! //!
//! Handles drive access, disc structure parsing, AACS decryption, and raw //! Handles drive access, disc structure parsing, AACS decryption, and raw
//! sector reading. 206 bundled drive profiles. No external files needed. //! sector reading. Drive unlocking is pluggable: libfreemkv owns only the
//! [`Unlocker`] seam and registry — firmware blobs and unlock CDBs live in
//! an external crate (e.g. `freemkv-unlock-ld`).
//! //!
//! # Quick Start //! # Quick Start
//! //!
@@ -44,10 +46,9 @@
//! ```text //! ```text
//! Drive -- open, identify, unlock, read sectors //! Drive -- open, identify, unlock, read sectors
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS) //! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
//! ├── DriveProfile -- per-drive unlock parameters (206 bundled)
//! ├── DriveId -- INQUIRY + GET_CONFIG identification //! ├── DriveId -- INQUIRY + GET_CONFIG identification
//! └── Platform //! └── Unlocker -- pluggable, external (e.g. freemkv-unlock-ld);
//! └── Mt1959 -- MediaTek unlock/read (Renesas planned) //! libfreemkv owns only the trait + registry
//! //!
//! Disc -- scan titles, streams, AACS state //! Disc -- scan titles, streams, AACS state
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions //! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
@@ -103,12 +104,12 @@ pub(crate) mod mpls;
pub mod mux; pub mod mux;
pub mod pes; pub mod pes;
pub(crate) mod platform; pub(crate) mod platform;
pub mod profile;
pub mod progress; pub mod progress;
pub mod scsi; pub mod scsi;
pub mod sector; pub mod sector;
pub(crate) mod speed; pub(crate) mod speed;
pub(crate) mod udf; pub(crate) mod udf;
pub mod unlock;
pub mod verify; pub mod verify;
// Re-export verify types at the crate root for ergonomic imports. // Re-export verify types at the crate root for ergonomic imports.
@@ -157,8 +158,15 @@ pub use io::pipeline::{
// ─── Drive events (low-level callbacks) ───────────────────────────────────── // ─── Drive events (low-level callbacks) ─────────────────────────────────────
pub use event::{BatchSizeReason, Event, EventKind}; pub use event::{BatchSizeReason, Event, EventKind};
pub use identity::DriveId; pub use identity::DriveId;
pub use profile::DriveProfile;
// Platform trait is pub(crate) — callers use Drive, not Platform directly. // ─── Pluggable unlock seam ──────────────────────────────────────────────────
//
// libfreemkv carries no firmware blobs / unlock CDBs / drive profiles. An
// external unlocker crate (e.g. `freemkv-unlock-ld`) implements `Unlocker`
// and registers it once at process start via `register_unlocker`. At
// drive-prep the registry is walked in order; the first matching unlocker
// runs, else the drive falls through to the host-cert AACS handshake.
pub use unlock::{Unlocker, register_unlocker};
// ─── Decryption (AACS / CSS) ──────────────────────────────────────────────── // ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
// //
+5 -34
View File
@@ -1,36 +1,7 @@
//! Platform-specific drive initialization and disc probing. //! Platform-specific filesystem / IO helpers.
//!
//! Drive unlock no longer lives here — it moved out behind the pluggable
//! [`crate::unlock::Unlocker`] seam. This module now carries only the
//! filesystem-type detection used by the writeback / sink paths.
pub mod fs_type; pub mod fs_type;
pub mod mt1959;
use crate::error::Result;
use crate::scsi::ScsiTransport;
pub(crate) trait PlatformDriver: Send {
/// Unlock drive + upload firmware if needed.
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
/// Calibrate drive for this disc. Probes disc surface so the drive's
/// firmware learns the optimal speed for each region. After probing
/// the drive manages per-zone speeds internally — the host just reads
/// at max speed.
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
/// True after successful init().
fn is_ready(&self) -> bool;
/// True if the drive is currently in the extended-access state —
/// per-drive runtime firmware uploaded AND the unlock response's
/// marker bytes confirm the mode is live. When true:
/// - host can issue the per-drive OEM CDBs in
/// [`crate::profile::DriveProfile`]
/// - VID retrieval works via the OEM CDB path (no cert-based
/// mutual auth required)
/// - SCSI READ_10 returns plaintext sectors (no bus encryption)
///
/// Default `false` — platforms without this mode always report
/// inactive.
fn is_unlocked(&self) -> bool {
false
}
}
-537
View File
@@ -1,537 +0,0 @@
//! MT1959 platform — shared logic for both variants.
mod variant_a;
mod variant_b;
use super::PlatformDriver;
use crate::error::{Error, Result};
use crate::profile::DriveProfile;
use crate::scsi::{self, DataDirection, ScsiTransport};
// ── Variant constants ──────────────────────────────────────────────────
// Every vendor command: 3C [mode] [buffer_id] [sub_cmd] [addr] ...
const MODE_A: u8 = 0x01;
const MODE_B: u8 = 0x02;
const BUFFER_ID_A: u8 = 0x44;
const BUFFER_ID_B: u8 = 0x77;
// ── SCSI opcodes ──────────────────────────────────────────────────────
const SCSI_READ_BUFFER: u8 = 0x3C;
const SCSI_READ_CAPACITY: u8 = 0x25;
/// Shared by both firmware-upload variants (see `variant_a` / `variant_b`).
pub(super) const SCSI_WRITE_BUFFER: u8 = 0x3B;
// ── Sub-commands (shared A/B) ─────────────────────────────────────────
const SUB_CMD_UNLOCK: u8 = 0x00;
const SUB_CMD_INIT: u8 = 0x12;
const SUB_CMD_PROBE: u8 = 0x14;
const UNLOCK_RESPONSE_SIZE: u8 = 64;
const VALIDATE_RESPONSE_SIZE: u8 = 4;
/// Primary mode marker at bytes [12..16] of the unlock response — set
/// by the platform firmware when the runtime image is loaded and the
/// extended-access surface is live.
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
/// Secondary mode marker repeated through bytes [16..64] of the unlock
/// response. Confirms the runtime firmware is the one driving the
/// response, not a stale image's residual buffer.
const FIRMWARE_MODE_OFFSET: usize = 16;
const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
// ── Init address (per disc type) ──────────────────────────────────────
const INIT_ADDR_BD: u16 = 0x0100;
const INIT_ADDR_UHD: u16 = 0x0200;
// ── Probe scan ranges ─────────────────────────────────────────────────
const PROBE_COARSE_END: u16 = 0x5800;
const PROBE_FINE_END: u32 = 0x10000;
const PROBE_STEP: u16 = 0x0100;
const PROBE_RESPONSE_SIZE: u8 = 4;
// ── Disc type threshold ───────────────────────────────────────────────
const UHD_SECTOR_THRESHOLD: u32 = 25_000_000; // ~50 GB
const READ_CAPACITY_RESPONSE_SIZE: usize = 8;
pub struct Mt1959 {
pub(crate) profile: DriveProfile,
pub(crate) mode: u8,
pub(crate) buffer_id: u8,
/// True after `run_init` has completed the unlock handshake (and any
/// required firmware upload). Gates probe + downstream control
/// commands; says nothing about whether the drive is in
/// extended-access mode.
pub(crate) init_complete: bool,
/// True when the unlock response carried both the per-drive
/// signature AND the primary mode marker at offset 12 AND the
/// secondary mode marker at offset 16. When true the drive is in
/// the extended-access state — host can issue the per-drive
/// OEM CDBs and read sectors without the cert-based AACS bus
/// encryption / mutual-auth gate.
unlocked: bool,
probed: bool,
}
impl Mt1959 {
pub fn new(profile: DriveProfile, is_variant_b: bool) -> Self {
let (mode, buffer_id) = if is_variant_b {
(MODE_B, BUFFER_ID_B)
} else {
(MODE_A, BUFFER_ID_A)
};
Mt1959 {
profile,
mode,
buffer_id,
init_complete: false,
unlocked: false,
probed: false,
}
}
// ── SCSI helpers (shared by both variants) ─────────────────────────
pub(crate) fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
[
SCSI_READ_BUFFER,
self.mode,
self.buffer_id,
sub_cmd,
(address >> 8) as u8,
address as u8,
0x00,
0x00,
length,
0x00,
]
}
pub(crate) fn read_buffer_probe(
&self,
scsi: &mut dyn ScsiTransport,
sub_cmd: u8,
address: u16,
buf: &mut [u8],
expected: usize,
) -> Result<usize> {
// The READ_BUFFER CDB transfer-length is a single byte; an
// `expected` above 255 cannot be expressed and would silently
// truncate. All in-crate callers pass small fixed sizes (4); guard
// the invariant rather than emit a malformed CDB.
debug_assert!(
expected <= u8::MAX as usize,
"read_buffer_probe expected exceeds 1-byte CDB length field"
);
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 {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
Ok(result.bytes_transferred)
}
pub(crate) fn set_cd_speed_max(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let cdb = scsi::build_set_cd_speed(0xFFFF);
let mut dummy = [0u8; 0];
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
Ok(())
}
// ── Unlock (shared) ────────────────────────────────────────────────
pub(crate) fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
let cdb = [
0x3C,
self.mode,
self.buffer_id,
SUB_CMD_UNLOCK,
0x00,
0x00,
0x00,
0x00,
UNLOCK_RESPONSE_SIZE,
0x00,
];
let mut response = vec![0u8; UNLOCK_RESPONSE_SIZE as usize];
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
// `response` is a fixed 64-byte buffer, so `response.len()` is
// always >= every offset below — the meaningful bound is how many
// bytes the drive actually delivered. Validate against
// `bytes_transferred` so a short/partial transfer (stale trailing
// zeros) can't be read as if the drive sent real marker bytes.
let n = result.bytes_transferred.min(response.len());
if n >= 4 && response[0..4] != self.profile.signature {
return Err(Error::SignatureMismatch {
expected: self.profile.signature,
got: response[0..4].try_into().unwrap_or([0; 4]),
});
}
if n >= FIRMWARE_ACTIVE_OFFSET + 4
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG
{
return Err(Error::UnlockFailed);
}
// Extended-access state is active when BOTH the per-drive
// signature matched AND the response carries the secondary
// marker at offset 16 (repeated through bytes 16..64) AND the
// primary mode marker at [12..16] is present. The active-mode
// marker at [12..16] is the primary gate; the [16..20] marker
// is the redundant confirmation the firmware writes through
// the rest of the response. Requiring both before we tell the
// upper layer "OEM path is live" keeps any partial / corrupted
// response from steering us off the cert-auth fallback.
self.unlocked = n >= FIRMWARE_MODE_OFFSET + 4
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] == FIRMWARE_ACTIVE_SIG
&& response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
self.init_complete = true;
Ok(response)
}
fn validate(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
for _attempt in 0..5 {
let cdb = [
0x3C,
self.mode,
self.buffer_id,
SUB_CMD_UNLOCK,
0x00,
0x00,
0x00,
0x00,
VALIDATE_RESPONSE_SIZE,
0x00,
];
let mut resp = [0u8; 4];
if scsi
.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000)
.is_ok()
{
return Ok(());
}
}
Err(Error::ScsiError {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
})
}
// ── Init (unlock + firmware) ───────────────────────────────────────
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let mut succeeded = false;
for _attempt in 0..3 {
match self.do_unlock(scsi) {
Ok(_) => {
succeeded = true;
break;
}
Err(Error::SignatureMismatch { .. }) => {
return Err(Error::UnlockFailed);
}
Err(_) => {
let loaded = if self.mode == MODE_A {
variant_a::load_firmware(self, scsi).is_ok()
} else {
variant_b::load_firmware(self, scsi).is_ok()
};
if !loaded {
continue;
}
// Firmware upload resets the drive. Give it time to
// fully recover before retrying unlock.
std::thread::sleep(std::time::Duration::from_secs(10));
}
}
}
if !succeeded {
return Err(Error::UnlockFailed);
}
Ok(())
}
// ── Probe disc ─────────────────────────────────────────────────────
/// Probe the disc surface so the drive firmware learns optimal speeds
/// per region. Two passes, then SET_CD_SPEED(max). After this the
/// drive manages per-zone speeds internally.
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if !self.init_complete {
self.do_unlock(scsi)?;
}
// Detect disc type from capacity to select probe mode.
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
// Empirically verified via SCSI capture: BD and UHD use different init addresses.
let cap_cdb = [
SCSI_READ_CAPACITY,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
let mut cap_buf = [0u8; READ_CAPACITY_RESPONSE_SIZE];
let disc_sectors = if scsi
.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000)
.is_ok()
{
// last_lba + 1 = sector count. A 0xFFFFFFFF last-LBA is the
// READ CAPACITY(10) "capacity exceeds 32 bits" sentinel; saturate
// rather than wrap to 0 (which would misclassify a huge disc as
// BD). A saturated count stays above the UHD threshold -> UHD.
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]).saturating_add(1)
} else {
0
};
let init_addr = if disc_sectors > UHD_SECTOR_THRESHOLD {
INIT_ADDR_UHD
} else {
INIT_ADDR_BD
};
let mut init_resp = [0u8; PROBE_RESPONSE_SIZE as usize];
let _ = self.read_buffer_probe(
scsi,
SUB_CMD_INIT,
init_addr,
&mut init_resp,
PROBE_RESPONSE_SIZE as usize,
);
self.validate(scsi)?;
// Pass 1: coarse scan
let mut addr: u16 = 0;
while addr < PROBE_COARSE_END {
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
if self
.read_buffer_probe(
scsi,
SUB_CMD_PROBE,
addr,
&mut resp,
PROBE_RESPONSE_SIZE as usize,
)
.is_err()
{
return Err(Error::ScsiError {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
addr = addr.wrapping_add(PROBE_STEP);
}
// Pass 2: fine scan
let mut addr: u32 = 0;
while addr < PROBE_FINE_END {
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
if self
.read_buffer_probe(
scsi,
SUB_CMD_PROBE,
addr as u16,
&mut resp,
PROBE_RESPONSE_SIZE as usize,
)
.is_err()
{
break;
}
addr += PROBE_STEP as u32;
}
// Set max speed — drive manages zones from here
let _ = self.set_cd_speed_max(scsi);
self.probed = true;
Ok(())
}
}
// ── PlatformDriver trait ───────────────────────────────────────────────
impl PlatformDriver for Mt1959 {
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if self.init_complete {
return Ok(());
}
self.run_init(scsi)
}
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if !self.init_complete {
// Don't retry init here — if init() failed, probing can't work either.
// Retrying causes repeated USB bus resets on BU40N.
return Ok(());
}
if self.probed {
return Ok(());
}
self.run_probe(scsi)
}
fn is_ready(&self) -> bool {
self.init_complete
}
fn is_unlocked(&self) -> bool {
self.unlocked
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::{DriveProfile, Identity};
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
/// Minimal mock transport that returns a scripted response to the
/// next `execute()` call. Only used for verifying that `do_unlock`
/// classifies the response correctly — no general SCSI coverage.
struct ScriptedTransport {
response: Vec<u8>,
}
impl ScsiTransport for ScriptedTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let n = self.response.len().min(data.len());
data[..n].copy_from_slice(&self.response[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
fn fixture_profile(signature: [u8; 4]) -> DriveProfile {
DriveProfile {
identity: Identity {
vendor_id: "TEST".into(),
product_revision: String::new(),
vendor_specific: String::new(),
firmware_date: String::new(),
},
signature,
firmware: Vec::new(),
unlock_init_value: 0,
unlock_response_size: 0,
read_vid_cdb: None,
read_disc_keys_cdb: None,
drive_nominal_speed_cdb: None,
set_speed_max_cdb: None,
read10_raw_2sec_cdb: None,
read10_raw_1sec_cdb: None,
read_buffer_verify_cdb: None,
write_buffer_cdb: None,
read_buffer_unlock_cdb: None,
speed_zone_table: None,
speed_calc_table: None,
}
}
/// Build a synthetic 64-byte unlock response.
///
/// `mode_marker`: bytes [12..16]. Pass `FIRMWARE_ACTIVE_SIG` for the
/// active-mode primary marker.
/// `id_marker`: bytes [16..20] (and repeated through [20..64] in
/// real responses; only [16..20] is checked).
fn build_response(signature: [u8; 4], mode_marker: [u8; 4], id_marker: [u8; 4]) -> Vec<u8> {
let mut r = vec![0u8; 64];
r[0..4].copy_from_slice(&signature);
// bytes [4..12] left as zeros (version + reserved per format)
r[12..16].copy_from_slice(&mode_marker);
// Real firmware repeats the secondary marker through [16..64];
// the parser only checks [16..20], so we just write the marker
// once.
r[16..20].copy_from_slice(&id_marker);
r
}
#[test]
fn do_unlock_sets_unlocked_when_both_markers_present() {
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile(sig), false);
let raw = mt.do_unlock(&mut transport).expect("unlock should succeed");
assert_eq!(raw.len(), 64);
assert!(mt.init_complete, "init_complete set after success");
assert!(
mt.is_unlocked(),
"both markers present -> extended-access state"
);
}
#[test]
fn do_unlock_init_complete_but_not_unlocked_when_id_marker_missing() {
// Primary mode marker present (so init passes) but the
// secondary marker is replaced with zeros — drive isn't in
// extended-access state.
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, [0u8; 4]);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile(sig), false);
mt.do_unlock(&mut transport).expect("unlock should succeed");
assert!(mt.init_complete);
assert!(
!mt.is_unlocked(),
"missing secondary marker -> not in extended-access state"
);
}
#[test]
fn do_unlock_rejects_signature_mismatch() {
let response = build_response(
[0xAA, 0xBB, 0xCC, 0xDD],
FIRMWARE_ACTIVE_SIG,
FIRMWARE_MODE_SIG,
);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile([0x99, 0x9E, 0xC3, 0x75]), false);
let err = mt.do_unlock(&mut transport).unwrap_err();
assert!(matches!(err, Error::SignatureMismatch { .. }));
assert!(!mt.init_complete);
assert!(!mt.is_unlocked());
}
#[test]
fn do_unlock_rejects_inactive_mode_marker() {
// Signature matches but the primary marker at [12..16] is
// missing -> drive is not in active mode; init_complete and the
// unlocked flag must both stay false.
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, [0u8; 4], FIRMWARE_MODE_SIG);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile(sig), false);
let err = mt.do_unlock(&mut transport).unwrap_err();
assert!(matches!(err, Error::UnlockFailed));
assert!(!mt.init_complete);
assert!(!mt.is_unlocked());
}
}
-74
View File
@@ -1,74 +0,0 @@
//! MT1959 variant A firmware upload.
//!
//! WRITE_BUFFER (0x3B) → verify READ_BUFFER (0x45) → unlock × 2
use super::Mt1959;
use crate::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
use super::SCSI_WRITE_BUFFER;
const VERIFY_BUFFER_ID: u8 = 0x45;
/// WRITE_BUFFER carries a 24-bit transfer length, so a firmware blob
/// larger than this cannot be uploaded in one command.
const WRITE_BUFFER_MAX_LEN: usize = 0x00FF_FFFF;
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
let firmware = &mt.profile.firmware;
if firmware.is_empty() {
return Err(crate::error::Error::UnlockFailed);
}
// Upload firmware via WRITE_BUFFER. The CDB's length is a 24-bit field;
// if the blob exceeds that, the encoded length would silently disagree
// with the bytes actually sent (`data`). Reject rather than upload a
// length-mismatched command.
let len = firmware.len();
if len > WRITE_BUFFER_MAX_LEN {
return Err(crate::error::Error::UnlockFailed);
}
let cdb = [
SCSI_WRITE_BUFFER,
0x06,
0x00,
0x00,
0x00,
0x00,
(len >> 16) as u8,
(len >> 8) as u8,
len as u8,
0x00,
];
let mut data = firmware.clone();
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// Verify firmware loaded (non-fatal — different buffer_id 0x45)
let verify_cdb = [
super::SCSI_READ_BUFFER,
super::MODE_A,
VERIFY_BUFFER_ID,
0x00,
0x00,
0x00,
0x00,
0x00,
super::VALIDATE_RESPONSE_SIZE,
0x00,
];
let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize];
let _ = scsi.execute(
&verify_cdb,
DataDirection::FromDevice,
&mut verify_resp,
5_000,
);
// Double unlock after firmware upload. The first establishes the
// unlock and is fatal on failure; the second is a confirmation pass and
// is best-effort (matching variant B), so a benign hiccup on the
// redundant call doesn't fail an already-successful unlock.
mt.do_unlock(scsi)?;
let _ = mt.do_unlock(scsi);
Ok(())
}
-97
View File
@@ -1,97 +0,0 @@
//! MT1959 variant B firmware upload.
//!
//! MODE SELECT (0x55) → read metadata → WRITE_BUFFER → vendor verify (0xF1) → unlock × 5+1
use super::{Mt1959, SCSI_READ_BUFFER, SCSI_WRITE_BUFFER};
use crate::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
const SCSI_MODE_SELECT: u8 = 0x55;
const FIRMWARE_MAX_SIZE: usize = 0x9C0;
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
let firmware = &mt.profile.firmware;
if firmware.is_empty() {
return Err(crate::error::Error::UnlockFailed);
}
// Step 1: Upload firmware via MODE SELECT. Variant-B firmware blobs are
// exactly FIRMWARE_MAX_SIZE; a larger blob means a corrupt/wrong profile,
// and silently truncating it would upload a partial image that can't
// unlock. Reject it explicitly instead.
if firmware.len() > FIRMWARE_MAX_SIZE {
return Err(crate::error::Error::UnlockFailed);
}
let write_len = FIRMWARE_MAX_SIZE.min(firmware.len());
let mode_select_cdb = [
SCSI_MODE_SELECT,
0x10,
0x00,
0x00,
0x00,
0x00,
(write_len >> 16) as u8,
(write_len >> 8) as u8,
write_len as u8,
0x00,
];
let mut data = firmware[..write_len].to_vec();
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000)
let read_meta_cdb = [
SCSI_READ_BUFFER,
0x06,
0x00,
0x00,
0x30,
0x00,
0x00,
0x00,
0x10,
0x00,
];
let mut meta_resp = [0u8; 16];
let _ = scsi.execute(
&read_meta_cdb,
DataDirection::FromDevice,
&mut meta_resp,
5_000,
);
// Step 3: Write extra firmware data (all zeros)
let write_extra_cdb = [
SCSI_WRITE_BUFFER,
0x06,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x10,
0x00,
];
let mut data2 = FIRMWARE_EXTRA.to_vec();
let _ = scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000);
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI)
let mut dummy = [0u8; 0];
let _ = scsi.execute(&VENDOR_VERIFY, DataDirection::None, &mut dummy, 5_000);
// Step 5: Unlock retries (up to 5, then a final fatal attempt). On a
// successful unlock we issue one confirmation pass; its result is
// intentionally best-effort — the first call already established the
// unlock state, so a hiccup on the redundant confirmation must not fail
// an otherwise-good unlock.
for _attempt in 0..5 {
if mt.do_unlock(scsi).is_ok() {
let _ = mt.do_unlock(scsi);
return Ok(());
}
}
mt.do_unlock(scsi)?;
Ok(())
}
-641
View File
@@ -1,641 +0,0 @@
//! Drive profile loading and matching.
use crate::error::{Error, Result};
use serde::Deserialize;
/// Top-level profiles file — keyed by chipset + variant.
#[derive(Debug, Deserialize)]
pub struct ProfilesFile {
#[serde(default)]
pub mt1959_a: Vec<DriveProfile>,
#[serde(default)]
pub mt1959_b: Vec<DriveProfile>,
#[serde(default)]
pub renesas: Vec<DriveProfile>,
}
/// Drive identity — matched against INQUIRY data.
#[derive(Debug, Clone, Deserialize)]
pub struct Identity {
#[serde(default)]
pub vendor_id: String,
#[serde(default)]
pub product_revision: String,
#[serde(default)]
pub vendor_specific: String,
#[serde(default)]
pub firmware_date: String,
}
/// Per-drive profile.
#[derive(Debug, Clone, Deserialize)]
pub struct DriveProfile {
pub identity: Identity,
/// Expected first 4 bytes of the drive's unlock response — the
/// per-drive signature the platform checks before trusting the
/// extended-access surface. JSON-encoded as 8 lowercase hex chars.
#[serde(default, deserialize_with = "deserialize_hex4")]
pub signature: [u8; 4],
/// Runtime firmware image uploaded during unlock (variant A/B
/// firmware-load step). JSON-encoded as standard base64; empty when
/// the profile carries no firmware blob.
#[serde(default, deserialize_with = "deserialize_base64")]
pub firmware: Vec<u8>,
// ── OEM-extended-access CDB templates ──────────────────────────────
//
// All optional — older profile blobs that pre-date the CDB capture
// pipeline simply omit these fields and decode as `None`. Encoded
// in the JSON as lowercase hex strings without separators
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
#[serde(default)]
pub unlock_init_value: u8,
#[serde(default)]
pub unlock_response_size: u8,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_vid_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_disc_keys_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
pub drive_nominal_speed_cdb: Option<[u8; 12]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
pub set_speed_max_cdb: Option<[u8; 12]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read10_raw_2sec_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read10_raw_1sec_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_buffer_verify_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub write_buffer_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
// Per-drive identifier tables — variable-length hex strings.
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
pub speed_zone_table: Option<Vec<u8>>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
pub speed_calc_table: Option<Vec<u8>>,
}
/// Chipset + variant — determined by which section the profile was found in.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Platform {
Mt1959A,
Mt1959B,
Renesas,
}
impl Platform {
/// Stable, language-neutral platform identifier. The two MT1959 variants
/// share the chipset but differ in their firmware-upload / unlock
/// sequence, so they get distinct suffixes — callers (and logs) that key
/// off `name()` must be able to tell A from B.
pub fn name(&self) -> &'static str {
match self {
Platform::Mt1959A => "MediaTek MT1959-A",
Platform::Mt1959B => "MediaTek MT1959-B",
Platform::Renesas => "Renesas",
}
}
}
/// Result of a profile lookup: the matched profile plus the platform
/// (chipset + variant) of the section it was found in. The platform
/// determines which unlock/firmware sequence the driver runs.
pub struct ProfileMatch {
/// The matched profile, cloned out of the profiles file.
pub profile: DriveProfile,
/// Which platform section the profile came from.
pub platform: Platform,
}
// ── Parsing ────────────────────────────────────────────────────────────
/// Decode an even-length ASCII hex string into bytes.
///
/// Operates on raw bytes rather than `&str` char-boundary slices: a
/// non-ASCII input (e.g. a hand-edited profile with a multi-byte char)
/// could otherwise have `&s[i..i+2]` land inside a UTF-8 char boundary and
/// panic. Hex is ASCII, so any non-ASCII or non-hex byte simply fails to
/// decode. The error is a stable, language-neutral token (`"hex"`), not a
/// translatable English message.
fn decode_hex(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
let bytes = s.as_bytes();
if bytes.len() % 2 != 0 {
return Err("hex");
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
let hi = (pair[0] as char).to_digit(16).ok_or("hex")?;
let lo = (pair[1] as char).to_digit(16).ok_or("hex")?;
out.push((hi * 16 + lo) as u8);
}
Ok(out)
}
fn parse_hex4(s: &str) -> Result<[u8; 4]> {
let bytes = decode_hex(s).map_err(|_| Error::ProfileParse)?;
let out: [u8; 4] = bytes.try_into().map_err(|_| Error::ProfileParse)?;
Ok(out)
}
fn deserialize_hex4<'de, D>(deserializer: D) -> std::result::Result<[u8; 4], D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.is_empty() {
return Ok([0; 4]);
}
parse_hex4(&s).map_err(serde::de::Error::custom)
}
fn deserialize_base64<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
where
D: serde::Deserializer<'de>,
{
use base64::Engine;
let s = String::deserialize(deserializer)?;
if s.is_empty() {
return Ok(Vec::new());
}
base64::engine::general_purpose::STANDARD
.decode(&s)
.map_err(serde::de::Error::custom)
}
// ── Fixed-length hex deserializers for CDB templates ────────────────────
//
// Profile JSON encodes CDBs as lowercase hex strings without separators.
// An empty string / null / missing field decodes as `None`.
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
decode_hex(s)
}
fn deserialize_opt_hex_bytes_10<'de, D>(
deserializer: D,
) -> std::result::Result<Option<[u8; 10]>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
let out: [u8; 10] = bytes
.try_into()
.map_err(|_| serde::de::Error::custom("len"))?;
Ok(Some(out))
}
fn deserialize_opt_hex_bytes_12<'de, D>(
deserializer: D,
) -> std::result::Result<Option<[u8; 12]>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
let out: [u8; 12] = bytes
.try_into()
.map_err(|_| serde::de::Error::custom("len"))?;
Ok(Some(out))
}
fn deserialize_opt_hex_bytes<'de, D>(
deserializer: D,
) -> std::result::Result<Option<Vec<u8>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
Ok(Some(bytes))
}
// ── Loading ────────────────────────────────────────────────────────────
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> {
load_from_str(BUNDLED_PROFILES)
}
/// Borrow the process-wide cached bundled profiles, parsing once on first
/// use. Avoids re-parsing the ~800 KB JSON on every `Drive::open()`.
///
/// 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> {
use std::sync::OnceLock;
static CACHE: OnceLock<Option<ProfilesFile>> = OnceLock::new();
CACHE
.get_or_init(|| load_from_str(BUNDLED_PROFILES).ok())
.as_ref()
}
/// Find a profile for a drive against the cached bundled profiles.
///
/// Convenience wrapper over [`bundled`] + [`find_by_drive_id`] that skips
/// the per-call re-parse. Returns `None` if no profile matches (or, in the
/// build-bug case, if the bundled JSON failed to parse).
pub fn find_bundled(drive_id: &crate::identity::DriveId) -> Option<ProfileMatch> {
find_by_drive_id(bundled()?, drive_id)
}
fn load_from_str(data: &str) -> Result<ProfilesFile> {
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
}
/// Find a profile matching a drive's INQUIRY fields.
///
/// Two-pass per platform section (MT1959-A, then MT1959-B, then Renesas):
/// first an exact match including `firmware_date`, then — if none — a
/// looser match on vendor / revision / vendor-specific only. The exact
/// pass wins so a drive with a known firmware date binds to its precise
/// profile; the looser pass lets a drive whose firmware date we don't have
/// 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,
drive_id: &crate::identity::DriveId,
) -> Option<ProfileMatch> {
let v = drive_id.vendor_id.trim();
let r = drive_id.product_revision.trim();
let vs = drive_id.vendor_specific.trim();
let date = drive_id.firmware_date.trim();
for (platform, list) in [
(Platform::Mt1959A, &profiles.mt1959_a),
(Platform::Mt1959B, &profiles.mt1959_b),
(Platform::Renesas, &profiles.renesas),
] {
if let Some(p) = list.iter().find(|p| {
p.identity.vendor_id.trim() == v
&& p.identity.product_revision.trim() == r
&& p.identity.vendor_specific.trim() == vs
&& p.identity.firmware_date.trim() == date
}) {
return Some(ProfileMatch {
profile: p.clone(),
platform,
});
}
if let Some(p) = list.iter().find(|p| {
p.identity.vendor_id.trim() == v
&& p.identity.product_revision.trim() == r
&& p.identity.vendor_specific.trim() == vs
}) {
return Some(ProfileMatch {
profile: p.clone(),
platform,
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::DriveId;
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
let mut inquiry = vec![0u8; 96];
inquiry[8..8 + vendor.len().min(8)]
.copy_from_slice(&vendor.as_bytes()[..vendor.len().min(8)]);
inquiry[32..32 + rev.len().min(4)].copy_from_slice(&rev.as_bytes()[..rev.len().min(4)]);
inquiry[36..36 + vs.len().min(7)].copy_from_slice(&vs.as_bytes()[..vs.len().min(7)]);
DriveId::from_inquiry(&inquiry, date)
}
#[test]
fn test_find_known_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
assert_eq!(m.platform, Platform::Mt1959A);
}
#[test]
fn test_find_unknown_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("FAKE-VND", "9.99", "XX12345", "");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
#[test]
fn decode_hex_rejects_non_ascii_without_panic() {
// A multi-byte char of even byte-length must not slice inside a
// char boundary; it must decode-fail gracefully.
assert!(decode_hex("中中").is_err()); // 6 bytes, none hex
assert!(parse_hex4("中中").is_err()); // 6 bytes != 8 anyway
// An 8-byte non-ASCII string (two 4-byte chars) hits the exact-len
// path of parse_hex4; must still error, not panic.
assert!(parse_hex4("𝕏𝕏").is_err());
}
#[test]
fn decode_hex_roundtrips_valid_hex() {
assert_eq!(decode_hex("00ff10").unwrap(), vec![0x00, 0xff, 0x10]);
assert_eq!(parse_hex4("deadbeef").unwrap(), [0xde, 0xad, 0xbe, 0xef]);
assert!(decode_hex("abc").is_err()); // odd length
assert!(decode_hex("zz").is_err()); // non-hex
}
#[test]
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).
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;
assert_eq!(a, b);
}
#[test]
fn find_bundled_matches_known_drive() {
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_bundled(&id).unwrap();
assert_eq!(m.platform, Platform::Mt1959A);
}
// ── New comprehensive tests ────────────────────────────────────────────────
/// decode_hex accepts empty string → empty Vec.
/// Mutation: returning an error on empty input breaks empty-field handling.
#[test]
fn decode_hex_accepts_empty_string() {
assert_eq!(decode_hex("").unwrap(), Vec::<u8>::new());
}
/// decode_hex handles all valid hex digit characters (0-9, a-f, A-F).
/// Mutation: not supporting uppercase A-F means uppercase-encoded profiles fail.
#[test]
fn decode_hex_handles_upper_and_lower_case() {
assert_eq!(
decode_hex("DEADBEEF").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("deadbeef").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("DeAdBeEf").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
}
/// parse_hex4 rejects an 8-hex-char string (4 bytes) correctly.
/// Spec: the signature field is exactly 4 bytes = 8 hex chars.
/// Mutation: accepting 6 hex chars (3 bytes) would pass a wrong-length signature.
#[test]
fn parse_hex4_rejects_wrong_byte_length() {
// 6 hex chars = 3 bytes ≠ 4.
assert!(
parse_hex4("aabbcc").is_err(),
"3 bytes must be rejected for 4-byte field"
);
// 10 hex chars = 5 bytes ≠ 4.
assert!(
parse_hex4("aabbccddee").is_err(),
"5 bytes must be rejected for 4-byte field"
);
// Exactly 8 hex chars = 4 bytes: must succeed.
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
}
/// Platform::name() returns stable, non-empty, language-neutral identifiers.
/// These strings are logged and keyed on in caller code; changing them is a
/// breaking change.
/// Mutation: swapping Mt1959A and Mt1959B names silently misroutes firmware upload.
#[test]
fn platform_name_is_stable() {
// The exact strings are part of the public stable API (logged/keyed).
assert_eq!(Platform::Mt1959A.name(), "MediaTek MT1959-A");
assert_eq!(Platform::Mt1959B.name(), "MediaTek MT1959-B");
assert_eq!(Platform::Renesas.name(), "Renesas");
}
/// 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,
/// and verify the correct one is selected.
/// Mutation: doing only the loose pass would return the first entry regardless of date.
#[test]
fn find_by_drive_id_exact_date_wins_over_loose() {
use serde_json::json;
// Use an 8-char vendor_id (padded with a trailing space so `trim()` strips
// the pad, matching the same trimmed form the JSON profile stores).
// "TESTDRV " fills INQUIRY [8..16] exactly; `ascii_field.trim()` → "TESTDRV".
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200001010000"
},
"signature": "aabbccdd",
"firmware": ""
},
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200006150000"
},
"signature": "11223344",
"firmware": ""
}
]
})
.to_string();
let profiles: ProfilesFile = 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");
let id_date2 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200006150000");
let m1 = find_by_drive_id(&profiles, &id_date1).unwrap();
let m2 = find_by_drive_id(&profiles, &id_date2).unwrap();
// Each must bind to its own profile by exact date match.
assert_eq!(
m1.profile.signature,
[0xaa, 0xbb, 0xcc, 0xdd],
"id_date1 must match first profile"
);
assert_eq!(
m2.profile.signature,
[0x11, 0x22, 0x33, 0x44],
"id_date2 must match second profile"
);
}
/// find_by_drive_id: loose match (no date) still works when an entry has
/// the same vendor/revision/vs but an unknown firmware_date.
/// Mutation: making the loose pass require a date match means "no date" drives
/// always return None even though a same-model profile exists.
#[test]
fn find_by_drive_id_loose_match_when_date_unknown() {
use serde_json::json;
// "LOOSEDR " fills 8 bytes; trim() → "LOOSEDR".
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "LOOSEDR",
"product_revision": "2.00",
"vendor_specific": "YY11111",
"firmware_date": "210101010000"
},
"signature": "deadbeef",
"firmware": ""
}
]
})
.to_string();
let profiles: ProfilesFile = 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.
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(
m.profile.signature,
[0xde, 0xad, 0xbe, 0xef],
"loose match must bind the same-model profile when date differs"
);
}
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
/// Mutation: returning an empty ProfilesFile 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> =
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
assert!(matches!(result, Err(Error::ProfileParse)));
}
/// Bundled profiles is non-empty (mt1959_a has at least one entry).
/// This pins the embedded JSON: if profiles.json is accidentally emptied
/// or truncated, this test goes red.
/// Mutation: clearing profiles.json would make this fail.
#[test]
fn bundled_profiles_has_entries() {
let profiles = load_bundled().unwrap();
assert!(
!profiles.mt1959_a.is_empty(),
"bundled profiles must have at least one mt1959_a entry"
);
}
/// deserialization of a profile with missing optional CDB fields
/// produces None for those fields (not a parse error).
/// Spec: all CDB template fields are `#[serde(default)]` — they are optional.
/// Mutation: making a CDB field required breaks backward-compat with old blobs.
#[test]
fn profile_optional_cdb_fields_default_to_none() {
use serde_json::json;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "00000000",
"firmware": ""
}
]
})
.to_string();
let profiles: ProfilesFile = 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!(
p.read_vid_cdb.is_none(),
"read_vid_cdb must default to None"
);
assert!(
p.read_disc_keys_cdb.is_none(),
"read_disc_keys_cdb must default to None"
);
assert!(
p.drive_nominal_speed_cdb.is_none(),
"drive_nominal_speed_cdb must default to None"
);
assert!(
p.set_speed_max_cdb.is_none(),
"set_speed_max_cdb must default to None"
);
assert!(
p.speed_zone_table.is_none(),
"speed_zone_table must default to None"
);
assert!(
p.speed_calc_table.is_none(),
"speed_calc_table must default to None"
);
}
/// deserialize_hex4 of an empty string must produce [0;4] without error.
/// This matches `deserialize_hex4`'s explicit early-return for empty strings.
/// Mutation: treating empty string as an error prevents profiles where signature
/// was not captured from loading.
#[test]
fn profile_empty_signature_deserialises_as_zeroes() {
use serde_json::json;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "",
"firmware": ""
}
]
})
.to_string();
let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap();
assert_eq!(
profiles.mt1959_a[0].signature, [0u8; 4],
"empty signature must deserialise as [0;4]"
);
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Pluggable drive-unlock seam.
//!
//! libfreemkv knows the *seam*, never the *mechanism*. An [`Unlocker`] is
//! supplied by an external crate (e.g. `freemkv-unlock-ld`) and registered
//! once at process start via [`register_unlocker`]. At drive-prep the
//! registry is walked in registration order; the first unlocker whose
//! [`Unlocker::matches`] returns true is asked to [`Unlocker::unlock`] the
//! drive by issuing its own CDBs through the raw [`ScsiTransport`].
//!
//! No firmware blobs, no unlock CDBs, no drive profiles live here — only
//! the trait, the registry, and the routing. If no unlocker matches, the
//! drive is left untouched and the caller falls back to the standard
//! host-certificate AACS handshake (the "OEM route").
use crate::error::Result;
use crate::identity::DriveId;
use crate::scsi::ScsiTransport;
use std::sync::RwLock;
/// A pluggable drive unlocker.
///
/// Implementors own everything about *how* a particular drive family is
/// unlocked: firmware upload, vendor CDBs, variant logic. libfreemkv only
/// hands over the raw SCSI transport and the drive identity.
pub trait Unlocker: Send + Sync {
/// Stable, language-neutral identifier for this unlocker (logged).
fn name(&self) -> &str;
/// True if this unlocker handles the given drive.
fn matches(&self, id: &DriveId) -> bool;
/// Unlock the drive. The unlocker issues its own CDBs through `scsi`.
/// Returns `Ok(())` once the drive is prepared for reads.
fn unlock(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()>;
}
/// Process-wide ordered registry of unlockers.
static REGISTRY: RwLock<Vec<Box<dyn Unlocker>>> = RwLock::new(Vec::new());
/// Register an unlocker. Order is preserved; [`route_unlock`] tries each in
/// registration order and stops at the first whose `matches` is true.
///
/// Call once at process start (CLI / service `main`), before any rip. The
/// single `register_unlocker(...)` line is the entire plug — remove it (and
/// the unlocker crate) and libfreemkv still compiles and falls back to the
/// host-cert handshake.
pub fn register_unlocker(u: Box<dyn Unlocker>) {
if let Ok(mut reg) = REGISTRY.write() {
reg.push(u);
}
}
/// Walk the registry in order and run the first matching unlocker.
///
/// Returns:
/// * `Ok(Some(name))` — a registered unlocker matched and unlocked the
/// drive; `name` is its [`Unlocker::name`].
/// * `Ok(None)` — no unlocker matched; the drive was left untouched and
/// the caller should fall through to the host-cert handshake.
/// * `Err(_)` — an unlocker matched but its `unlock` failed.
pub(crate) fn route_unlock(scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<Option<String>> {
let reg = match REGISTRY.read() {
Ok(r) => r,
// A poisoned lock means a prior unlocker panicked; treat as
// "no unlocker available" so the cert fallback still runs.
Err(_) => return Ok(None),
};
for u in reg.iter() {
if u.matches(id) {
let name = u.name().to_string();
u.unlock(scsi, id)?;
return Ok(Some(name));
}
}
Ok(None)
}
/// Number of registered unlockers — test/introspection helper.
#[doc(hidden)]
pub fn registered_count() -> usize {
REGISTRY.read().map(|r| r.len()).unwrap_or(0)
}
/// Name of the first registered unlocker that matches `id`, without
/// running it. Used for drive-info display ("is this drive supported?")
/// before any unlock has been attempted.
pub(crate) fn matching_name(id: &DriveId) -> Option<String> {
let reg = REGISTRY.read().ok()?;
reg.iter()
.find(|u| u.matches(id))
.map(|u| u.name().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct NoopTransport;
impl ScsiTransport for NoopTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
fn fake_id(vendor: &str) -> DriveId {
let mut inquiry = vec![0u8; 96];
let v = vendor.as_bytes();
inquiry[8..8 + v.len().min(8)].copy_from_slice(&v[..v.len().min(8)]);
DriveId::from_inquiry(&inquiry, "")
}
/// Fake unlocker that records whether it ran, and matches on vendor id.
struct FakeUnlocker {
want_vendor: String,
ran: Arc<AtomicBool>,
}
impl Unlocker for FakeUnlocker {
fn name(&self) -> &str {
"fake"
}
fn matches(&self, id: &DriveId) -> bool {
id.vendor_id.trim() == self.want_vendor
}
fn unlock(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
self.ran.store(true, Ordering::SeqCst);
Ok(())
}
}
/// A registered, matching unlocker runs; a non-matching identity leaves
/// the registry untouched and routes to the OEM (cert) fallback.
///
/// Both assertions live in one test because the registry is process-wide
/// and tests share it — running them as one case keeps the ordering
/// deterministic regardless of test-harness threading.
#[test]
fn registry_routes_match_else_oem() {
let ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(FakeUnlocker {
want_vendor: "MATCHVND".into(),
ran: ran.clone(),
}));
// Matching identity → unlocker runs, returns its name.
let mut scsi = NoopTransport;
let matched = route_unlock(&mut scsi, &fake_id("MATCHVND")).unwrap();
assert_eq!(matched.as_deref(), Some("fake"), "matching unlocker runs");
assert!(ran.load(Ordering::SeqCst), "unlock() was invoked");
// Non-matching identity → no unlocker runs, OEM path (None).
ran.store(false, Ordering::SeqCst);
let none = route_unlock(&mut scsi, &fake_id("OTHERVND")).unwrap();
assert!(none.is_none(), "no match → OEM/cert fallback");
assert!(
!ran.load(Ordering::SeqCst),
"unlock() not invoked on no-match"
);
}
}