Compare commits

...
24 Commits
Author SHA1 Message Date
Matthew Jackson 67919326f1 v1.2.2: bump version (unified release) 2026-07-03 20:50:05 -07:00
Matthew Jackson d39b9b8a82 v1.2.1: bump version 2026-07-01 17:30:25 -07:00
Matthew Jackson 9dbf3f6381 unlock: drop applies_to — the matrix reports did-work, not matched 2026-07-01 10:44:39 -07:00
Matthew Jackson d3255cafc4 unlock: add Unlocker::name() + report-only applies_to() for the matrix
Each unlocker now carries its own stable name() (the single source; apps
never hardcode it) and a report-only applies_to() — phase-independent
'would this apply to this drive+disc', distinct from the dispatch-gated
matches(). LibreDrive overrides applies_to to report on drive identity
regardless of disc kind (its matches() is gated to the Unknown-kind init
phase). Enables a registry-driven unlocker matrix.
2026-07-01 10:00:59 -07:00
Matthew Jackson 558bcecf8a Revert "unlock: drive-features capability (stock riplock lift for any disc)"
This reverts commit 07e030e4ea.
2026-07-01 09:34:58 -07:00
Matthew Jackson 07e030e4ea unlock: drive-features capability (stock riplock lift for any disc)
Reframe: an unlocker provides drive-features (speed/riplock — a property
of the DRIVE, any disc) AND bus removal (AACS decrypt+VID / CSS), kept
separate so a disc can take one without the other. A CSS DVD wants a
matched drive's speed but must NOT take its firmware bus-unlock (breaks
stock CSS).

Add Unlocker::apply_drive_features (default no-op, self-gating). LibreDrive
implements it with STOCK MMC commands only — SET STREAMING (0xB6), the
modern riplock lift many slot-loading BD combos honor when they ignore the
legacy SET CD SPEED (0xBB), then SET CD SPEED as fallback — so it is safe
on a non-unlocked drive. New build_set_streaming() + tests.
2026-07-01 08:56:10 -07:00
Matthew Jackson f47200518b README: rewrite for the single-crate, base-of-graph architecture 2026-06-29 21:15:04 -07:00
Matthew Jackson b91ccfa4f8 Cargo: publish = false (firmware crate — git-tag-only, never crates.io) 2026-06-29 21:04:05 -07:00
Matthew Jackson 817c7285f4 ld: public profile catalog API + emulation feature; clippy-clean
Expose the LibreDrive profile catalog as a curated public API on `ld`:
`ld::profiles()` returns the `Profiles` object (with `.get(drive_id)`) and
`ld::profile(filter)` finds one — the catalog of supported drives, queried
without unlocking. The unlock mechanism (firmware blobs, upload sequence, CDB
wire format) stays private; `LibreDrive` is pub(crate), reached only via
`all_unlockers()`.

The unlock-handshake wire format the bdemu test-emulator needs to impersonate a
drive (`UNLOCK_MARKER`, `is_unlock_read_buffer`) is behind a non-default
`emulation` feature; the `cdb` module compiles only under it. Rename the
`ScsiError` error variant to `Scsi` (matches css), gate the test-only
`load_bundled` and `SCSI_STATUS_CHECK_CONDITION`. Clippy-clean in both feature
configs; 86/89 tests pass.
2026-06-29 20:44:52 -07:00
Matthew Jackson 19f2b959a2 ld: drive-prep guard (fire only at kind==Unknown) + firmware_unlocker_name
Restore ld's drive-prep match guard so the firmware unlocker fires exactly once
(at drive-prep, kind==Unknown) and never re-fires during the content-keyed
Aacs/Css dispatch — the consumer (libfreemkv) dispatches at three points
(init/cert/css) with the appropriate kind. Add firmware_unlocker_name(drive_id)
for "is this drive supported?" drive-info display without unlocking. 89 tests.
2026-06-29 19:54:36 -07:00
Matthew Jackson 26512f4895 css: relocate the CSS bus-auth into freemkv-unlock; all 3 unlockers wired
Stage 3: the CSS bus-authentication (the challenge-response that sets the
drive's ASF=1 to unlock scrambled-sector reads, plus crypt_key + the CSS
tables) moves out of libfreemkv into the self-contained src/css module, with
its own error type. CssUnlocker impls crate::Unlocker — matches DiscKind::Css,
self-guards against a non-DVD profile, runs the bus-auth, and returns an empty
Unlocked (CSS yields no VID/bus key; the keyless Stevenson descramble stays in
libfreemkv). The keyless descramble (lfsr/stevenson) is NOT moved — that's
content decryption, the consumer's job.

all_unlockers() now returns [LibreDrive, AacsCert, CssUnlocker] — the entire
unlock layer is built. libfreemkv rewires onto it in stage 4. 89 tests pass.
2026-06-29 19:36:28 -07:00
Matthew Jackson 6c75592387 aacs: relocate the AACS cert handshake into freemkv-unlock
Stage 2: the AACS host-certificate handshake (the AKE + bus-key derivation +
P-160/P-256 EC crypto, ~2050 lines) moves out of libfreemkv into the
self-contained src/aacs module, with its own error type (the Aacs* failure
points + structured ScsiError) and an aes_ecb_decrypt helper. AacsCert impls
crate::Unlocker — matches DiscKind::Aacs, runs run_cert_handshake against the
host certs the consumer passes via UnlockCtx, and returns Unlocked { vid,
bus_key }. collect_host_certs stays in libfreemkv (it reads keysources). The
SCSI contract gains ScsiSense + the AACS/REPORT-KEY opcodes. libfreemkv is
untouched (still green); it rewires onto this in stage 4.

72 tests pass (the handshake brought its full EC-crypto test suite).
2026-06-29 19:33:07 -07:00
Matthew Jackson a244589eca 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.
2026-06-29 19:19:19 -07:00
Matthew Jackson 7ec8f0316e 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.
2026-06-29 19:11:21 -07:00
Matthew Jackson 06a259f60e aacs: new freemkv-unlock-aacs plugin crate
The AACS host-certificate bus-auth unlocker as a first-class external plugin,
peer to freemkv-unlock-ld and freemkv-unlock-css. AacsUnlocker impls
libfreemkv::Unlocker — matches DiscKind::Aacs, self-guards against the hardware
(refuses NotApplicable without issuing a handshake CDB if the drive reports a
non-Blu-ray profile), gathers host certs from the scan options, and runs the
libfreemkv cert-handshake primitive to learn the Volume ID + bus key. libfreemkv
keeps the handshake + AACS content decryption; this crate owns the unlocker.
Additive: a consuming binary opts in with one register_unlocker(...) line.
2026-06-29 17:33:27 -07:00
Matthew Jackson ed54b2bb01 css: new freemkv-unlock-css plugin crate
The CSS (DVD-Video) bus-auth unlocker as a first-class external plugin, peer
to freemkv-unlock-ld. CssUnlocker impls libfreemkv::Unlocker — matches
DiscKind::Css, self-guards against the hardware (refuses NotApplicable without
issuing a CSS CDB if the drive reports a non-DVD profile), then runs the
libfreemkv bus-auth primitive (unlock_css_reads). libfreemkv keeps the bus-auth
+ keyless descramble; this crate owns the unlocker impl. Additive: a consuming
binary opts in with one register_unlocker(CssUnlocker::new()) line; the in-tree
built-in stays until clients are rewired.
2026-06-29 17:22:50 -07:00
Matthew Jackson ca5c8275fe unlock: firmware Unlocked sets drive_unlocked=true
The firmware route puts the drive into clear-content mode, so it reports
drive_unlocked=true (and no bus key) per the extended Unlocked contract.
2026-06-29 16:59:04 -07:00
Matthew Jackson 7d3ce8ef86 unlock: firmware unlocker matches only the drive-prep phase
LibreDrive::matches now requires ctx.kind == DiscKind::Unknown (the drive-prep
dispatch) in addition to a bundled-profile hit. This keeps the firmware unlock
from firing during the later content-keyed (Aacs/Css) dispatch on a profiled
drive — the registry is walked at one spot per phase and matches() is the only
filter.
2026-06-29 16:49:52 -07:00
Matthew Jackson e377a0067a unlock: adopt UnlockCtx in the Unlocker impl
libfreemkv's Unlocker trait now hands matches()/unlock()/set_max_read_speed()
an UnlockCtx instead of a bare DriveId. The firmware unlocker keys off
ctx.drive_id (disc kind is irrelevant to it).
2026-06-29 16:27:30 -07:00
Matthew Jackson 2a9a15602a unlock: return Unlocked per the generalized libfreemkv trait
libfreemkv's Unlocker::unlock now returns Unlocked { vid, read_data_key }
rather than a bare Vid. The firmware route serves clear content, so it
reports { vid: Some(oem_vid), read_data_key: None }.
2026-06-29 15:09:22 -07:00
Matthew Jackson 3fdbf01a16 v1.1.0: bump version (unified release) 2026-06-28 18:20:14 -07:00
Matthew Jackson 493aceaf71 Unlocker reshape: unlock() -> Result<Vid, UnlockError>
LibreDrive impl migrated to the single unlock() returning the OEM VID;
firmware-unsupported -> FirmwareNotUnlockable, no-VID -> VidUnavailable.
2026-06-26 12:19:24 -07:00
Matthew Jackson 1eea38e9df v1.0.0-rc.5.3: bump version (unified release) 2026-06-24 21:55:19 -07:00
Matthew Jackson 967b011c96 v1.0.0-rc.5.2: bump version (unified release) 2026-06-24 17:19:59 -07:00
21 changed files with 4020 additions and 498 deletions
+28 -13
View File
@@ -1,13 +1,28 @@
# 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)
# (future unlockers go here as additional members)
[workspace]
resolver = "2"
members = ["ld"]
[package]
name = "freemkv-unlock"
version = "1.2.2"
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"
# NEVER crates.io: this crate carries drive firmware. Consumed only by git tag.
publish = false
[features]
# Off by default. Exposes ld's unlock-handshake wire format (`UNLOCK_MARKER`,
# `is_unlock_read_buffer`) for the bdemu test-emulator, which impersonates an
# ld-unlockable drive. Real clients never enable it and keep the generic API.
emulation = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22.1"
tracing = "0.1"
num-bigint = "0.4"
num-traits = "0.2"
sha1 = "0.10"
sha2 = "0.10"
aes = "0.8"
rand = "0.8"
+18 -12
View File
@@ -1,21 +1,27 @@
# freemkv-unlock
Unlocker plugins for [libfreemkv](https://github.com/freemkv/libfreemkv).
The unlock layer for the freemkv toolchain.
libfreemkv ships only the pluggable `Unlocker` trait + registry and stays
firmware-clean — it contains no concrete unlock code. Each crate in this
workspace implements one unlocker and is registered into libfreemkv by a single
line in the consuming binary:
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
concern — the consumer's job.
This crate defines the `Unlocker` contract and a generic SCSI transport
contract, and holds the self-contained unlocker modules. The consumer
([libfreemkv](https://github.com/freemkv/libfreemkv)) depends on this crate and
dispatches through `all_unlockers()`; it never names an individual unlocker, and
clients of libfreemkv are oblivious to unlockers entirely (as they are to the
SCSI layer).
```rust
libfreemkv::register_unlocker(Box::new(my_unlocker::MyUnlocker::new()));
for u in freemkv_unlock::all_unlockers() {
if u.matches(&ctx) {
return u.unlock(&mut scsi, &ctx);
}
}
```
Removing an unlocker is deleting that one line and the dependency
(delete-to-comply).
## Members
Each member is a self-contained unlocker plugin — see its own README for details.
To remove an unlocker, delete its module directory and its one line in
`all_unlockers()` — nothing else changes.
License: AGPL-3.0-only.
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "freemkv-unlock-ld"
version = "1.0.0-rc.5.1"
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.0.0-rc.5.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22.1"
tracing = "0.1"
-54
View File
@@ -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.
-354
View File
@@ -1,354 +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::{Error, Result};
use libfreemkv::{DriveId, ScsiTransport, 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 Unlocker for LibreDrive {
fn name(&self) -> &str {
"LibreDrive"
}
fn matches(&self, id: &DriveId) -> bool {
profile::find_bundled(id).is_some()
}
fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()> {
let Some(m) = profile::find_bundled(id) else {
// matches() returned true but the profile vanished — treat as
// "nothing to do"; the caller falls back to the cert handshake.
return Ok(());
};
let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B);
if matches!(m.platform, profile::Platform::Renesas) {
// Renesas firmware unlock is not implemented; leave the drive
// untouched so the host-cert handshake carries the disc.
return Ok(());
}
use platform::PlatformDriver;
let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b);
// Firmware unlock. On success, prime the per-region speed table so
// the drive manages zone speeds internally (best-effort — probe
// calibration failure must not fail an otherwise-good unlock).
mt.init(scsi)?;
let _ = mt.probe_disc(scsi);
Ok(())
}
/// OEM Volume ID retrieval.
///
/// An unlocker unlocks drive *functionality*, not just the disc: VID
/// retrieval via the drive's OEM CDB is a capability separate from
/// `unlock`. 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.
///
/// Returns:
/// * `Ok(Some(vid))` — the drive's profile carries an OEM VID CDB and
/// the drive served a well-formed VID. No host certificate or HRL is
/// involved — VID is decoupled from the cert chain.
/// * `Ok(None)` — no profile matched, or the profile carries no OEM VID
/// CDB; libfreemkv falls back to the cert-based VID read.
/// * `Err(_)` — the OEM CDB ran but returned a short or malformed
/// response.
///
/// Response layout (36 bytes):
/// * `[0..3]` 3-byte response signature; expected `00 22 00`
/// * `[3]` reserved
/// * `[4..20]` 16-byte Volume ID
/// * `[20..36]` reserved / per-drive padding
fn read_volume_id(
&self,
scsi: &mut dyn ScsiTransport,
id: &DriveId,
) -> Result<Option<[u8; 16]>> {
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
// path; the caller falls back to the cert handshake.
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 Err(Error::AacsVidRead);
}
if buf[0..3] != EXPECTED_HEADER {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_bad_header",
header_0 = buf[0],
header_1 = buf[1],
header_2 = buf[2],
"OEM VID response header mismatch"
);
return Err(Error::AacsVidRead);
}
let mut vid = [0u8; 16];
vid.copy_from_slice(&buf[4..20]);
tracing::debug!(
target: "freemkv::disc",
phase = "oem_vid_ok",
"OEM VID retrieved via unlocker"
);
Ok(Some(vid))
}
/// 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, id: &DriveId) -> Result<()> {
let Some(m) = profile::find_bundled(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 scsi::{DataDirection, ScsiResult, ScsiTransport};
/// 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.
#[test]
fn read_vid_parses_well_formed_response() {
// Confirm the bundled profile actually carries a read_vid_cdb;
// otherwise read_vid would short-circuit to Ok(None) 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_volume_id(&mut t, &known_vid_drive_id())
.expect("parse ok");
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
}
/// A short response (fewer than 36 transferred bytes) → AacsVidRead.
#[test]
fn read_vid_short_response_errors() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 20,
};
let err = LibreDrive::new()
.read_volume_id(&mut t, &known_vid_drive_id())
.expect_err("short response must error");
assert!(matches!(err, Error::AacsVidRead));
}
/// A response whose 3-byte signature isn't `00 22 00` → AacsVidRead.
#[test]
fn read_vid_bad_header_errors() {
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_volume_id(&mut t, &known_vid_drive_id())
.expect_err("bad header must error");
assert!(matches!(err, Error::AacsVidRead));
}
/// A drive with no matching profile → Ok(None) (cert fallback), and the
/// transport is never issued a CDB.
#[test]
fn read_vid_no_profile_is_none() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let got = LibreDrive::new()
.read_volume_id(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
.expect("no-profile is Ok(None)");
assert!(got.is_none(), "no profile → cert fallback");
}
/// 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, &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, &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");
}
}
+88
View File
@@ -0,0 +1,88 @@
//! aacs's internal error for the cert-handshake SCSI/crypto code. Mirrors the
//! handshake's original libfreemkv error surface (the specific Aacs* failure
//! points + a structured SCSI error), so the moved handshake body is unchanged.
use crate::scsi::{SCSI_STATUS_TRANSPORT_FAILURE, ScsiSense};
pub type Result<T> = std::result::Result<T, Error>;
// A few variants are matched (defensive arms in the handshake) but never
// constructed in the wired path — kept for completeness.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum Error {
AacsAgidAlloc,
AacsCertRead,
AacsCertRejected,
AacsCertShort,
AacsCertVerify,
AacsDataKey,
AacsKeyRead,
AacsKeyRejected,
AacsKeyVerify,
AacsNoKeys,
AacsVidMac,
AacsVidRead,
HandshakeRejected,
VidUnavailable,
/// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with
/// `sense: None` is a transport-layer fault; a CHECK CONDITION carries the
/// parsed [`ScsiSense`].
Scsi {
/// CDB opcode that failed — diagnostic, carried for future logging.
opcode: u8,
status: u8,
sense: Option<ScsiSense>,
},
}
impl Error {
/// Stable numeric code (logged). Values are local to this crate.
pub fn code(&self) -> u16 {
match self {
Error::AacsAgidAlloc => 7001,
Error::AacsCertRead => 7002,
Error::AacsCertRejected => 7003,
Error::AacsCertShort => 7004,
Error::AacsCertVerify => 7005,
Error::AacsDataKey => 7006,
Error::AacsKeyRead => 7007,
Error::AacsKeyRejected => 7008,
Error::AacsKeyVerify => 7009,
Error::AacsNoKeys => 7010,
Error::AacsVidMac => 7011,
Error::AacsVidRead => 7012,
Error::HandshakeRejected => 7013,
Error::VidUnavailable => 7014,
Error::Scsi { .. } => 7099,
}
}
/// The parsed sense for a CHECK CONDITION SCSI error, else `None`.
pub fn scsi_sense(&self) -> Option<ScsiSense> {
match self {
Error::Scsi { sense, .. } => *sense,
_ => None,
}
}
/// True if this is a transport-layer SCSI failure (bus dead).
pub fn is_scsi_transport_failure(&self) -> bool {
matches!(
self,
Error::Scsi { status, sense: None, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE
)
}
}
/// A generic transport fault from the SCSI contract converts in (opcode unknown
/// at the transport level; sense parsed from the raw buffer when present).
impl From<crate::scsi::ScsiError> for Error {
fn from(e: crate::scsi::ScsiError) -> Self {
Error::Scsi {
opcode: 0,
status: e.status,
sense: e.sense.map(|s| ScsiSense::from_buf(&s)),
}
}
}
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
//! aacs — the AACS host-certificate unlocker (Blu-ray / UHD).
//!
//! Self-contained module: it owns the cert-handshake EC crypto (the AKE, bus-key
//! derivation, P-160 / P-256 curve math) that REMOVES AACS bus encryption. It
//! implements [`crate::Unlocker`], learning the Volume ID + AACS 2.x bus key.
//! Content-key decryption (unit keys, MKB, VUK) is the consumer's job, not here.
mod error;
mod handshake;
use aes::Aes128;
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
use crate::scsi::ScsiTransport;
use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
/// AES-128-ECB decrypt a single 16-byte block — used to decrypt the bus key /
/// read_data_key the drive returns after the handshake.
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.decrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc
/// (`DiscKind::Aacs`) and runs the cert handshake against the host certs the
/// consumer collected (via [`UnlockCtx::host_certs`]), learning the Volume ID
/// and — on AACS 2.0 — the bus key.
pub struct AacsCert;
impl AacsCert {
pub fn new() -> Self {
AacsCert
}
}
impl Default for AacsCert {
fn default() -> Self {
Self::new()
}
}
impl Unlocker for AacsCert {
fn name(&self) -> &'static str {
"AACS"
}
fn matches(&self, ctx: &UnlockCtx) -> bool {
ctx.kind == DiscKind::Aacs
}
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
if ctx.host_certs.is_empty() {
// No host cert to authenticate with — the consumer falls back to a
// VID-less / keysource path.
return Err(UnlockError::NoUsableHostCert);
}
let h = handshake::run_cert_handshake(scsi, ctx.host_certs)?;
Ok(Unlocked {
vid: Some(h.volume_id),
// Host-cert AKE path: bus removal depends on the bus key, not a
// firmware unlock.
bus_key: h.read_data_key,
drive_unlocked: false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id() -> crate::DriveId {
crate::DriveId::default()
}
/// AacsCert matches only `DiscKind::Aacs`.
#[test]
fn matches_only_aacs_kind() {
let id = id();
let u = AacsCert::new();
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
assert!(
!u.matches(&UnlockCtx::new(&id, k, &[])),
"must not match {k:?}"
);
}
assert!(u.matches(&UnlockCtx::new(&id, DiscKind::Aacs, &[])));
}
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
/// and the transport is never touched.
#[test]
fn no_host_certs_is_no_usable_host_cert() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched with no host certs");
}
}
let id = id();
let mut t = DeadTransport;
let r = AacsCert::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert);
}
}
+41
View File
@@ -0,0 +1,41 @@
//! css's internal error for the CSS bus-auth code. Distinct from
//! [`crate::UnlockError`] (the outcome the consumer sees).
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone)]
pub enum Error {
/// The CSS bus-authentication challenge-response failed.
CssAuthFailed,
/// A SCSI transport-layer fault.
Scsi(crate::scsi::ScsiError),
}
impl Error {
/// Stable numeric code (logged). Values are local to this crate.
pub fn code(&self) -> u16 {
match self {
Error::CssAuthFailed => 7201,
Error::Scsi(_) => 7299,
}
}
}
impl From<crate::scsi::ScsiError> for Error {
fn from(e: crate::scsi::ScsiError) -> Self {
Error::Scsi(e)
}
}
/// CSS bus-auth either enables scrambled reads or it doesn't — any failure means
/// "this unlocker didn't apply" (a transport fault still surfaces as Transport).
impl From<Error> for crate::UnlockError {
fn from(e: Error) -> Self {
match e {
Error::Scsi(s) if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE => {
crate::UnlockError::Transport
}
_ => crate::UnlockError::NotApplicable,
}
}
}
+1008
View File
File diff suppressed because it is too large Load Diff
View File
+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).
Scsi {
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::Scsi { 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::Scsi { 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::Scsi {
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
}
}
}
+302
View File
@@ -0,0 +1,302 @@
//! 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.
// `cdb` carries ONLY the unlock-handshake wire format that the bdemu emulator
// needs (the real unlocker drives its CDBs from per-drive profile templates, not
// these constants). Compile it only when the `emulation` feature exposes it, so
// it never dead-codes in a normal build.
#[cfg(feature = "emulation")]
mod cdb;
mod error;
mod platform;
mod profile;
use crate::ld::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
// ── Public profile catalog ──────────────────────────────────────────────────
//
// The catalog of drives the LibreDrive unlocker recognizes is the one piece of
// ld worth exposing publicly: it answers "is this drive supported?" without
// unlocking, and the bdemu test-emulator reads it to impersonate a supported
// drive. The unlock *mechanism* (firmware blobs, upload sequence, CDB wire
// format) stays private — only the catalog and its match result are public.
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
/// The bundled LibreDrive profile catalog (parsed once, process-cached), or
/// `None` if the embedded JSON fails to parse (a build-time bug). Pair with
/// [`Profiles::get`] to look up a specific drive:
/// `freemkv_unlock::ld::profiles().and_then(|p| p.get(&drive_id))`.
pub fn profiles() -> Option<&'static Profiles> {
profile::bundled()
}
/// The bundled profile matching a drive identity, if the drive is supported.
/// Convenience over [`profiles`] + [`Profiles::get`].
pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
profile::find_bundled(drive_id)
}
/// The unlock-handshake wire format the bdemu test-emulator needs to impersonate
/// an ld-unlockable drive: the marker an unlocked drive returns and the
/// READ BUFFER mode/buf-id that constitutes an unlock request. Behind the
/// non-default `emulation` feature so real clients never see ld's wire format.
#[cfg(feature = "emulation")]
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
/// The LibreDrive unlocker. `pub(crate)` — clients reach it only through
/// [`crate::all_unlockers`], never by name (the locked-design contract).
///
/// Matches a drive against the bundled profile database and, on a hit,
/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration)
/// handshake over the raw SCSI transport.
pub(crate) struct LibreDrive;
impl LibreDrive {
pub(crate) fn new() -> Self {
LibreDrive
}
}
/// The firmware-unlocker name for a drive that has a bundled profile (for
/// drive-info "is this drive supported?" display), or `None`. A pure profile
/// lookup — does NOT touch the drive or unlock anything.
pub(crate) fn firmware_name(id: &DriveId) -> Option<&'static str> {
profile::find_bundled(id).map(|_| "LibreDrive")
}
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 {
/// Firmware unlock is a DRIVE-PREP concern: it runs before the disc kind is
/// probed (`kind == Unknown`) and keys off the drive identity. It must NOT
/// fire during the later content-keyed dispatch (Aacs/Css), or a DVD/Blu-ray
/// in a profiled drive would be re-firmware-unlocked.
fn name(&self) -> &'static str {
"LibreDrive"
}
fn matches(&self, ctx: &UnlockCtx) -> bool {
ctx.kind == crate::DiscKind::Unknown && 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,
})
}
}
#[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,
) -> crate::scsi::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);
}
}
@@ -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 {
@@ -4,8 +4,8 @@ mod variant_a;
mod variant_b;
use super::PlatformDriver;
use crate::error::{Error, Result};
use crate::profile::DriveProfile;
use crate::ld::error::{Error, Result};
use crate::ld::profile::DriveProfile;
use crate::scsi::{self, DataDirection, ScsiTransport};
// ── Variant constants ──────────────────────────────────────────────────
@@ -124,7 +124,7 @@ impl Mt1959 {
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
if result.bytes_transferred != expected {
return Err(Error::ScsiError {
return Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
@@ -217,7 +217,7 @@ impl Mt1959 {
return Ok(());
}
}
Err(Error::ScsiError {
Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
@@ -327,7 +327,7 @@ impl Mt1959 {
)
.is_err()
{
return Err(Error::ScsiError {
return Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
@@ -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
@@ -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,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,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,
+42 -35
View File
@@ -1,11 +1,13 @@
//! 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.
/// The LibreDrive profile catalog — the set of optical drives the firmware
/// unlocker recognizes, keyed by chipset + variant. Loaded from the bundled
/// JSON; the public entry point is [`crate::ld::profiles`].
#[derive(Debug, Deserialize)]
pub struct ProfilesFile {
pub struct Profiles {
#[serde(default)]
pub mt1959_a: Vec<DriveProfile>,
#[serde(default)]
@@ -14,6 +16,15 @@ pub struct ProfilesFile {
pub renesas: Vec<DriveProfile>,
}
impl Profiles {
/// The profile matching a drive identity, if this catalog supports that
/// drive. Two-pass per platform section: exact (incl. firmware date) then a
/// looser vendor/revision/vendor-specific match. See [`find_by_drive_id`].
pub fn get(&self, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
find_by_drive_id(self, drive_id)
}
}
/// Drive identity — matched against INQUIRY data.
#[derive(Debug, Clone, Deserialize)]
pub struct Identity {
@@ -233,14 +244,13 @@ 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`].
///
/// Re-parses the embedded JSON (~800 KB) on every call; prefer
/// [`bundled`] for the hot path, which parses once and caches. This
/// owned form is kept for callers that need a mutable / independent copy.
pub fn load_bundled() -> Result<ProfilesFile> {
/// Parse the bundled profiles fresh into an owned [`Profiles`]. Test-only — the
/// library hot path uses the cached [`bundled`]; tests use this owned form for
/// independent copies.
#[cfg(test)]
pub fn load_bundled() -> Result<Profiles> {
load_from_str(BUNDLED_PROFILES)
}
@@ -250,9 +260,9 @@ pub fn load_bundled() -> Result<ProfilesFile> {
/// Returns `None` if the embedded JSON fails to parse (a build-time bug —
/// the bundled blob is fixed at compile time, so the first successful call
/// guarantees all later calls succeed too).
pub fn bundled() -> Option<&'static ProfilesFile> {
pub fn bundled() -> Option<&'static Profiles> {
use std::sync::OnceLock;
static CACHE: OnceLock<Option<ProfilesFile>> = OnceLock::new();
static CACHE: OnceLock<Option<Profiles>> = OnceLock::new();
CACHE
.get_or_init(|| load_from_str(BUNDLED_PROFILES).ok())
.as_ref()
@@ -263,11 +273,11 @@ 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)
}
fn load_from_str(data: &str) -> Result<ProfilesFile> {
fn load_from_str(data: &str) -> Result<Profiles> {
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
}
@@ -280,10 +290,7 @@ fn load_from_str(data: &str) -> Result<ProfilesFile> {
/// 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: &libfreemkv::DriveId,
) -> Option<ProfileMatch> {
pub fn find_by_drive_id(profiles: &Profiles, 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();
@@ -324,15 +331,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]
@@ -374,11 +381,11 @@ mod tests {
fn bundled_is_cached_and_matches_fresh_parse() {
let cached = bundled().expect("bundled profiles parse");
let fresh = load_bundled().unwrap();
// Same data either way (compare section sizes — ProfilesFile isn't Eq).
// Same data either way (compare section sizes — Profiles isn't Eq).
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
// Cached accessor returns a stable address across calls.
let a = bundled().unwrap() as *const ProfilesFile;
let b = bundled().unwrap() as *const ProfilesFile;
let a = bundled().unwrap() as *const Profiles;
let b = bundled().unwrap() as *const Profiles;
assert_eq!(a, b);
}
@@ -449,7 +456,7 @@ mod tests {
/// find_by_drive_id: exact match (including firmware_date) wins over loose match.
/// Spec: two-pass — first an exact match including firmware_date, then looser.
/// Build two synthetic ProfilesFile entries that differ only by firmware_date,
/// Build two synthetic Profiles entries that differ only by firmware_date,
/// and verify the correct one is selected.
/// Mutation: doing only the loose pass would return the first entry regardless of date.
#[test]
@@ -483,7 +490,7 @@ mod tests {
]
})
.to_string();
let profiles: ProfilesFile = serde_json::from_str(&profiles_json).unwrap();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
@@ -528,7 +535,7 @@ mod tests {
]
})
.to_string();
let profiles: ProfilesFile = serde_json::from_str(&profiles_json).unwrap();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// Drive with an unknown firmware date — no exact match, loose match should work.
// "LOOSEDR " fills 8 bytes; "000000000000" is the unknown date.
@@ -542,11 +549,11 @@ mod tests {
}
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
/// Mutation: returning an empty ProfilesFile instead of an error silently
/// Mutation: returning an empty Profiles instead of an error silently
/// leaves the drive-profile database empty.
#[test]
fn load_from_str_returns_profile_parse_on_bad_json() {
let result: Result<ProfilesFile> =
let result: Result<Profiles> =
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
assert!(matches!(result, Err(Error::ProfileParse)));
}
@@ -586,7 +593,7 @@ mod tests {
]
})
.to_string();
let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
let p = &profiles.mt1959_a[0]; // DriveProfile directly
// All optional CDB fields must be None when absent from JSON.
assert!(
@@ -637,7 +644,7 @@ mod tests {
]
})
.to_string();
let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
assert_eq!(
profiles.mt1959_a[0].signature, [0u8; 4],
"empty signature must deserialise as [0;4]"
+150
View File
@@ -0,0 +1,150 @@
//! 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 scsi;
mod aacs;
mod css;
// `ld` is public ONLY for its drive-profile catalog (`ld::profiles` / the
// `Profiles` object) and, under the `emulation` feature, the unlock-handshake
// wire format the bdemu test-emulator needs. The unlocker impl itself
// (`LibreDrive`) is `pub(crate)` — clients still reach unlockers only through
// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they
// stay fully private.
pub mod ld;
use scsi::ScsiTransport;
/// 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 {
/// AACS 1.0 host private key (20 bytes).
pub private_key: [u8; 20],
/// AACS 1.0 host certificate (92 bytes).
pub certificate: Vec<u8>,
/// AACS 2.0 host private key (P-256, 32 bytes). `None` for AACS 1.0 only.
pub private_key_v2: Option<[u8; 32]>,
/// AACS 2.0 host certificate (type 0x11). `None` for AACS 1.0 only.
pub certificate_v2: Option<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,
}
/// 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`]. (Each module owns its own conversion from its
/// internal error to [`UnlockError`].)
///
/// NOTE: drive tuning (e.g. SET CD SPEED to lift riplock) is deliberately NOT
/// here — that is the consumer's concern, not bus removal.
pub trait Unlocker: Send + Sync {
/// Short, stable identifier for this unlocker (e.g. "LibreDrive", "AACS",
/// "CSS"). The ONE place a name lives — apps render the unlocker report from
/// [`all_unlockers`], never hardcoding names, so adding/removing an unlocker
/// updates every report with no app change.
fn name(&self) -> &'static str;
/// True if this unlocker applies to the given context (drive id + disc kind)
/// during live dispatch — the gate [`crate::all_unlockers`] uses to decide
/// whether to RUN it.
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>;
}
/// Name of the unlocker that claims this drive by identity (for drive-info "is
/// this drive supported?" display), or `None`. A pure lookup — does NOT touch
/// the drive or unlock anything. Only the identity-keyed (drive-prep) unlocker
/// can answer from a `DriveId` alone; the disc-kind-keyed unlockers (AACS / CSS)
/// don't claim a drive sight-unseen, so they never match here.
pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
ld::firmware_name(drive_id)
}
/// 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()),
Box::new(css::CssUnlocker::new()),
]
}
+100
View File
@@ -0,0 +1,100 @@
//! 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],
}
/// A transport-layer SCSI failure (the command could not complete — bridge
/// crash / disconnect), as opposed to a drive sense returned in [`ScsiResult`].
#[derive(Debug, Clone)]
pub struct ScsiError {
pub status: u8,
pub sense: Option<[u8; 32]>,
}
/// Transport-layer result.
pub type Result<T> = std::result::Result<T, ScsiError>;
/// 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,
) -> Result<ScsiResult>;
}
/// Parsed SCSI sense (the diagnostic an unlocker reads off a failed command).
#[derive(Debug, Clone, Copy)]
pub struct ScsiSense {
pub sense_key: u8,
pub asc: u8,
pub ascq: u8,
}
impl ScsiSense {
/// Parse the fixed-format sense buffer (key at byte 2, ASC at 12, ASCQ at 13).
pub fn from_buf(sense: &[u8; 32]) -> Self {
ScsiSense {
sense_key: sense[2] & 0x0F,
asc: sense[12],
ascq: sense[13],
}
}
/// ILLEGAL REQUEST (sense key 0x05) — the drive won't honor the command.
pub fn is_illegal_request(&self) -> bool {
self.sense_key == 0x05
}
}
/// SCSI status byte for a transport-layer failure (bridge crash / disconnect).
pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
/// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the
/// status contract; currently referenced only by tests asserting the
/// transport-vs-check-condition distinction.
#[allow(dead_code)]
pub(crate) const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02;
// Common opcodes used by the unlocker modules.
pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB;
pub(crate) const SCSI_SEND_KEY: u8 = 0xA3;
pub(crate) const SCSI_REPORT_KEY: u8 = 0xA4;
pub(crate) const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
pub(crate) const SCSI_GET_CONFIGURATION: u8 = 0x46;
/// AACS key class selector used in REPORT/SEND KEY CDBs.
pub(crate) const AACS_KEY_CLASS: u8 = 0x02;
/// 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,
]
}