seam cleanup: /src generic-only; drop set_max from the trait

Per the seam audit: keep /src purely the generic contract and move everything
unlocker-specific into its module.

- ld's error type moves src/error.rs -> src/ld/error.rs; it owns the conversions
  From<scsi::ScsiError> (transport fault in) and From<ld::Error> for UnlockError
  (outcome out). src/scsi.rs keeps only the generic transport contract + a
  generic ScsiError; the trait no longer references any concrete error.
- Remove set_max_read_speed from the Unlocker trait — raising read speed is
  drive tuning, NOT bus removal. libfreemkv will issue a generic SET CD SPEED
  itself at drive-open (tracked).

29 tests pass.
This commit is contained in:
Matthew Jackson
2026-06-29 19:19:19 -07:00
parent 314c365290
commit 0e943c3792
9 changed files with 61 additions and 117 deletions
+76
View File
@@ -0,0 +1,76 @@
//! ld's internal error type for the firmware-unlock SCSI/handshake code.
//! Distinct from [`crate::UnlockError`] (the unlock OUTCOME the consumer sees);
//! this is the low-level error ld's code uses. Maps both ways: a generic
//! [`crate::scsi::ScsiError`] transport fault converts IN, and an `Error`
//! converts OUT to the contract's `UnlockError`.
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// A bundled drive profile could not be parsed (malformed hex / length).
ProfileParse,
/// A handshake completed but the device did not reach the expected state.
UnlockFailed,
/// A verify/handshake response did not match the expected signature.
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
/// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with
/// `sense: None` is a transport-layer fault (bridge crash / disconnect).
ScsiError {
opcode: u8,
status: u8,
sense: Option<[u8; 32]>,
},
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::ProfileParse => write!(f, "drive profile parse error"),
Error::UnlockFailed => write!(f, "firmware unlock failed"),
Error::SignatureMismatch { .. } => write!(f, "signature mismatch"),
Error::ScsiError { opcode, status, .. } => {
write!(f, "SCSI error (opcode {opcode:#04x}, status {status:#04x})")
}
}
}
}
impl std::error::Error for Error {}
impl Error {
/// True if this is a transport-layer SCSI failure (bus dead), as opposed to a
/// drive sense or a logical failure.
pub(crate) fn is_transport_failure(&self) -> bool {
matches!(
self,
Error::ScsiError { status, sense: None, .. }
if *status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE
)
}
}
/// A generic transport fault from the SCSI contract converts into ld's error
/// (the opcode is unknown at the transport level).
impl From<crate::scsi::ScsiError> for Error {
fn from(e: crate::scsi::ScsiError) -> Self {
Error::ScsiError {
opcode: 0,
status: e.status,
sense: e.sense,
}
}
}
/// ld's internal error converts OUT to the contract outcome: a dead bus is a
/// hard `Transport` abort; any other firmware failure means "this unlocker
/// didn't apply" → the consumer falls through to the next unlocker.
impl From<Error> for crate::UnlockError {
fn from(e: Error) -> Self {
if e.is_transport_failure() {
crate::UnlockError::Transport
} else {
crate::UnlockError::NotApplicable
}
}
}
+3 -80
View File
@@ -7,10 +7,11 @@
//! reporting the OEM Volume ID.
mod cdb;
mod error;
mod platform;
mod profile;
use crate::error::Result;
use crate::ld::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
@@ -122,21 +123,6 @@ impl Unlocker for LibreDrive {
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)]
@@ -164,7 +150,7 @@ mod tests {
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
) -> crate::scsi::Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
@@ -271,67 +257,4 @@ mod tests {
.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");
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
pub mod mt1959;
use crate::error::Result;
use crate::ld::error::Result;
use crate::scsi::ScsiTransport;
pub(crate) trait PlatformDriver: Send {
+3 -3
View File
@@ -4,7 +4,7 @@ mod variant_a;
mod variant_b;
use super::PlatformDriver;
use crate::error::{Error, Result};
use crate::ld::error::{Error, Result};
use crate::ld::profile::DriveProfile;
use crate::scsi::{self, DataDirection, ScsiTransport};
@@ -414,7 +414,7 @@ mod tests {
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
) -> crate::scsi::Result<ScsiResult> {
let n = self.response.len().min(data.len());
data[..n].copy_from_slice(&self.response[..n]);
Ok(ScsiResult {
@@ -437,7 +437,7 @@ mod tests {
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
) -> crate::scsi::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)
+3 -3
View File
@@ -3,7 +3,7 @@
//! WRITE_BUFFER (0x3B) → verify READ_BUFFER (0x45) → unlock × 2
use super::Mt1959;
use crate::error::Result;
use crate::ld::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
use super::SCSI_WRITE_BUFFER;
@@ -17,7 +17,7 @@ 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);
return Err(crate::ld::error::Error::UnlockFailed);
}
// Upload firmware via WRITE_BUFFER. The CDB's length is a 24-bit field;
@@ -26,7 +26,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
// length-mismatched command.
let len = firmware.len();
if len > WRITE_BUFFER_MAX_LEN {
return Err(crate::error::Error::UnlockFailed);
return Err(crate::ld::error::Error::UnlockFailed);
}
let cdb = [
SCSI_WRITE_BUFFER,
+3 -3
View File
@@ -3,7 +3,7 @@
//! 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::ld::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
const SCSI_MODE_SELECT: u8 = 0x55;
@@ -16,7 +16,7 @@ const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3,
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);
return Err(crate::ld::error::Error::UnlockFailed);
}
// Step 1: Upload the firmware via MODE SELECT. The profile's `firmware` is
@@ -27,7 +27,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
// expressed in the CDB.
let write_len = firmware.len();
if write_len > u16::MAX as usize {
return Err(crate::error::Error::UnlockFailed);
return Err(crate::ld::error::Error::UnlockFailed);
}
let mode_select_cdb = [
SCSI_MODE_SELECT,
+1 -1
View File
@@ -1,6 +1,6 @@
//! Drive profile loading and matching.
use crate::error::{Error, Result};
use crate::ld::error::{Error, Result};
use serde::Deserialize;
/// Top-level profiles file — keyed by chipset + variant.