collapse freemkv-unlock into one crate; ld becomes a module
Replace the ld/aacs/css member-crate workspace with a single freemkv-unlock crate: the generic Unlocker contract + SCSI transport contract at the crate root (lib.rs, scsi.rs, error.rs), and the firmware unlocker as the self- contained src/ld module. The contract is repo-agnostic raw types (DriveId, HostCert, DiscKind, Unlocked, UnlockError) with NO libfreemkv dependency, so libfreemkv will depend on this crate (one-way, no cycle) and dispatch via all_unlockers(). ld now impl crate::Unlocker, takes its own DriveId (4 raw fields, no INQUIRY parsing), and returns a raw Option<[u8;16]> VID. The old aacs/css plugin wrappers are removed; their crypto moves in from libfreemkv in the next stages. 31 tests pass.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
//! Public unlock-CDB seam.
|
||||
//!
|
||||
//! These items expose the *minimum* an external consumer (e.g. the bdemu
|
||||
//! drive emulator) needs to recognise and answer the LibreDrive unlock
|
||||
//! READ_BUFFER handshake, without that consumer open-coding the
|
||||
//! handshake internals. The concrete CDB shapes and the verification
|
||||
//! marker are unlock-handshake details and must live ONLY in this crate.
|
||||
|
||||
/// The 4-byte marker the unlock READ_BUFFER response carries at bytes
|
||||
/// `[12..16]`. A consumer answering the handshake writes this at that
|
||||
/// offset; a verifier checks for it there. It is the universal
|
||||
/// "this is a real unlock reply" tag, independent of the per-drive
|
||||
/// signature at `[0..4]`.
|
||||
pub const UNLOCK_MARKER: &[u8] = b"MMkv";
|
||||
|
||||
/// Returns `true` when a READ_BUFFER (`0x3C`) CDB with the given mode
|
||||
/// (`cdb[1] & 0x1F`) and buffer id (`cdb[2]`) is an unlock-handshake
|
||||
/// read — i.e. one of the LibreDrive unlock variants.
|
||||
///
|
||||
/// This is the single source of truth for the unlock READ_BUFFER CDB
|
||||
/// shapes; consumers must call it rather than hardcoding the mode /
|
||||
/// buffer-id pairs.
|
||||
pub fn is_unlock_read_buffer(mode: u8, buf_id: u8) -> bool {
|
||||
matches!((mode, buf_id), (1, 0x44) | (2, 0x77))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marker_is_four_bytes() {
|
||||
assert_eq!(UNLOCK_MARKER, b"MMkv");
|
||||
assert_eq!(UNLOCK_MARKER.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_variants_match() {
|
||||
// Variant A and variant B.
|
||||
assert!(is_unlock_read_buffer(1, 0x44));
|
||||
assert!(is_unlock_read_buffer(2, 0x77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_unlock_cdbs_do_not_match() {
|
||||
// Right mode, wrong buffer id.
|
||||
assert!(!is_unlock_read_buffer(1, 0x77));
|
||||
assert!(!is_unlock_read_buffer(2, 0x44));
|
||||
// Wrong mode, right buffer id.
|
||||
assert!(!is_unlock_read_buffer(0, 0x77));
|
||||
assert!(!is_unlock_read_buffer(0, 0x44));
|
||||
assert!(!is_unlock_read_buffer(3, 0x77));
|
||||
// Ordinary data reads.
|
||||
assert!(!is_unlock_read_buffer(2, 0x00));
|
||||
assert!(!is_unlock_read_buffer(0, 0x00));
|
||||
}
|
||||
}
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
//! ld — the LibreDrive firmware unlocker (MediaTek MT1959).
|
||||
//!
|
||||
//! Self-contained module: it owns the bundled drive profiles, firmware blobs,
|
||||
//! the WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A /
|
||||
//! variant-B handshake. It implements [`crate::Unlocker`] — removing AACS bus
|
||||
//! encryption AT THE DRIVE (the unlocked drive serves clear content) and
|
||||
//! reporting the OEM Volume ID.
|
||||
|
||||
mod cdb;
|
||||
mod platform;
|
||||
mod profile;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// The LibreDrive unlocker.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
impl LibreDrive {
|
||||
pub fn new() -> Self {
|
||||
LibreDrive
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LibreDrive {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LibreDrive {
|
||||
/// Read the OEM Volume ID via the matched profile's vendor CDB.
|
||||
///
|
||||
/// `Ok(Some(vid))` on a well-formed 36-byte response (signature `00 22 00`,
|
||||
/// VID at `[4..20]`); `Ok(None)` when there is no OEM-VID CDB or the response
|
||||
/// is short / bad-signature (the drive is still unlocked, just no VID); `Err`
|
||||
/// only on a transport fault.
|
||||
fn read_oem_vid(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<Option<[u8; 16]>> {
|
||||
const RESPONSE_LEN: usize = 36;
|
||||
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(cdb) = m.profile.read_vid_cdb else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; RESPONSE_LEN];
|
||||
let result = scsi.execute(&cdb, 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 Ok(None);
|
||||
}
|
||||
if buf[0..3] != EXPECTED_HEADER {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_bad_header",
|
||||
"OEM VID response header mismatch"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let mut vid = [0u8; 16];
|
||||
vid.copy_from_slice(&buf[4..20]);
|
||||
tracing::debug!(target: "freemkv::disc", phase = "oem_vid_ok", "OEM VID retrieved via unlocker");
|
||||
Ok(Some(vid))
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
/// Applies when the drive matches a bundled firmware profile. Disc kind is
|
||||
/// irrelevant — firmware unlock removes bus encryption at the drive for any
|
||||
/// disc; it runs first, so a profiled drive is unlocked before the cert/CSS
|
||||
/// unlockers are ever consulted.
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool {
|
||||
profile::find_bundled(ctx.drive_id).is_some()
|
||||
}
|
||||
|
||||
/// Firmware-unlock the drive and report its OEM Volume ID. The unlocked drive
|
||||
/// serves CLEAR content, so `drive_unlocked: true` and there is no bus key.
|
||||
///
|
||||
/// A no-firmware-route drive (Renesas) returns `NotApplicable` (fall through);
|
||||
/// a transport fault propagates as `Transport`; a firmware failure that isn't
|
||||
/// a dead bus also falls through (`NotApplicable`).
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let id = ctx.drive_id;
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
};
|
||||
if matches!(m.platform, profile::Platform::Renesas) {
|
||||
// Renesas firmware unlock is not implemented — fall through to cert.
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B);
|
||||
use platform::PlatformDriver;
|
||||
let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b);
|
||||
// Firmware unlock. A transport fault → UnlockError::Transport; any other
|
||||
// firmware failure → NotApplicable (via From<error::Error>).
|
||||
mt.init(scsi)?;
|
||||
// Prime the per-region speed table (best-effort — must not fail the unlock).
|
||||
let _ = mt.probe_disc(scsi);
|
||||
// The unlocked drive hands back its Volume ID; firmware serves clear
|
||||
// content, so no bus key and drive_unlocked = true.
|
||||
let vid = self.read_oem_vid(scsi, id)?;
|
||||
Ok(Unlocked {
|
||||
vid,
|
||||
bus_key: None,
|
||||
drive_unlocked: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Raise the drive to its max read speed via the matched profile's
|
||||
/// `set_speed_max_cdb` (no-op if the profile carries none).
|
||||
fn set_max_read_speed(&self, scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx) -> Result<()> {
|
||||
let Some(m) = profile::find_bundled(ctx.drive_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(cdb) = m.profile.set_speed_max_cdb else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut buf = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut buf, 5_000)?;
|
||||
tracing::debug!(target: "freemkv::drive", phase = "set_max_read_speed", "issued SET CD SPEED (max) via unlocker");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Unlock context for a fake drive id (kind/host-certs irrelevant to the
|
||||
/// firmware unlocker — it keys off the drive identity).
|
||||
fn ctx(id: &DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown, &[])
|
||||
}
|
||||
|
||||
/// A fake transport that fills the response buffer from a fixed payload and
|
||||
/// reports a configurable transferred-byte count.
|
||||
struct FakeTransport {
|
||||
payload: Vec<u8>,
|
||||
bytes_transferred: usize,
|
||||
}
|
||||
impl ScsiTransport for FakeTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A DriveId for the bundled HL-DT-ST profile that carries a real
|
||||
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
|
||||
fn known_vid_drive_id() -> DriveId {
|
||||
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
|
||||
}
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
|
||||
/// to `Some(vid)`.
|
||||
#[test]
|
||||
fn read_oem_vid_parses_well_formed_response() {
|
||||
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||
assert!(
|
||||
m.profile.read_vid_cdb.is_some(),
|
||||
"test fixture drive must carry an OEM VID CDB"
|
||||
);
|
||||
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
|
||||
let vid = [0x3Cu8; 16];
|
||||
payload[4..20].copy_from_slice(&vid);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("parse ok");
|
||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||
}
|
||||
|
||||
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
|
||||
#[test]
|
||||
fn read_oem_vid_short_response_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("short response is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_bad_header_is_none() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("bad header is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → `read_oem_vid` is `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_no_profile_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||
.expect("no profile is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// `unlock` on a drive with no matching profile → `NotApplicable` (fall
|
||||
/// through), short-circuiting before any firmware handshake.
|
||||
#[test]
|
||||
fn unlock_no_profile_is_not_applicable() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.unlock(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
.expect_err("no profile → NotApplicable");
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Records the CDB issued so the speed test can assert the exact CDB.
|
||||
struct RecordingTransport {
|
||||
last_cdb: Vec<u8>,
|
||||
calls: usize,
|
||||
}
|
||||
impl ScsiTransport for RecordingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.last_cdb = cdb.to_vec();
|
||||
self.calls += 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A matched drive whose profile carries `set_speed_max_cdb` → that exact CDB.
|
||||
#[test]
|
||||
fn set_max_read_speed_issues_profile_cdb() {
|
||||
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||
let expected = m
|
||||
.profile
|
||||
.set_speed_max_cdb
|
||||
.expect("test fixture drive must carry a set_speed_max_cdb");
|
||||
|
||||
let mut t = RecordingTransport {
|
||||
last_cdb: Vec::new(),
|
||||
calls: 0,
|
||||
};
|
||||
LibreDrive::new()
|
||||
.set_max_read_speed(&mut t, &ctx(&known_vid_drive_id()))
|
||||
.expect("set_max_read_speed ok");
|
||||
assert_eq!(t.calls, 1, "exactly one CDB issued");
|
||||
assert_eq!(
|
||||
t.last_cdb,
|
||||
expected.to_vec(),
|
||||
"the profile's set_speed_max_cdb"
|
||||
);
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → no-op: no CDB issued, Ok(()).
|
||||
#[test]
|
||||
fn set_max_read_speed_no_profile_is_noop() {
|
||||
let mut t = RecordingTransport {
|
||||
last_cdb: Vec::new(),
|
||||
calls: 0,
|
||||
};
|
||||
LibreDrive::new()
|
||||
.set_max_read_speed(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
.expect("no-profile is a no-op Ok(())");
|
||||
assert_eq!(t.calls, 0, "no profile → no CDB issued");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Platform-specific drive unlock + disc probing (LibreDrive internals).
|
||||
|
||||
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 the disc surface so the
|
||||
/// drive's firmware learns the optimal speed for each region.
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// True after successful init().
|
||||
#[allow(dead_code)]
|
||||
fn is_ready(&self) -> bool;
|
||||
|
||||
/// True if the drive is currently in the extended-access state.
|
||||
#[allow(dead_code)]
|
||||
fn is_unlocked(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
//! MT1959 platform — shared logic for both variants.
|
||||
|
||||
mod variant_a;
|
||||
mod variant_b;
|
||||
|
||||
use super::PlatformDriver;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ld::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::ld::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],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Records every CDB issued, so a test can assert which bytes hit the wire.
|
||||
struct RecordingTransport {
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for RecordingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Empty response → do_unlock's signature check fails, so the unlock
|
||||
// loop exhausts — but the firmware-load CDBs (incl. the F1 verify)
|
||||
// are already recorded by then.
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// variant_b must issue the PROFILE's per-drive `fw_verify_cdb`, not the
|
||||
/// hardcoded fallback const — the bug that broke ~139 of 140 B drives.
|
||||
#[test]
|
||||
fn variant_b_issues_profile_fw_verify_cdb_not_const() {
|
||||
let drive_f1 = [0xF1, 0x01, 0x02, 0x00, 0x0C, 0xF0, 0x01, 0xFB, 0xC9, 0x93];
|
||||
// variant_b's hardcoded fallback const (a different drive's token).
|
||||
let fallback_f1 = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
let mut profile = fixture_profile([0x9a, 0xa9, 0x3a, 0xe2]);
|
||||
profile.firmware = vec![0u8; 2208]; // a real per-drive length, != old 0x9C0
|
||||
profile.fw_verify_cdb = Some(drive_f1);
|
||||
|
||||
let mut mt = Mt1959::new(profile, true);
|
||||
let mut t = RecordingTransport { cdbs: Vec::new() };
|
||||
let _ = variant_b::load_firmware(&mut mt, &mut t);
|
||||
|
||||
assert!(
|
||||
t.cdbs.iter().any(|c| c.as_slice() == drive_f1),
|
||||
"must send the profile's F1 verify CDB"
|
||||
);
|
||||
assert!(
|
||||
!t.cdbs.iter().any(|c| c.as_slice() == fallback_f1),
|
||||
"must NOT send the hardcoded fallback const when the profile has its own"
|
||||
);
|
||||
// MODE SELECT must encode the real per-drive length (2208), not 0x9C0.
|
||||
let ms = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.find(|c| c.first() == Some(&0x55))
|
||||
.expect("MODE SELECT issued");
|
||||
let len = ((ms[7] as usize) << 8) | ms[8] as usize;
|
||||
assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496");
|
||||
}
|
||||
|
||||
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,
|
||||
fw_verify_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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 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_EXTRA: [u8; 16] = [0; 16];
|
||||
/// Fallback F1 vendor-verify for legacy profiles that predate the per-drive
|
||||
/// `fw_verify_cdb` capture. It carries ONE drive's token, so it only works for
|
||||
/// that drive — real profiles must supply their own (see `DriveProfile`).
|
||||
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 the firmware via MODE SELECT. The profile's `firmware` is
|
||||
// the exact per-drive image — extracted at the drive's own load-CDB length
|
||||
// (2192..2528 bytes; the old fixed 0x9C0 truncated some drives and over-read
|
||||
// others into blob strings). Upload all of it. MODE SELECT(10)'s
|
||||
// parameter-list length is 16-bit, so reject only a blob that can't be
|
||||
// expressed in the CDB.
|
||||
let write_len = firmware.len();
|
||||
if write_len > u16::MAX as usize {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
}
|
||||
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). PER-DRIVE: take
|
||||
// it from the profile (39 distinct values across the 140 B drives). The
|
||||
// const is only a legacy fallback — it carries one drive's token.
|
||||
let verify_cdb = mt.profile.fw_verify_cdb.unwrap_or(VENDOR_VERIFY);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&verify_cdb, 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(())
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
//! 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]>,
|
||||
/// Variant-B vendor verify (0xF1) CDB. PER-DRIVE: 39 distinct values across
|
||||
/// the 140 B drives, so it CANNOT be a hardcoded constant. `variant_b`'s old
|
||||
/// `VENDOR_VERIFY` const was one drive's token, wrong for the other ~139.
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub fw_verify_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::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::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::DriveId;
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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]"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user