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:
+14
-15
@@ -1,15 +1,14 @@
|
||||
# freemkv-unlock — unlocker plugins for libfreemkv.
|
||||
#
|
||||
# libfreemkv ships only the `Unlocker` trait + registry and stays firmware-clean.
|
||||
# Each member here is one concrete unlocker, registered into libfreemkv by a
|
||||
# single `register_unlocker(...)` line in the consuming binary — so dropping an
|
||||
# unlocker is deleting that one line plus the dependency (delete-to-comply).
|
||||
#
|
||||
# Members:
|
||||
# ld — LibreDrive (MediaTek MT1959 firmware unlock)
|
||||
# aacs — AACS host-certificate bus-auth unlock (Blu-ray / UHD)
|
||||
# css — CSS (DVD-Video) bus-auth unlock
|
||||
# (future unlockers go here as additional members)
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["ld", "aacs", "css"]
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()."
|
||||
repository = "https://github.com/freemkv/freemkv-unlock"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22.1"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "freemkv-unlock-aacs"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "AACS host-certificate bus-auth unlocker plugin for libfreemkv (Blu-ray / UHD)"
|
||||
repository = "https://github.com/freemkv/freemkv-unlock"
|
||||
|
||||
[dependencies]
|
||||
libfreemkv = "1.2.0"
|
||||
tracing = "0.1"
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
//! freemkv-unlock-aacs — the AACS host-certificate unlocker plugin for libfreemkv.
|
||||
//!
|
||||
//! A Blu-ray / UHD disc with AACS bus encryption only serves clear content
|
||||
//! after a host-certificate AKE (the cert handshake) yields the bus key. This
|
||||
//! crate owns the [`libfreemkv::Unlocker`] impl that performs that unlock;
|
||||
//! libfreemkv owns the cert-handshake primitive
|
||||
//! ([`libfreemkv::aacs::handshake::run_cert_handshake`]) and the AACS content
|
||||
//! decryption.
|
||||
//!
|
||||
//! Plug it in once at process start:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! libfreemkv::register_unlocker(Box::new(freemkv_unlock_aacs::AacsUnlocker::new()));
|
||||
//! ```
|
||||
//!
|
||||
//! Remove the unlocker by deleting that one line and the dependency.
|
||||
|
||||
use libfreemkv::aacs::Vid;
|
||||
use libfreemkv::aacs::handshake::{collect_host_certs, run_cert_handshake};
|
||||
use libfreemkv::scsi::{DataDirection, SCSI_GET_CONFIGURATION, ScsiTransport};
|
||||
use libfreemkv::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc
|
||||
/// (`DiscKind::Aacs`), self-verifies the drive reports a BD profile, gathers
|
||||
/// host certs from the scan options, and runs the cert handshake to learn the
|
||||
/// Volume ID + AACS bus key (`read_data_key`).
|
||||
pub struct AacsUnlocker;
|
||||
|
||||
impl AacsUnlocker {
|
||||
pub fn new() -> Self {
|
||||
AacsUnlocker
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AacsUnlocker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for AacsUnlocker {
|
||||
fn name(&self) -> &str {
|
||||
"aacs-cert"
|
||||
}
|
||||
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool {
|
||||
ctx.kind == DiscKind::Aacs
|
||||
}
|
||||
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
// Self-guard against the hardware — do NOT trust the caller-declared
|
||||
// DiscKind alone. If the drive does not report a Blu-ray profile, refuse
|
||||
// (NotApplicable) WITHOUT issuing any handshake CDB.
|
||||
if !mounted_disc_is_bd(scsi) {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_unlocker_not_bd",
|
||||
"AacsUnlocker invoked on a non-Blu-ray profile; refusing (NotApplicable)"
|
||||
);
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
// The cert route needs a host-cert source. Without scan options there is
|
||||
// nothing to authenticate with — fall through (NotApplicable).
|
||||
let Some(opts) = ctx.opts else {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
};
|
||||
|
||||
// MKB generation (best-effort): lets a key source pick a
|
||||
// generation-appropriate host cert.
|
||||
let mkb = libfreemkv::aacs::read_mkb_from_drive(scsi)
|
||||
.ok()
|
||||
.and_then(|m| libfreemkv::aacs::mkb_version(&m));
|
||||
|
||||
let host_certs = collect_host_certs(opts, mkb);
|
||||
if host_certs.is_empty() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_no_host_cert",
|
||||
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
|
||||
);
|
||||
return Err(UnlockError::NoUsableHostCert { mkb });
|
||||
}
|
||||
|
||||
let h = run_cert_handshake(scsi, &host_certs)?;
|
||||
Ok(Unlocked {
|
||||
vid: Some(Vid(h.volume_id)),
|
||||
read_data_key: h.read_data_key,
|
||||
// Host-cert AKE path: bus removal depends on read_data_key, NOT a
|
||||
// firmware unlock.
|
||||
drive_unlocked: false,
|
||||
read_data_key_err: h.read_data_key_err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-level "is the mounted disc a Blu-ray?" probe (GET CONFIGURATION
|
||||
/// current-profile, BD family `0x0040..=0x0043`). Keeps the AACS self-guard
|
||||
/// inside the unlocker that needs it.
|
||||
fn mounted_disc_is_bd(scsi: &mut dyn ScsiTransport) -> bool {
|
||||
// RT=0: the 8-byte feature header carries the Current Profile in bytes 6-7.
|
||||
let cdb = [
|
||||
SCSI_GET_CONFIGURATION,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = [0u8; 8];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) if r.bytes_transferred >= 8 => {
|
||||
let profile = ((buf[6] as u16) << 8) | buf[7] as u16;
|
||||
(0x0040..=0x0043).contains(&profile)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use libfreemkv::scsi::ScsiResult;
|
||||
|
||||
fn fake_id() -> libfreemkv::DriveId {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[8..16].copy_from_slice(b"FAKEVNDR");
|
||||
libfreemkv::DriveId::from_inquiry(&inquiry, "")
|
||||
}
|
||||
|
||||
/// A transport reporting a fixed current profile; counts any non-GET
|
||||
/// CONFIGURATION CDB (handshake activity).
|
||||
struct ProfileTransport {
|
||||
profile: u16,
|
||||
other_cdbs: usize,
|
||||
}
|
||||
impl ScsiTransport for ProfileTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> libfreemkv::error::Result<ScsiResult> {
|
||||
if cdb[0] == SCSI_GET_CONFIGURATION {
|
||||
if data.len() >= 8 {
|
||||
data[6] = (self.profile >> 8) as u8;
|
||||
data[7] = self.profile as u8;
|
||||
}
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 8,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.other_cdbs += 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// AacsUnlocker matches only `DiscKind::Aacs` and carries the stable name.
|
||||
#[test]
|
||||
fn matches_only_aacs_kind() {
|
||||
let id = fake_id();
|
||||
let u = AacsUnlocker::new();
|
||||
assert_eq!(u.name(), "aacs-cert");
|
||||
assert!(u.matches(&UnlockCtx::new(&id, DiscKind::Aacs)));
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
assert!(!u.matches(&UnlockCtx::new(&id, k)), "must not match {k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-guard: a DVD-profile drive yields NotApplicable and NO handshake CDB
|
||||
/// is issued (no AACS auth fired at a DVD).
|
||||
#[test]
|
||||
fn self_guards_against_non_bd() {
|
||||
let id = fake_id();
|
||||
let mut t = ProfileTransport {
|
||||
profile: 0x0010, // DVD-ROM
|
||||
other_cdbs: 0,
|
||||
};
|
||||
let r = AacsUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
|
||||
assert_eq!(t.other_cdbs, 0, "no handshake CDB at a non-BD drive");
|
||||
}
|
||||
|
||||
/// A BD-profile drive with no scan options (no host-cert source) → it passes
|
||||
/// the hardware self-guard but cannot authenticate → NotApplicable.
|
||||
#[test]
|
||||
fn bd_without_opts_is_not_applicable() {
|
||||
let id = fake_id();
|
||||
let mut t = ProfileTransport {
|
||||
profile: 0x0040, // BD-ROM
|
||||
other_cdbs: 0,
|
||||
};
|
||||
// UnlockCtx::new carries opts = None.
|
||||
let r = AacsUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "freemkv-unlock-css"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "CSS (DVD-Video) bus-auth unlocker plugin for libfreemkv"
|
||||
repository = "https://github.com/freemkv/freemkv-unlock"
|
||||
|
||||
[dependencies]
|
||||
libfreemkv = "1.2.0"
|
||||
tracing = "0.1"
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
//! freemkv-unlock-css — the CSS (DVD-Video) unlocker plugin for libfreemkv.
|
||||
//!
|
||||
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a CSS
|
||||
//! bus-authentication handshake has set its Authentication Success Flag
|
||||
//! (ASF=1). This crate owns the [`libfreemkv::Unlocker`] impl that performs
|
||||
//! that unlock; libfreemkv owns the bus-auth primitive
|
||||
//! ([`libfreemkv::css::auth::unlock_css_reads`]) and the keyless descramble.
|
||||
//!
|
||||
//! Plug it in once at process start:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! libfreemkv::register_unlocker(Box::new(freemkv_unlock_css::CssUnlocker::new()));
|
||||
//! ```
|
||||
//!
|
||||
//! Remove the unlocker by deleting that one line and the dependency.
|
||||
|
||||
use libfreemkv::scsi::{DataDirection, SCSI_GET_CONFIGURATION, ScsiTransport};
|
||||
use libfreemkv::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// The CSS unlocker. Matches a DVD (`DiscKind::Css`), self-verifies the drive
|
||||
/// reports a DVD profile, then runs the CSS bus-auth handshake to unlock
|
||||
/// scrambled-sector reads. It learns neither a Volume ID nor a bus key — the
|
||||
/// descramble key is recovered keylessly (the Stevenson attack in libfreemkv).
|
||||
pub struct CssUnlocker;
|
||||
|
||||
impl CssUnlocker {
|
||||
pub fn new() -> Self {
|
||||
CssUnlocker
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CssUnlocker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for CssUnlocker {
|
||||
fn name(&self) -> &str {
|
||||
"css"
|
||||
}
|
||||
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool {
|
||||
ctx.kind == DiscKind::Css
|
||||
}
|
||||
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
// Self-guard against the hardware — do NOT trust the caller-declared
|
||||
// DiscKind alone. If the drive does not report a DVD profile, refuse
|
||||
// (NotApplicable) WITHOUT issuing any CSS CDB, so a mis-routed
|
||||
// Blu-ray/UHD is never sent CSS bus-auth.
|
||||
if !mounted_disc_is_dvd(scsi) {
|
||||
tracing::debug!(
|
||||
target: "freemkv::css",
|
||||
phase = "css_unlocker_not_dvd",
|
||||
"CssUnlocker invoked on a non-DVD profile; refusing (NotApplicable)"
|
||||
);
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
// The bus-auth handshake (libfreemkv primitive) sets ASF=1; the lba is
|
||||
// not consumed by the unlock. CSS yields no VID and no bus key.
|
||||
libfreemkv::css::auth::unlock_css_reads(scsi, 0).map_err(UnlockError::from)?;
|
||||
Ok(Unlocked::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-level "is the mounted disc a DVD?" probe (GET CONFIGURATION
|
||||
/// current-profile, DVD family `0x0010..=0x001F`). Keeps the CSS self-guard
|
||||
/// inside the unlocker that needs it.
|
||||
fn mounted_disc_is_dvd(scsi: &mut dyn ScsiTransport) -> bool {
|
||||
// RT=0: the 8-byte feature header carries the Current Profile in bytes 6-7.
|
||||
let cdb = [
|
||||
SCSI_GET_CONFIGURATION,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = [0u8; 8];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) if r.bytes_transferred >= 8 => {
|
||||
let profile = ((buf[6] as u16) << 8) | buf[7] as u16;
|
||||
(0x0010..=0x001F).contains(&profile)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use libfreemkv::scsi::ScsiResult;
|
||||
|
||||
fn fake_id() -> libfreemkv::DriveId {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[8..16].copy_from_slice(b"FAKEVNDR");
|
||||
libfreemkv::DriveId::from_inquiry(&inquiry, "")
|
||||
}
|
||||
|
||||
/// CssUnlocker matches only `DiscKind::Css` and carries the stable name.
|
||||
#[test]
|
||||
fn matches_only_css_kind() {
|
||||
let id = fake_id();
|
||||
let u = CssUnlocker::new();
|
||||
assert_eq!(u.name(), "css");
|
||||
assert!(u.matches(&UnlockCtx::new(&id, DiscKind::Css)));
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Aacs] {
|
||||
assert!(!u.matches(&UnlockCtx::new(&id, k)), "must not match {k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-guard: a drive reporting a Blu-ray profile yields NotApplicable and
|
||||
/// no CSS CDB is issued.
|
||||
#[test]
|
||||
fn self_guards_against_non_dvd() {
|
||||
struct BdTransport {
|
||||
non_config_cdbs: usize,
|
||||
}
|
||||
impl ScsiTransport for BdTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> libfreemkv::error::Result<ScsiResult> {
|
||||
if cdb[0] == SCSI_GET_CONFIGURATION {
|
||||
if data.len() >= 8 {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x40; // BD-ROM current profile
|
||||
}
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 8,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.non_config_cdbs += 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let id = fake_id();
|
||||
let mut t = BdTransport { non_config_cdbs: 0 };
|
||||
let r = CssUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
|
||||
assert_eq!(t.non_config_cdbs, 0, "no CSS CDB at a non-DVD drive");
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "freemkv-unlock-ld"
|
||||
version = "1.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "LibreDrive unlocker plugin for libfreemkv (firmware unlock for MediaTek MT1959 drives)"
|
||||
repository = "https://github.com/freemkv/freemkv-unlock"
|
||||
|
||||
[dependencies]
|
||||
libfreemkv = "1.1.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22.1"
|
||||
tracing = "0.1"
|
||||
@@ -1,54 +0,0 @@
|
||||
# freemkv-unlock-ld
|
||||
|
||||
The **LibreDrive** unlocker plugin for [libfreemkv](https://github.com/freemkv/libfreemkv).
|
||||
|
||||
libfreemkv ships only the `Unlocker` trait + registry and stays firmware-clean.
|
||||
This crate owns *how* MediaTek MT1959 drives are firmware-unlocked: the bundled
|
||||
drive-profile database (`profiles.json`), the firmware blobs, the
|
||||
WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A / variant-B
|
||||
handshake logic.
|
||||
|
||||
## Usage
|
||||
|
||||
Register the unlocker once at process start, before any rip:
|
||||
|
||||
```rust
|
||||
libfreemkv::register_unlocker(Box::new(freemkv_unlock_ld::LibreDrive::new()));
|
||||
```
|
||||
|
||||
That single line is the whole plug. Any drive whose identity matches a bundled
|
||||
profile is firmware-unlocked at drive-prep; everything else falls through to
|
||||
libfreemkv's host-certificate AACS handshake.
|
||||
|
||||
## The `Unlocker` contract
|
||||
|
||||
This crate is the LibreDrive unlocker — an implementation of libfreemkv's
|
||||
`Unlocker` trait. The trait is a 3-method capability contract:
|
||||
|
||||
- `unlock_drive` — put the drive into extended-access mode. The one required
|
||||
capability.
|
||||
- `read_volume_id` — read the disc Volume ID directly, bypassing the AACS cert
|
||||
handshake. `None` → libfreemkv falls back to the cert-based read. No-op
|
||||
default.
|
||||
- `set_max_read_speed` — raise the drive to its maximum read speed. No-op
|
||||
default.
|
||||
|
||||
libfreemkv's AACS layer is the always-present baseline; it uses an unlocker's
|
||||
capabilities when one matches, and does the full cert handshake when none do.
|
||||
Remove this crate and libfreemkv still compiles and rips — every capability
|
||||
falls back to the OEM/baseline path.
|
||||
|
||||
## Scope: RAM microcode only (`#2`), never the bootloader flash (`#1`)
|
||||
|
||||
freemkv uploads the RAM microcode to an **already-bootloader-flashed** drive.
|
||||
The permanent bootloader flash (`#1`) is the drive owner's one-time manual
|
||||
step; it is **never** automated by freemkv. This crate only performs the
|
||||
non-persistent `#2` step — the microcode lives in RAM and is gone on power
|
||||
cycle.
|
||||
|
||||
## Credits
|
||||
|
||||
LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This crate
|
||||
builds on their work — our thanks and full credit to them for the LibreDrive
|
||||
capabilities.
|
||||
|
||||
-417
@@ -1,417 +0,0 @@
|
||||
//! freemkv-unlock-ld — the LibreDrive unlocker plugin for libfreemkv.
|
||||
//!
|
||||
//! This crate owns *how* MediaTek MT1959 drives are firmware-unlocked:
|
||||
//! the bundled drive profiles, the firmware blobs, the WRITE_BUFFER /
|
||||
//! MODE SELECT upload, the unlock CDBs, and the variant-A / variant-B
|
||||
//! handshake logic. libfreemkv knows none of it — it only exposes the
|
||||
//! [`libfreemkv::Unlocker`] trait and a registry.
|
||||
//!
|
||||
//! Plug it in once at process start:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! libfreemkv::register_unlocker(Box::new(freemkv_unlock_ld::LibreDrive::new()));
|
||||
//! ```
|
||||
//!
|
||||
//! With that one line, any drive whose identity matches a bundled profile
|
||||
//! is firmware-unlocked at drive-prep; everything else falls through to
|
||||
//! libfreemkv's host-certificate AACS handshake.
|
||||
|
||||
// libfreemkv module aliases so the moved firmware code keeps its original
|
||||
// `crate::error::*` / `crate::scsi::*` paths.
|
||||
pub(crate) use libfreemkv::{error, scsi};
|
||||
|
||||
pub mod cdb;
|
||||
pub mod profile;
|
||||
|
||||
mod platform;
|
||||
|
||||
use error::Result;
|
||||
use libfreemkv::aacs::Vid;
|
||||
use libfreemkv::{DiscKind, DriveId, ScsiTransport, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
use scsi::DataDirection;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// This recovers the per-drive READ_BUFFER VID path that lived in
|
||||
/// libfreemkv before the unlocker refactor (it was `read_vid_oem` in
|
||||
/// `libfreemkv/src/disc/encrypt.rs`), now living inside the unlocker where
|
||||
/// the per-drive `read_vid_cdb` template belongs. Folded into [`Self::unlock`]:
|
||||
/// an unlocked drive must hand back its Volume ID in one step.
|
||||
///
|
||||
/// Returns:
|
||||
/// * `Ok(Vid)` — the profile carries an OEM VID CDB and the drive served a
|
||||
/// well-formed VID. No host certificate or HRL is involved.
|
||||
/// * `Err(UnlockError::VidUnavailable)` — no matching profile, no OEM VID
|
||||
/// CDB, or a short / bad-signature response (auth succeeded but the VID
|
||||
/// could not be read → cert fallback).
|
||||
/// * `Err(UnlockError::Scsi(code))` — the OEM CDB itself failed at the
|
||||
/// transport (via `?` / `From<Error>`).
|
||||
///
|
||||
/// 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_oem_vid(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
id: &DriveId,
|
||||
) -> std::result::Result<Vid, UnlockError> {
|
||||
const RESPONSE_LEN: usize = 36;
|
||||
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||
|
||||
// No matching profile, or a profile without an OEM VID CDB → no OEM VID
|
||||
// path; the caller falls back to the cert handshake.
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
return Err(UnlockError::VidUnavailable);
|
||||
};
|
||||
let Some(cdb) = m.profile.read_vid_cdb else {
|
||||
return Err(UnlockError::VidUnavailable);
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; RESPONSE_LEN];
|
||||
// A transport failure here is a raw SCSI error → Scsi(code) via `?`.
|
||||
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 Err(UnlockError::VidUnavailable);
|
||||
}
|
||||
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(UnlockError::VidUnavailable);
|
||||
}
|
||||
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(Vid(vid))
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
fn name(&self) -> &str {
|
||||
"LibreDrive"
|
||||
}
|
||||
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool {
|
||||
// Firmware unlock is a drive-prep concern: it runs before the disc kind
|
||||
// is probed (ctx.kind == Unknown) and keys off the drive identity. It
|
||||
// must NOT fire during the later content-keyed dispatch (kind Aacs/Css),
|
||||
// or a DVD/Blu-ray in a profiled drive would be re-firmware-unlocked.
|
||||
ctx.kind == DiscKind::Unknown && profile::find_bundled(ctx.drive_id).is_some()
|
||||
}
|
||||
|
||||
/// Firmware-unlock the drive AND return the disc's OEM Volume ID in one step
|
||||
/// (the new trait folds the old `unlock_drive()` + `read_volume_id()`).
|
||||
///
|
||||
/// Maps to [`UnlockError`]:
|
||||
/// * no matching profile / Renesas (no firmware unlock implemented) →
|
||||
/// [`UnlockError::FirmwareNotUnlockable`];
|
||||
/// * a SCSI failure during the firmware-unlock handshake →
|
||||
/// [`UnlockError::Scsi`] (via `?` / `From<Error>`);
|
||||
/// * unlocked but no readable OEM VID → [`UnlockError::VidUnavailable`].
|
||||
///
|
||||
/// Any error makes libfreemkv fall through to the in-tree cert handshake.
|
||||
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 {
|
||||
// matches() returned true but the profile vanished — this unlocker
|
||||
// cannot put the firmware into extended mode.
|
||||
return Err(UnlockError::FirmwareNotUnlockable);
|
||||
};
|
||||
if matches!(m.platform, profile::Platform::Renesas) {
|
||||
// Renesas firmware unlock is not implemented; cannot unlock the
|
||||
// firmware, so the host-cert handshake must carry the disc.
|
||||
return Err(UnlockError::FirmwareNotUnlockable);
|
||||
}
|
||||
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 SCSI failure becomes UnlockError::Scsi via `?`.
|
||||
mt.init(scsi)?;
|
||||
// On success, prime the per-region speed table so the drive manages
|
||||
// zone speeds internally (best-effort — calibration failure must not
|
||||
// fail an otherwise-good unlock).
|
||||
let _ = mt.probe_disc(scsi);
|
||||
// An unlocked drive must hand back its Volume ID in the same step. The
|
||||
// firmware route serves CLEAR content, so it carries no bus key.
|
||||
let vid = self.read_oem_vid(scsi, id)?;
|
||||
Ok(Unlocked {
|
||||
vid: Some(vid),
|
||||
read_data_key: None,
|
||||
// Firmware unlock puts the drive in clear-content mode: AACS bus
|
||||
// encryption is removed at the drive, so the gate needs no bus key.
|
||||
drive_unlocked: true,
|
||||
read_data_key_err: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Raise the drive to its maximum read speed.
|
||||
///
|
||||
/// Issues the matched profile's `set_speed_max_cdb` (the
|
||||
/// `0xBB SET CD SPEED`-to-max command) over the raw transport. A profile
|
||||
/// without a `set_speed_max_cdb` (or no matching profile at all) is a
|
||||
/// no-op — the drive stays at its current speed.
|
||||
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 libfreemkv::DiscKind;
|
||||
use scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Drive-prep unlock context for a fake drive id (disc kind 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. Exercises the OEM
|
||||
/// VID response-parse branches without a live drive.
|
||||
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_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 {
|
||||
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)
|
||||
}
|
||||
|
||||
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20])
|
||||
/// parses to that VID via the OEM-VID helper `unlock` folds in.
|
||||
#[test]
|
||||
fn read_oem_vid_parses_well_formed_response() {
|
||||
// Confirm the bundled profile actually carries a read_vid_cdb; otherwise
|
||||
// read_oem_vid would short-circuit and this test wouldn't exercise the
|
||||
// parse path.
|
||||
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, Vid(vid), "VID parsed from [4..20]");
|
||||
}
|
||||
|
||||
/// A short response (fewer than 36 transferred bytes) → VidUnavailable
|
||||
/// (unlocked but no readable VID → cert fallback).
|
||||
#[test]
|
||||
fn read_oem_vid_short_response_is_vid_unavailable() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect_err("short response must error");
|
||||
assert_eq!(err, UnlockError::VidUnavailable);
|
||||
}
|
||||
|
||||
/// A response whose 3-byte signature isn't `00 22 00` → VidUnavailable.
|
||||
#[test]
|
||||
fn read_oem_vid_bad_header_is_vid_unavailable() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]); // wrong signature
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect_err("bad header must error");
|
||||
assert_eq!(err, UnlockError::VidUnavailable);
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → `read_oem_vid` is VidUnavailable
|
||||
/// (cert fallback), and the transport is never issued a CDB.
|
||||
#[test]
|
||||
fn read_oem_vid_no_profile_is_vid_unavailable() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||
.expect_err("no profile → VidUnavailable");
|
||||
assert_eq!(err, UnlockError::VidUnavailable);
|
||||
}
|
||||
|
||||
/// `unlock` on a drive with no matching profile → FirmwareNotUnlockable
|
||||
/// (this unlocker cannot put the firmware into extended mode), short-
|
||||
/// circuiting before any firmware handshake is attempted.
|
||||
#[test]
|
||||
fn unlock_no_profile_is_firmware_not_unlockable() {
|
||||
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 → FirmwareNotUnlockable");
|
||||
assert_eq!(err, UnlockError::FirmwareNotUnlockable);
|
||||
}
|
||||
|
||||
/// A transport that records the CDB issued (and how many CDBs it saw),
|
||||
/// so the speed test can assert the profile's `set_speed_max_cdb` is the
|
||||
/// one sent — or that nothing was sent at all (no-op).
|
||||
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 is issued.
|
||||
#[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,49 @@
|
||||
//! Internal error type shared by the unlocker modules (firmware / handshake /
|
||||
//! bus-auth). Distinct from [`crate::UnlockError`], which is the unlock OUTCOME
|
||||
//! the consumer sees; this is the low-level error the SCSI/crypto code uses.
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ mod variant_b;
|
||||
|
||||
use super::PlatformDriver;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::ld::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
|
||||
// ── Variant constants ──────────────────────────────────────────────────
|
||||
@@ -397,7 +397,7 @@ impl PlatformDriver for Mt1959 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::{DriveProfile, Identity};
|
||||
use crate::ld::profile::{DriveProfile, Identity};
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Minimal mock transport that returns a scripted response to the
|
||||
@@ -233,7 +233,7 @@ where
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
||||
const BUNDLED_PROFILES: &str = include_str!("profiles.json");
|
||||
|
||||
/// Parse the bundled profiles fresh into an owned [`ProfilesFile`].
|
||||
///
|
||||
@@ -263,7 +263,7 @@ pub fn bundled() -> Option<&'static ProfilesFile> {
|
||||
/// 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: &libfreemkv::DriveId) -> Option<ProfileMatch> {
|
||||
pub fn find_bundled(drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
find_by_drive_id(bundled()?, drive_id)
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
/// whitespace-trimmed. Returns the first section that yields a match.
|
||||
pub fn find_by_drive_id(
|
||||
profiles: &ProfilesFile,
|
||||
drive_id: &libfreemkv::DriveId,
|
||||
drive_id: &crate::DriveId,
|
||||
) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let r = drive_id.product_revision.trim();
|
||||
@@ -324,15 +324,15 @@ pub fn find_by_drive_id(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use libfreemkv::DriveId;
|
||||
use crate::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)
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
//! freemkv-unlock — the unlock layer for the freemkv toolchain.
|
||||
//!
|
||||
//! An **unlocker removes a drive-level bus-encryption barrier** so the drive
|
||||
//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption is
|
||||
//! a separate layer — the consumer's (libfreemkv's) job.
|
||||
//!
|
||||
//! This crate defines the [`Unlocker`] contract + the SCSI transport contract,
|
||||
//! and holds the self-contained unlocker modules (firmware / AACS cert / CSS).
|
||||
//! libfreemkv depends on this crate and dispatches via [`all_unlockers`]; it
|
||||
//! never names an individual unlocker. To remove an unlocker, delete its module
|
||||
//! dir and its one line in [`all_unlockers`] — nothing else changes.
|
||||
|
||||
pub mod error;
|
||||
pub mod scsi;
|
||||
|
||||
mod ld;
|
||||
// mod aacs; // stage 2 — AACS host-certificate handshake
|
||||
// mod css; // stage 3 — CSS bus-auth
|
||||
|
||||
use scsi::ScsiTransport;
|
||||
|
||||
/// Drive identity an unlocker matches against — four raw INQUIRY-derived fields,
|
||||
/// filled by the consumer (this crate parses no INQUIRY itself).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DriveId {
|
||||
pub vendor_id: String,
|
||||
pub product_revision: String,
|
||||
pub vendor_specific: String,
|
||||
pub firmware_date: String,
|
||||
}
|
||||
|
||||
/// Bus-encryption class of the mounted disc, probed by the consumer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiscKind {
|
||||
Unknown,
|
||||
Unencrypted,
|
||||
Aacs,
|
||||
Css,
|
||||
}
|
||||
|
||||
/// A host certificate for the AACS cert handshake (raw; the consumer collects
|
||||
/// these from its key sources and passes them in).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostCert {
|
||||
pub private_key: [u8; 20],
|
||||
pub certificate: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Context handed to an unlocker: drive identity, disc kind, and (for the cert
|
||||
/// route) the host certs the consumer collected.
|
||||
pub struct UnlockCtx<'a> {
|
||||
pub drive_id: &'a DriveId,
|
||||
pub kind: DiscKind,
|
||||
pub host_certs: &'a [HostCert],
|
||||
}
|
||||
|
||||
impl<'a> UnlockCtx<'a> {
|
||||
pub fn new(drive_id: &'a DriveId, kind: DiscKind, host_certs: &'a [HostCert]) -> Self {
|
||||
Self {
|
||||
drive_id,
|
||||
kind,
|
||||
host_certs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What removing bus encryption yielded. `drive_unlocked` means the drive now
|
||||
/// serves clear content (firmware route) — equivalent, for the gate, to a cert
|
||||
/// `bus_key`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Unlocked {
|
||||
pub vid: Option<[u8; 16]>,
|
||||
pub bus_key: Option<[u8; 16]>,
|
||||
pub drive_unlocked: bool,
|
||||
}
|
||||
|
||||
/// Why an unlock produced no usable result. Only `Transport` is a hard error
|
||||
/// (bus dead → consumer aborts); the rest mean "fall through to the next
|
||||
/// unlocker".
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UnlockError {
|
||||
/// This unlocker does not apply (wrong disc kind / no profile / no certs).
|
||||
NotApplicable,
|
||||
/// The AACS cert route had no usable host certificate.
|
||||
NoUsableHostCert,
|
||||
/// The drive rejected the auth handshake.
|
||||
HandshakeRejected,
|
||||
/// Auth succeeded but no Volume ID could be read.
|
||||
VidUnavailable,
|
||||
/// A genuine SCSI transport fault (bus dead). The consumer aborts.
|
||||
Transport,
|
||||
}
|
||||
|
||||
impl From<error::Error> for UnlockError {
|
||||
fn from(e: error::Error) -> Self {
|
||||
if e.is_transport_failure() {
|
||||
UnlockError::Transport
|
||||
} else {
|
||||
// A logical firmware/handshake failure is not a dead bus — this
|
||||
// unlocker simply didn't apply; the consumer falls through.
|
||||
UnlockError::NotApplicable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An unlocker removes a drive-level bus-encryption barrier. Implementors are
|
||||
/// the self-contained modules in this crate; the consumer only ever sees the
|
||||
/// trait, via [`all_unlockers`].
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// True if this unlocker applies to the given context (drive id + disc kind).
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool;
|
||||
/// Remove the bus-encryption barrier, returning what was learned.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError>;
|
||||
/// Best-effort: raise the drive to its maximum read speed. Default no-op.
|
||||
fn set_max_read_speed(
|
||||
&self,
|
||||
_scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Every unlocker, in dispatch order (firmware → cert → css). This is the ONLY
|
||||
/// place an unlocker is named. Remove one = delete its line here + its module
|
||||
/// dir; the consumer never changes.
|
||||
pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
|
||||
vec![
|
||||
Box::new(ld::LibreDrive::new()),
|
||||
// Box::new(aacs::AacsCert::new()), // stage 2
|
||||
// Box::new(css::Css::new()), // stage 3
|
||||
]
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
//! The SCSI transport contract every unlocker issues CDBs through. The consumer
|
||||
//! (libfreemkv) implements [`ScsiTransport`] over its own SCSI; the unlockers
|
||||
//! never see a concrete transport. Common MMC/SPC opcodes live here too.
|
||||
|
||||
/// Direction of a SCSI data transfer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DataDirection {
|
||||
None,
|
||||
FromDevice,
|
||||
ToDevice,
|
||||
}
|
||||
|
||||
/// Result of a SCSI command: status byte, bytes transferred, raw sense.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScsiResult {
|
||||
pub status: u8,
|
||||
pub bytes_transferred: usize,
|
||||
pub sense: [u8; 32],
|
||||
}
|
||||
|
||||
/// The one capability an unlocker needs from the host: run a raw CDB. `Ok` even
|
||||
/// on a SCSI sense (inspect `status`); `Err` only on a transport-layer fault.
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult>;
|
||||
}
|
||||
|
||||
/// SCSI status byte for a transport-layer failure (bridge crash / disconnect).
|
||||
pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
|
||||
|
||||
// Common opcodes used by the unlocker modules.
|
||||
pub(crate) const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
pub(crate) const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
pub(crate) const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
pub(crate) const SCSI_MODE_SELECT: u8 = 0x55; // MODE SELECT (10)
|
||||
pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB;
|
||||
|
||||
/// Build a SET CD SPEED (0xBB) CDB requesting `read_speed` (KB/s; 0xFFFF = max).
|
||||
pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
[
|
||||
SCSI_SET_CD_SPEED,
|
||||
0x00,
|
||||
(read_speed >> 8) as u8,
|
||||
read_speed as u8,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user