unlock: unify the Unlocker contract across all routes

Every unlocker now implements one method — unlock(&self, scsi, ctx) ->
Result<Option<Unlocked>> — with identical workflow and error handling,
differing only in CDBs/checks. Drops the unlock_features/unlock_bus split,
per-unlocker result state, and the drive_unlocked flag ("unlocked" is just
Some). Only a dead bus is Err; everything else falls through as Ok(None).

- VID read is best-effort everywhere via the shared crate::vid reader: a
  VID miss no longer discards an unlock that already removed the bus — the
  bug that dropped a freemkv-unlocked drive to the cert path and sent the
  keyserver vid=none.
- AACS: host certs injected at construction (AacsUnlocker::new(certs)), not
  passed per-call; a successful cert AKE is a real unlock.
- Renesas/Pioneer: the vendor open enables raw reads (LibreDrive parity),
  then reads the VID via the same 0xAD reader — no host cert, matching the
  firmware routes.
- Rename the MT1959 unlocker to LD (matches the ld/ module); the MediaTek
  MT1959 platform driver keeps its name.
- Consumer assembles its own dispatch list from the exported types; no
  central factory. Adds a uniform dead-bus contract test over all unlockers.
This commit is contained in:
Matthew Jackson
2026-09-02 17:53:17 -07:00
parent 417d4598ca
commit 09dd8ef14c
10 changed files with 554 additions and 650 deletions
+58 -68
View File
@@ -12,7 +12,7 @@ use aes::Aes128;
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
use crate::scsi::ScsiTransport;
use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
use crate::{DiscKind, HostCert, 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.
@@ -25,53 +25,47 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
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;
/// The AACS host-certificate unlocker — the fallback for a drive with NO vendor
/// unlock (can't be raw-read-enabled), so the bus is removed by a real host-cert
/// AKE + bus key rather than a CDB. The host certs are injected at CONSTRUCTION
/// (the one place certs enter the system). Owns its AKE crypto — no reach into
/// libfreemkv.
pub struct AacsUnlocker {
host_certs: Vec<HostCert>,
}
impl AacsCert {
pub fn new() -> Self {
AacsCert
impl AacsUnlocker {
pub fn new(host_certs: Vec<HostCert>) -> Self {
AacsUnlocker { host_certs }
}
}
impl Default for AacsCert {
fn default() -> Self {
Self::new()
}
}
impl Unlocker for AacsCert {
impl Unlocker for AacsUnlocker {
fn name(&self) -> &'static str {
"AACS"
}
/// AACS removes BUS encryption via the host-cert handshake; it provides no
/// drive features. Self-guards on the disc kind (the consumer iterates every
/// unlocker's `unlock_bus`, so a non-AACS disc must decline here).
fn unlock_bus(
/// Remove AACS bus encryption via the host-cert handshake. Like every other
/// unlocker this DOES unlock the drive — just with a cert + AKE instead of a
/// vendor CDB. `Some` on a successful handshake (VID + bus key learned);
/// `None` on a non-AACS disc, no usable cert, or a rejected handshake (fall
/// through); `Err(Transport)` on a dead bus.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
if ctx.kind != DiscKind::Aacs {
return Err(UnlockError::NotApplicable);
) -> std::result::Result<Option<Unlocked>, UnlockError> {
if ctx.kind != DiscKind::Aacs || self.host_certs.is_empty() {
// Wrong disc kind, or no host cert to authenticate with — the loop
// falls through to a VID-less / keysource path.
return Ok(None);
}
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 {
crate::fallthrough(
handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| 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,
})
}),
)
}
}
@@ -115,11 +109,11 @@ mod tests {
crate::DriveId::default()
}
/// `unlock_bus` self-guards on the disc kind: on a non-AACS disc it declines
/// (`NotApplicable`) WITHOUT touching the transport, so iterating it on a
/// CSS/unknown disc is safe.
/// Self-guards on the disc kind: on a non-AACS disc `unlock()` declines
/// (`Ok(false)`) WITHOUT touching the transport, even WITH a cert present —
/// so the reason is the kind, not a missing cert.
#[test]
fn unlock_bus_declines_non_aacs_kinds() {
fn declines_non_aacs_kinds() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
@@ -135,49 +129,41 @@ mod tests {
let id = id();
let mut t = DeadTransport;
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, k, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable, "declines {k:?}");
assert!(
AacsUnlocker::new(vec![host_cert()])
.unlock(&mut t, &UnlockCtx::new(&id, k))
.expect("declines")
.is_none(),
"declines {k:?}"
);
}
}
/// `Default` must delegate to `new()` (there is only one way to build an
/// `AacsCert`, so this pins the two constructors never drift apart).
// Full success path through `Unlocker::unlock`: a self-consistent AACS 1.0
// emulator proves the entry point learns `vid`/`bus_key` — and that the cert
// route reports `unlock() == true` (it DOES unlock the drive, via the cert).
#[test]
#[allow(clippy::default_constructed_unit_structs)]
fn default_matches_new() {
let _ = AacsCert::default();
let _ = AacsCert::new();
}
// Full success path through `Unlocker::unlock_bus`: a self-consistent
// AACS 1.0 emulator proves the public entry point (not just
// `run_cert_handshake`) wires `volume_id`/`read_data_key` into `Unlocked`.
#[test]
fn unlock_bus_succeeds_end_to_end() {
fn unlock_succeeds_end_to_end() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_data_keys = true;
let id = id();
let certs = [host_cert()];
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &certs);
let unlocked = AacsCert::new()
.unlock_bus(&mut t, &ctx)
.expect("auth + VID + data-key reads all succeed");
assert_eq!(unlocked.vid, Some([0x5Au8; 16]));
assert!(unlocked.bus_key.is_some());
assert!(
!unlocked.drive_unlocked,
"cert path never sets drive_unlocked"
);
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.unlock(&mut t, &ctx)
.expect("auth + VID + data-key reads all succeed")
.expect("the cert route unlocks the drive");
assert_eq!(out.vid, Some([0x5Au8; 16]));
assert!(out.bus_key.is_some());
}
fn host_cert() -> crate::HostCert {
handshake::tests::dummy_cert()
}
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
/// and the transport is never touched.
#[test]
fn no_host_certs_is_no_usable_host_cert() {
fn no_host_certs_declines() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
@@ -192,7 +178,11 @@ mod tests {
}
let id = id();
let mut t = DeadTransport;
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert);
assert!(
AacsUnlocker::new(vec![])
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
.expect("no certs declines")
.is_none()
);
}
}
+36 -57
View File
@@ -206,30 +206,35 @@ impl crate::Unlocker for DvdUnlocker {
"DVD"
}
/// CSS removes the scrambled-sector barrier (a bus-level concern); it
/// provides no drive features. Self-guards against the hardware (below), so
/// it declines cleanly when the consumer iterates it on a non-DVD.
fn unlock_bus(
/// CSS removes the scrambled-sector barrier via bus-auth. Self-guards
/// against the hardware (below), so it declines cleanly (`Ok(None)`) when the
/// consumer iterates it on a non-DVD. `Some` once the bus-auth runs;
/// `Err(Transport)` on a dead bus. Yields no VID and no bus key (descrambling
/// is keyless — the returned `Unlocked` is empty).
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &crate::UnlockCtx,
) -> std::result::Result<crate::Unlocked, crate::UnlockError> {
) -> std::result::Result<Option<crate::Unlocked>, crate::UnlockError> {
// Self-guard against the hardware, not just the caller-declared
// DiscKind: refuse (NotApplicable) without issuing any CSS CDB when
// the drive doesn't report a DVD profile.
// DiscKind: decline without issuing any CSS CDB when the drive doesn't
// report a DVD profile.
if !mounted_disc_is_dvd(scsi)? {
tracing::debug!(
target: "freemkv::css",
phase = "dvd_unlocker_not_dvd",
"DvdUnlocker invoked on a non-DVD profile; refusing (NotApplicable)"
"DvdUnlocker invoked on a non-DVD profile; declining"
);
return Err(crate::UnlockError::NotApplicable);
return Ok(None);
}
// The bus-auth handshake is what unlocks scrambled-sector reads; the lba
// is not consumed by the unlock primitive (the disc-key REPORT KEY is
// best-effort). CSS yields neither a Volume ID nor an AACS bus key.
unlock_css_reads(scsi, 0)?;
Ok(crate::Unlocked::default())
// Bus-auth unlocks scrambled-sector reads (the lba isn't consumed here;
// the disc-key REPORT KEY is best-effort). A refused handshake falls
// through (`fallthrough` → `Ok(None)`); only a dead bus is an `Err`.
crate::fallthrough(
unlock_css_reads(scsi, 0)
.map(|()| crate::Unlocked::default())
.map_err(crate::UnlockError::from),
)
}
}
@@ -925,36 +930,13 @@ mod tests {
}
/// DvdUnlocker provides bus removal only — it never provides drive features.
#[test]
fn dvd_unlocker_provides_no_features() {
use crate::scsi::{DataDirection, ScsiResult};
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
panic!("unlock_features must not touch the transport");
}
}
let id = DriveId::default();
let mut t = DeadTransport;
let r =
DvdUnlocker::new().unlock_features(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
}
// Defense in depth: even when the caller declares `DiscKind::Css`,
// DvdUnlocker self-verifies against GET CONFIGURATION; a BD profile
// must yield NotApplicable with no CSS CDB issued.
#[test]
fn dvd_unlocker_self_guards_against_non_dvd() {
use crate::scsi::{DataDirection, ScsiResult};
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
use crate::{DiscKind, DriveId, UnlockCtx, Unlocker};
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
/// other CDB (i.e. CSS bus-auth activity).
@@ -995,10 +977,9 @@ mod tests {
};
let mut t = BdTransport { non_config_cdbs: 0 };
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
assert_eq!(
r.unwrap_err(),
UnlockError::NotApplicable,
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert!(
r.expect("BD profile declines, not a hard error").is_none(),
"a BD-profile drive must be refused"
);
assert_eq!(
@@ -1018,7 +999,7 @@ mod tests {
fn transport_fault_probing_for_a_dvd_aborts() {
let id = DriveId::default();
let mut t = MockTransport::always(Reply::TransportFault);
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(r.unwrap_err(), UnlockError::Transport);
assert_eq!(
t.calls(),
@@ -1034,8 +1015,8 @@ mod tests {
fn check_condition_probing_for_a_dvd_declines() {
let id = DriveId::default();
let mut t = MockTransport::always(Reply::illegal_request());
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert!(r.expect("inconclusive probe declines").is_none());
}
// Defect-2 regression: a DVD is mounted, then the bus dies mid
@@ -1049,7 +1030,7 @@ mod tests {
config[6] = 0x00;
config[7] = 0x10;
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::TransportFault);
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(r.unwrap_err(), UnlockError::Transport);
}
@@ -1061,8 +1042,8 @@ mod tests {
let mut config = vec![0u8; 8];
config[7] = 0x10;
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::illegal_request());
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert!(r.expect("a refused bus-auth declines").is_none());
}
// A CHECK CONDITION on AGID allocation must not let the handshake carry
@@ -1339,9 +1320,9 @@ mod tests {
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
}
// The same happy path through the public `unlock_bus` entry point,
// proving it returns `Ok(Unlocked::default())` on success — the path
// every failure-injection test above deliberately avoids.
// The same happy path through the public `unlock` entry point, proving it
// returns `Some(Unlocked::default())` on success — the path every
// failure-injection test above deliberately avoids.
#[test]
fn dvd_unlocker_succeeds_end_to_end() {
let id = DriveId::default();
@@ -1349,14 +1330,12 @@ mod tests {
variant: 3,
host_challenge: [0u8; 10],
};
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
let unlocked = r.expect("full unlock succeeds");
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
let unlocked = r
.expect("full unlock succeeds")
.expect("DVD bus-auth unlocks the drive");
assert!(unlocked.vid.is_none(), "CSS yields no Volume ID");
assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key");
assert!(
!unlocked.drive_unlocked,
"CSS is a bus-auth unlock, not a firmware drive-unlock"
);
}
/// `unlock_css_reads` (the crate-level public entry point, distinct from
+72 -268
View File
@@ -75,16 +75,6 @@ const IDENTITY_RESPONSE_LEN: usize = 32;
// transfer (ABORTED COMMAND, then a wedged FIFO) — see docs/freemkv-abi.md.
const KNOCK_RESP_LEN: usize = 64;
/// The 16-byte AACS Volume ID.
const VID_LEN: usize = 16;
/// AACS `READ DISC STRUCTURE` (`0xAD`) format for the Volume ID.
const DISC_STRUCT_FMT_VID: u8 = 0x80;
/// Response length requested for the format-0x80 VID structure: a 4-byte header,
/// the 16-byte VID, and a 16-byte MAC. On the bare-read path (no AKE → no bus
/// key) the MAC cannot be verified and is ignored; only `buf[4..20]` matters.
const VID_STRUCT_LEN: u16 = 36;
/// Bytes returned by one DumpAll (subfn 0x09) diagnostic read — the firmware
/// always commits a fixed 64-byte window.
const MEMREAD_LEN: usize = 64;
@@ -126,20 +116,6 @@ fn build_memread_cdb(addr: u32) -> [u8; 10] {
]
}
// Build the STANDARD AACS READ DISC STRUCTURE (0xAD) CDB for the Volume ID
// (format 0x80, Blu-ray, AGID 0) — NOT a vendor knock; valid only once Raw
// Read is on. Mirrors libfreemkv's read_volume_id.
fn build_read_disc_structure_vid_cdb() -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
cdb[1] = 0x01; // Blu-ray
cdb[7] = DISC_STRUCT_FMT_VID;
cdb[8] = (VID_STRUCT_LEN >> 8) as u8;
cdb[9] = (VID_STRUCT_LEN & 0xFF) as u8;
// cdb[10] = agid << 6; AGID 0 on the bare path (no AKE) → 0.
cdb
}
/// Whether a transport error is a genuine dead bus (a senseless
/// transport-failure status) rather than a drive rejection surfaced through a
/// non-conforming transport (`Err` carrying a sense).
@@ -147,13 +123,14 @@ fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
// The freemkv custom-firmware unlocker; pub(crate) so clients reach it only via
// crate::all_unlockers. Detection: subfn-01 Identity, checking the response starts
// "freemkv" — no bundled profile catalog needed, unlike crate::ld::Mt1959Unlocker.
pub(crate) struct FreemkvUnlocker;
// The freemkv custom-firmware unlocker. Detection: subfn-01 Identity, checking
// the response starts "freemkv" — no bundled profile catalog needed, unlike
// crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned.
#[derive(Default)]
pub struct FreemkvUnlocker;
impl FreemkvUnlocker {
pub(crate) fn new() -> Self {
pub fn new() -> Self {
FreemkvUnlocker
}
@@ -274,71 +251,6 @@ impl FreemkvUnlocker {
self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID)
}
// Read the AACS VID with a BARE 0xAD fmt 0x80 (valid only after set_raw_read):
// no AKE/cert/bus key needed; VID comes back clear at response[4..20], MAC at
// [20..36] is unverifiable and ignored. Ok/Err semantics: see docs/freemkv-abi.md.
fn read_vid(
&self,
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<[u8; VID_LEN], UnlockError> {
let cdb = build_read_disc_structure_vid_cdb();
let mut buf = [0u8; VID_STRUCT_LEN as usize];
let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => r,
Err(e) => {
if is_dead_bus(&e) {
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_vid_rejected_as_err",
status = e.status,
"bare VID read rejected (via Err); no Volume ID"
);
return Err(UnlockError::VidUnavailable);
}
};
// A drive sense arrives as Ok with a non-zero status; without this check
// a CHECK CONDITION's zero-filled buffer would parse as a VID.
if result.status != 0 {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_vid_check_condition",
status = result.status,
"bare VID read returned a drive sense (no medium / Raw Read not honoured)"
);
return Err(UnlockError::VidUnavailable);
}
// Need the 4-byte header + the 16-byte VID.
if result.bytes_transferred < 4 + VID_LEN {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_vid_short_response",
bytes_transferred = result.bytes_transferred,
"bare VID response too short"
);
return Err(UnlockError::VidUnavailable);
}
let mut vid = [0u8; VID_LEN];
vid.copy_from_slice(&buf[4..4 + VID_LEN]);
// An all-zero VID is what a permissive stub or an unfilled response
// leaves behind — reject it rather than pass a bogus key downstream.
if vid.iter().all(|&b| b == 0) {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_vid_all_zero",
"bare VID read returned an all-zero Volume ID"
);
return Err(UnlockError::VidUnavailable);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_vid_ok",
"Volume ID retrieved via bare 0xAD read"
);
Ok(vid)
}
/// DumpAll diagnostic RAM read (subfn 0x09): return the 64-byte window at
/// `addr`. A host-side diagnostic path only — not used by the unlock flow.
#[allow(dead_code)]
@@ -409,18 +321,17 @@ impl FreemkvUnlocker {
return Err(UnlockError::VidUnavailable);
}
}
// Bare 0xAD VID read (LOAD-BEARING). No VID ⇒ fail (propagates).
let vid = self.read_vid(scsi)?;
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
// LD/Renesas routes). Raw Read already unlocked the drive, so a VID miss
// must not discard it: only a dead bus propagates (`?`), else `None`.
let vid = crate::vid::read_aacs_vid(scsi)?;
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_unlocked",
"freemkv drive unlocked (Raw Read on, VID read)"
has_vid = vid.is_some(),
"freemkv drive unlocked (Raw Read on)"
);
Ok(Unlocked {
vid: Some(vid),
bus_key: None,
drive_unlocked: true,
})
Ok(Unlocked { vid, bus_key: None })
}
}
@@ -429,26 +340,18 @@ impl Unlocker for FreemkvUnlocker {
"freemkv"
}
/// Unlock a freemkv drive at drive-prep: recognise it, lift riplock and
/// region (best-effort), turn on Raw Read, and read the Volume ID with a
/// bare `0xAD`. One pass turns every capability on and returns the VID.
fn unlock_features(
/// Recognise the drive by its Identity knock, lift riplock/region
/// (best-effort), turn on Raw Read (the actual unlock), and read the Volume
/// ID with a bare `0xAD` (best-effort). `Some` when Raw Read succeeded — the
/// drive is unlocked whether or not the VID read did; `None` if it isn't a
/// freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
/// unlocker self-identifies rather than matching on drive identity.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
self.full_unlock(scsi)
}
/// Same full sequence as [`Unlocker::unlock_features`]: a freemkv drive is
/// unlocked in one pass, removing bus encryption (Raw Read on) and returning
/// the Volume ID.
fn unlock_bus(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
self.full_unlock(scsi)
) -> std::result::Result<Option<Unlocked>, UnlockError> {
crate::fallthrough(self.full_unlock(scsi))
}
}
@@ -460,7 +363,7 @@ mod tests {
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Unknown, &[])
UnlockCtx::new(id, DiscKind::Unknown)
}
/// Serves a fixed payload with a GOOD status (used for the Identity probe).
@@ -493,9 +396,10 @@ mod tests {
}
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked).
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked). 36 =
/// the fixed `0xAD` fmt-0x80 response length (see `crate::vid`).
fn vid_ds_response(vid: [u8; 16]) -> Vec<u8> {
let mut p = vec![0u8; VID_STRUCT_LEN as usize];
let mut p = vec![0u8; 36];
p[4..20].copy_from_slice(&vid);
p
}
@@ -569,18 +473,6 @@ mod tests {
);
}
/// The bare VID read is the STANDARD 0xAD READ DISC STRUCTURE (format 0x80,
/// Blu-ray, AGID 0, len 36) — NOT a vendor knock.
#[test]
fn build_read_disc_structure_vid_cdb_is_standard_0xad_fmt_80() {
let cdb = build_read_disc_structure_vid_cdb();
assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
assert_eq!(cdb[1], 0x01); // Blu-ray
assert_eq!(cdb[7], 0x80); // AACS Volume ID
assert_eq!([cdb[8], cdb[9]], [0x00, 0x24]); // 36, BE16
assert_eq!(cdb[10], 0x00); // AGID 0 (no AKE)
}
// ── Detection ────────────────────────────────────────────────────────
#[test]
@@ -690,75 +582,9 @@ mod tests {
assert_eq!(err, UnlockError::Transport);
}
// ── Bare 0xAD VID read ───────────────────────────────────────────────
/// read_vid issues the STANDARD 0xAD READ DISC STRUCTURE and lifts the VID
/// from response[4..20].
#[test]
fn read_vid_issues_standard_0xad_and_parses_offset_4() {
let vid = [0x5Au8; 16];
let mut t = MockTransport::always(Reply::good(vid_ds_response(vid)));
let got = FreemkvUnlocker::new().read_vid(&mut t).expect("parse ok");
assert_eq!(got, vid);
assert_eq!(t.cdbs.len(), 1, "exactly one command");
assert_eq!(t.cdbs[0][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
assert_eq!(t.cdbs[0][7], 0x80);
}
/// The MAC region (bytes 20..36) is ignored on the bare path — a zero MAC
/// still yields the VID.
#[test]
fn read_vid_ignores_the_unverifiable_mac() {
let vid: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
];
let resp = vid_ds_response(vid); // MAC left zero
let mut t = MockTransport::always(Reply::good(resp));
assert_eq!(FreemkvUnlocker::new().read_vid(&mut t).expect("ok"), vid);
}
/// A response too short to carry header + VID → VidUnavailable.
#[test]
fn read_vid_short_response_is_vid_unavailable() {
let mut t = MockTransport::always(Reply::short(vid_ds_response([0x11; 16]), 19));
assert_eq!(
FreemkvUnlocker::new().read_vid(&mut t).unwrap_err(),
UnlockError::VidUnavailable
);
}
/// A CHECK CONDITION (no medium / Raw Read not honoured) → VidUnavailable,
/// never a VID parsed from the zero fill.
#[test]
fn read_vid_check_condition_is_vid_unavailable() {
let mut t = MockTransport::always(Reply::illegal_request());
assert_eq!(
FreemkvUnlocker::new().read_vid(&mut t).unwrap_err(),
UnlockError::VidUnavailable
);
}
/// An all-zero VID (permissive stub / unfilled response) is rejected.
#[test]
fn read_vid_all_zero_is_vid_unavailable() {
let mut t = MockTransport::always(Reply::good(vid_ds_response([0u8; 16])));
assert_eq!(
FreemkvUnlocker::new().read_vid(&mut t).unwrap_err(),
UnlockError::VidUnavailable
);
}
#[test]
fn read_vid_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(
FreemkvUnlocker::new().read_vid(&mut t).unwrap_err(),
UnlockError::Transport
);
}
// ── full_unlock / unlock_features / unlock_bus ───────────────────────
// ── full_unlock / unlock ─────────────────────────────────────────────
// The bare 0xAD VID read itself is tested in crate::vid; here we cover the
// full unlock sequence end-to-end, including the best-effort VID handling.
/// The full unlock runs `01→03→02→04→AD` in order: identity, region, speed,
/// raw-read, bare VID read. This is the load-bearing sequence test.
@@ -776,11 +602,11 @@ mod tests {
Reply::TransportFault,
);
let id = crate::DriveId::default();
let u = FreemkvUnlocker::new()
.unlock_features(&mut t, &ctx(&id))
.expect("recognized");
assert!(u.drive_unlocked, "Raw Read on ⇒ drive unlocked");
assert_eq!(u.vid, Some(vid));
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("Raw Read on ⇒ unlocked");
assert_eq!(out.vid, Some(vid));
// Four vendor knocks then the standard 0xAD read.
assert_eq!(t.cdbs.len(), 5);
let vendor_subfns: Vec<u8> = t.cdbs[..4].iter().map(|c| c[4]).collect();
@@ -804,17 +630,18 @@ mod tests {
Reply::TransportFault,
);
let id = crate::DriveId::default();
let u = FreemkvUnlocker::new()
.unlock_features(&mut t, &ctx(&id))
.expect("recognized");
assert_eq!(u.vid, Some(vid));
assert!(u.drive_unlocked);
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("unlocked");
assert_eq!(out.vid, Some(vid));
}
/// Raw Read is LOAD-BEARING: if the drive rejects it, the unlock FAILS with
/// VidUnavailable — no fallback.
/// Raw Read is LOAD-BEARING: if the drive rejects it, this isn't an
/// unlockable freemkv drive, so `unlock()` declines (`Ok(false)`) and falls
/// through — never a hard error.
#[test]
fn full_unlock_fails_when_raw_read_rejected() {
fn declines_when_raw_read_rejected() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
@@ -825,16 +652,20 @@ mod tests {
Reply::TransportFault,
);
let id = crate::DriveId::default();
let err = FreemkvUnlocker::new()
.unlock_features(&mut t, &ctx(&id))
.unwrap_err();
assert_eq!(err, UnlockError::VidUnavailable);
assert!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.is_none()
);
}
/// No VID (Raw Read accepted but the bare read yields nothing) is a FAIL,
/// not an Ok with vid: None.
/// Best-effort VID: Raw Read succeeded (drive unlocked), but the bare VID
/// read was rejected — `unlock()` still returns `true`, with `vid() == None`.
/// This is the fix: the old load-bearing VID aborted the whole unlock here,
/// dropping a genuinely-unlocked drive to the cert path.
#[test]
fn full_unlock_fails_when_no_vid() {
fn unlocks_without_vid_when_bare_read_fails() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
@@ -846,62 +677,35 @@ mod tests {
Reply::TransportFault,
);
let id = crate::DriveId::default();
let err = FreemkvUnlocker::new()
.unlock_features(&mut t, &ctx(&id))
.unwrap_err();
assert_eq!(err, UnlockError::VidUnavailable);
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("unlocked despite no VID");
assert_eq!(out.vid, None);
}
#[test]
fn unlock_features_not_applicable_on_non_freemkv_drive() {
fn declines_non_freemkv_drive() {
let mut t = MockTransport::always(Reply::illegal_request());
let id = crate::DriveId::default();
let err = FreemkvUnlocker::new()
.unlock_features(&mut t, &ctx(&id))
.unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
assert!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.is_none()
);
}
#[test]
fn unlock_features_transport_fault_propagates() {
fn transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let id = crate::DriveId::default();
let err = FreemkvUnlocker::new()
.unlock_features(&mut t, &ctx(&id))
.unwrap_err();
assert_eq!(err, UnlockError::Transport);
}
/// unlock_bus runs the same full sequence and reports the VID + unlocked.
#[test]
fn unlock_bus_runs_full_sequence_and_reports_unlocked() {
let vid = [0x9Au8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vid_ds_response(vid)),
],
Reply::TransportFault,
assert_eq!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.unwrap_err(),
UnlockError::Transport
);
let id = crate::DriveId::default();
let u = FreemkvUnlocker::new()
.unlock_bus(&mut t, &ctx(&id))
.expect("bus removed");
assert!(u.drive_unlocked);
assert_eq!(u.vid, Some(vid));
}
#[test]
fn unlock_bus_not_applicable_on_non_freemkv_drive() {
let mut t = MockTransport::always(Reply::illegal_request());
let id = crate::DriveId::default();
let err = FreemkvUnlocker::new()
.unlock_bus(&mut t, &ctx(&id))
.unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
}
// ── DumpAll (subfn 0x09) ─────────────────────────────────────────────
+2 -2
View File
@@ -1,7 +1,7 @@
//! Public unlock-CDB seam.
//!
//! These items expose the *minimum* an external consumer (e.g. the bdemu
//! drive emulator) needs to recognise and answer the Mt1959Unlocker unlock
//! drive emulator) needs to recognise and answer the LdUnlocker unlock
//! READ_BUFFER handshake, without that consumer open-coding the
//! handshake internals. The concrete CDB shapes and the verification
//! marker are unlock-handshake details and must live ONLY in this crate.
@@ -15,7 +15,7 @@ pub const UNLOCK_MARKER: &[u8] = b"MMkv";
/// Returns `true` when a READ_BUFFER (`0x3C`) CDB with the given mode
/// (`cdb[1] & 0x1F`) and buffer id (`cdb[2]`) is an unlock-handshake
/// read — i.e. one of the Mt1959Unlocker unlock variants.
/// read — i.e. one of the LdUnlocker unlock variants.
///
/// This is the single source of truth for the unlock READ_BUFFER CDB
/// shapes; consumers must call it rather than hardcoding the mode /
+60 -88
View File
@@ -46,14 +46,14 @@ pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
#[cfg(feature = "emulation")]
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
// The MT1959 unlocker, reached only through `crate::all_unlockers` (never by
// name). Matches a drive against the bundled profile database and runs the
// firmware-unlock + disc-speed-calibration handshake over raw SCSI.
pub(crate) struct Mt1959Unlocker;
// The MT1959 unlocker. Matches a drive against the bundled profile database and
// runs the firmware-unlock + disc-speed-calibration handshake over raw SCSI.
// Stateless: `unlock()` returns what it learned.
pub struct LdUnlocker;
impl Mt1959Unlocker {
pub(crate) fn new() -> Self {
Mt1959Unlocker
impl LdUnlocker {
pub fn new() -> Self {
LdUnlocker
}
}
@@ -61,10 +61,10 @@ impl Mt1959Unlocker {
/// 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(|_| "MT1959")
profile::find_bundled(id).map(|_| "LD")
}
impl Mt1959Unlocker {
impl LdUnlocker {
// Read the OEM Volume ID via the matched profile's vendor CDB (profile passed
// in to avoid a redundant 206-entry catalog scan). Ok(Some) on a well-formed
// response; Ok(None) if unreadable; Err only on a transport fault.
@@ -118,7 +118,7 @@ impl Mt1959Unlocker {
}
}
impl Mt1959Unlocker {
impl LdUnlocker {
// The MediaTek firmware unlock. Since it removes AACS at the drive (clear
// content), this one op satisfies both features and bus-removal, so both
// trait methods delegate here. See docs/ld-mod.md — firmware_unlock contract.
@@ -175,38 +175,26 @@ impl Mt1959Unlocker {
);
}
let vid = self.read_oem_vid(scsi, &m.profile)?;
Ok(Unlocked {
vid,
bus_key: None,
drive_unlocked: true,
})
Ok(Unlocked { vid, bus_key: None })
}
}
impl Unlocker for Mt1959Unlocker {
impl Unlocker for LdUnlocker {
fn name(&self) -> &'static str {
"MT1959"
"LD"
}
/// Mt1959Unlocker provides drive features (riplock/speed, OEM VID) — and, because
/// its firmware unlock serves clear content, bus removal comes free with it.
fn unlock_features(
/// Match the drive against the bundled profile database and run the firmware
/// unlock — one op that removes bus encryption at the drive (clear content)
/// and reads the OEM Volume ID (best-effort). `Some` when the firmware
/// handshake reached the extended-access state; `None` for an unknown drive
/// or an incomplete handshake; `Err(Transport)` on a dead bus.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
self.firmware_unlock(scsi, ctx)
}
/// Same firmware code as [`unlock_features`]: Mt1959Unlocker removes the bus at
/// the drive. In practice the consumer skips this because drive-prep already
/// set `drive_unlocked`; it's here for completeness / a bus-first call order.
fn unlock_bus(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
self.firmware_unlock(scsi, ctx)
) -> std::result::Result<Option<Unlocked>, UnlockError> {
crate::fallthrough(self.firmware_unlock(scsi, ctx))
}
}
@@ -219,7 +207,7 @@ mod tests {
/// 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, &[])
UnlockCtx::new(id, DiscKind::Unknown)
}
/// A fake transport that fills the response buffer from a fixed payload and
@@ -285,7 +273,7 @@ mod tests {
payload,
bytes_transferred: 36,
};
let got = Mt1959Unlocker::new()
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("parse ok");
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
@@ -298,7 +286,7 @@ mod tests {
payload: vec![0u8; 36],
bytes_transferred: 20,
};
let got = Mt1959Unlocker::new()
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("short response is Ok(None)");
assert_eq!(got, None);
@@ -313,7 +301,7 @@ mod tests {
payload,
bytes_transferred: 36,
};
let got = Mt1959Unlocker::new()
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("bad header is Ok(None)");
assert_eq!(got, None);
@@ -326,7 +314,7 @@ mod tests {
fn read_oem_vid_check_condition_is_none_not_a_vid() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
let got = Mt1959Unlocker::new()
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("a drive sense is not a transport fault");
assert_eq!(got, None, "a CHECK CONDITION must never yield a VID");
@@ -338,7 +326,7 @@ mod tests {
fn read_oem_vid_transport_fault_propagates() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let err = Mt1959Unlocker::new()
let err = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect_err("a dead bus must not be Ok(None)");
assert!(err.is_transport_failure());
@@ -354,7 +342,7 @@ mod tests {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let got = Mt1959Unlocker::new()
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &p)
.expect("no CDB is Ok(None)");
assert_eq!(got, None);
@@ -377,13 +365,13 @@ mod tests {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let err = Mt1959Unlocker::new()
.unlock_features(
let unlocked = LdUnlocker::new()
.unlock(
&mut t,
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
)
.expect_err("no profile → NotApplicable");
assert_eq!(err, UnlockError::NotApplicable);
.expect("no profile → declines, not a hard error");
assert!(unlocked.is_none());
}
// THE defect-1 test: response carries the signature + primary marker but not
@@ -405,17 +393,20 @@ mod tests {
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
let mut t = MockTransport::always(Reply::good(resp));
let err = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&id))
.expect_err("a half-unlocked drive must fall through to cert-auth");
assert_eq!(err, UnlockError::NotApplicable);
let unlocked = LdUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("a half-unlock declines, not a hard error");
assert!(
unlocked.is_none(),
"a half-unlocked drive must fall through to cert-auth"
);
}
/// The whole-unlock happy path still reports `drive_unlocked` when BOTH
/// firmware markers are present — the `is_unlocked()` gate must not have
/// made a genuinely unlocked drive fall through.
/// The whole-unlock happy path still reports unlocked when BOTH firmware
/// markers are present — the `is_unlocked()` gate must not have made a
/// genuinely unlocked drive fall through.
#[test]
fn fully_unlocked_drive_reports_drive_unlocked() {
fn fully_unlocked_drive_reports_unlocked() {
use crate::scsi::mock::{MockTransport, Reply};
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
@@ -429,10 +420,13 @@ mod tests {
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
let mut t = MockTransport::always(Reply::good(resp));
let u = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&id))
.expect("both markers → unlocked");
assert!(u.drive_unlocked);
assert!(
LdUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.is_some(),
"both markers → unlocked"
);
}
// THE probe-disc dead-bus test: bus dies during speed calibration after a
@@ -481,8 +475,8 @@ mod tests {
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
let mut t = ProbeFaultsDrive { resp };
let err = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&id))
let err = LdUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect_err("a dead bus during probe must abort, not report success");
assert_eq!(err, UnlockError::Transport);
}
@@ -532,34 +526,12 @@ mod tests {
}
}
let mut t = ProbeSenseDrive { resp };
let u = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&id))
.expect("a calibration miss must not fail the whole unlock");
assert!(u.drive_unlocked);
}
/// `unlock_bus` is the same firmware code as `unlock_features` (bus removal
/// comes free with the drive unlock) — pins that the trait method actually
/// delegates rather than being an accidental no-op stub.
#[test]
fn unlock_bus_delegates_to_the_same_firmware_unlock() {
use crate::scsi::mock::{MockTransport, Reply};
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
let mut t = MockTransport::always(Reply::good(resp));
let u = Mt1959Unlocker::new()
.unlock_bus(&mut t, &ctx(&id))
.expect("unlock_bus runs the same firmware handshake");
assert!(u.drive_unlocked);
assert!(
LdUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("a calibration miss must not fail the whole unlock")
.is_some()
);
}
/// Catches classifying a dead bus as "not this unlocker's drive": the very
@@ -569,8 +541,8 @@ mod tests {
fn transport_fault_during_unlock_is_transport_not_not_applicable() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let err = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&known_vid_drive_id()))
let err = LdUnlocker::new()
.unlock(&mut t, &ctx(&known_vid_drive_id()))
.expect_err("dead bus");
assert_eq!(err, UnlockError::Transport);
}
+2 -2
View File
@@ -1,4 +1,4 @@
//! Platform-specific drive unlock + disc probing (Mt1959Unlocker internals).
//! Platform-specific drive unlock + disc probing (LdUnlocker internals).
pub mod mt1959;
@@ -18,7 +18,7 @@ pub(crate) trait PlatformDriver: Send {
fn is_ready(&self) -> bool;
/// True if the drive is currently in the extended-access state. THE gate
/// `Mt1959Unlocker::firmware_unlock` reports `drive_unlocked` from — `init()`
/// `LdUnlocker::firmware_unlock` reports `drive_unlocked` from — `init()`
/// returning `Ok` only means the handshake completed, not that the drive
/// serves clear content.
fn is_unlocked(&self) -> bool {
+2 -2
View File
@@ -3,7 +3,7 @@
use crate::ld::error::{Error, Result};
use serde::Deserialize;
/// The Mt1959Unlocker profile catalog — the set of optical drives the firmware
/// The LdUnlocker 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)]
@@ -286,7 +286,7 @@ pub fn bundled() -> Option<&'static Profiles> {
target: "freemkv::disc",
phase = "bundled_profiles_parse_failed",
error_code = e.code(),
"bundled Mt1959Unlocker profile catalog failed to parse; no drive can match"
"bundled LdUnlocker profile catalog failed to parse; no drive can match"
);
None
}
+86 -108
View File
@@ -6,13 +6,16 @@
//!
//! 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 directly.
//! cert / CSS). The consumer (libfreemkv) assembles its own dispatch list from
//! the exported unlocker types and drives them all through the same trait.
pub mod scsi;
mod aacs;
mod css;
// Shared best-effort AACS Volume ID read, used by every route that opens the
// drive to raw reads (freemkv / MT1959 / Renesas). See `vid`.
mod vid;
// `ld` is public only for its drive-profile catalog + (under `emulation`) the
// handshake wire format bdemu needs; the unlocker impl stays `pub(crate)`.
// See docs/module-visibility.md — module visibility rationale.
@@ -27,6 +30,15 @@ mod freemkv;
use scsi::ScsiTransport;
// The five unlockers, exposed as concrete types so the consumer assembles its
// own dispatch list and injects each one's deps at construction (certs → AACS)
// — no central factory to thread another unlocker's config through.
pub use aacs::AacsUnlocker;
pub use css::DvdUnlocker;
pub use freemkv::FreemkvUnlocker;
pub use ld::LdUnlocker;
pub use renesas::Renesas;
/// 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)]
@@ -61,32 +73,31 @@ pub struct HostCert {
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.
/// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the
/// drive identity and the mounted disc's kind. These are the shared facts the
/// dispatch loop knows; anything an individual unlocker needs beyond them (the
/// AACS cert route's host certs) is injected into THAT unlocker at construction,
/// so the loop and the other unlockers never see it. See docs/module-visibility.md.
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,
}
pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self {
Self { drive_id, kind }
}
}
/// 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`.
/// What a successful unlock captured: the Volume ID (best-effort — may be `None`
/// even on success) and, for the cert route, the bus key the read path applies
/// to de-bus content. Returned inside the `Some` of [`Unlocker::unlock`]; there
/// is no `drive_unlocked` flag — "unlocked" is simply `unlock()` returning
/// `Some`, uniformly for every route.
#[derive(Clone, Default)]
pub struct Unlocked {
pub vid: Option<[u8; 16]>,
pub bus_key: Option<[u8; 16]>,
pub drive_unlocked: bool,
}
// Hand-written, REDACTING Debug: `bus_key`/`vid` are key material that must
@@ -97,7 +108,6 @@ impl std::fmt::Debug for Unlocked {
f.debug_struct("Unlocked")
.field("vid", &self.vid.map(|_| "[redacted]"))
.field("bus_key", &self.bus_key.map(|_| "[redacted]"))
.field("drive_unlocked", &self.drive_unlocked)
.finish()
}
}
@@ -120,47 +130,46 @@ pub enum UnlockError {
}
/// 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`].)
/// the self-contained modules in this crate; the consumer drives them all
/// through this trait. (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. "MT1959", "AACS",
/// "DVD", "Renesas"). The ONE place a name lives — apps render the unlocker
/// report from [`all_unlockers`], never hardcoding names, so adding/removing
/// Short, stable identifier for this unlocker (e.g. "freemkv", "LD",
/// "AACS", "DVD", "Renesas"). The ONE place a name lives — apps render the
/// unlocker report from `name()`, never hardcoding names, so adding/removing
/// an unlocker updates every report with no app change.
fn name(&self) -> &'static str;
/// Unlock DRIVE FEATURES — riplock/speed, OEM Volume ID, OEM extended-access
/// reads. The consumer runs this at drive-prep, trying each unlocker until
/// one handles the drive. `Ok(_)` = this unlocker handled it (Mt1959Unlocker also
/// removes bus encryption at the drive, so its result carries
/// `drive_unlocked: true`); `Err(NotApplicable)` = not this unlocker's drive;
/// `Err(Transport)` = dead bus (the consumer aborts). Default: not provided.
fn unlock_features(
/// Attempt to unlock the drive, whatever the mechanism (vendor CDB, profile
/// firmware handshake, AACS cert AKE, CSS auth):
/// `Ok(Some(unlocked))` = this one unlocked it (read `vid`/`bus_key`, stop);
/// `Ok(None)` = not this unlocker's drive (try the next); `Err(Transport)` =
/// dead bus (abort). Only a dead bus is `Err` — a missing VID, rejected
/// handshake, missing cert, or wrong disc kind all fall through as `Ok(None)`
/// (the VID inside `Unlocked` is best-effort). `&self`: stateless, returns
/// what it learned rather than stashing it.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
let _ = (scsi, ctx);
Err(UnlockError::NotApplicable)
}
) -> std::result::Result<Option<Unlocked>, UnlockError>;
}
/// Remove BUS ENCRYPTION for the mounted disc (AACS host-cert handshake, or
/// CSS scrambled-sector auth). The consumer runs this only when the bus isn't
/// already clear, trying each unlocker until one handles it. Same `Ok` /
/// `Err(NotApplicable)` / `Err(Transport)` contract as
/// [`Unlocker::unlock_features`].
/// Default: not provided.
fn unlock_bus(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
let _ = (scsi, ctx);
Err(UnlockError::NotApplicable)
/// The shared "only a dead bus is fatal" rule every unlocker uses to turn its
/// mechanism's `Result<Unlocked>` into the trait's outcome: success → `Some`, a
/// dead bus → `Err(Transport)`, any other failure → `None` (fall through to the
/// next unlocker). Written once here so the five unlockers stay identical in
/// error handling and differ only in their CDBs and checks.
pub(crate) fn fallthrough(
r: std::result::Result<Unlocked, UnlockError>,
) -> std::result::Result<Option<Unlocked>, UnlockError> {
match r {
Ok(u) => Ok(Some(u)),
Err(UnlockError::Transport) => Err(UnlockError::Transport),
Err(_) => Ok(None),
}
}
@@ -173,75 +182,46 @@ pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
ld::firmware_name(drive_id)
}
/// Every unlocker, in dispatch order (freemkv → 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.
///
/// `FreemkvUnlocker` runs FIRST: its detection is a single, unambiguous
/// self-identification probe (the drive's own firmware answers `"freemkv"`)
/// rather than a profile-catalog match, so it is the cheapest and most
/// certain check to run before falling back to the MT1959 profile lookup or
/// the Renesas/cert/css routes.
pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
vec![
Box::new(freemkv::FreemkvUnlocker::new()),
Box::new(ld::Mt1959Unlocker::new()),
Box::new(renesas::Renesas::new()),
Box::new(aacs::AacsCert::new()),
Box::new(css::DvdUnlocker::new()),
]
}
#[cfg(test)]
mod tests {
use super::*;
// Dispatch order is a documented contract (`firmware → cert → css`); the
// consumer stops at the first unlocker that doesn't decline, so reordering
// this list silently changes which unlocker claims a drive.
#[test]
fn all_unlockers_dispatch_order_is_firmware_then_cert_then_css() {
let names: Vec<&'static str> = all_unlockers().iter().map(|u| u.name()).collect();
assert_eq!(names, vec!["freemkv", "MT1959", "Renesas", "AACS", "DVD"]);
// The canonical dispatch order the consumer assembles (freemkv → firmware →
// Renesas → cert → css). It lives in the consumer now (no factory), but the
// types are ours, so pin their names here so a rename is caught crate-local.
fn canonical_unlockers() -> Vec<Box<dyn Unlocker>> {
vec![
Box::new(FreemkvUnlocker::new()),
Box::new(LdUnlocker::new()),
Box::new(Renesas::new()),
Box::new(AacsUnlocker::new(Vec::new())),
Box::new(DvdUnlocker::new()),
]
}
/// Every unlocker declines by default rather than claiming a capability it
/// does not provide — and declines WITHOUT touching the transport, so the
/// consumer can iterate the whole registry on any drive.
#[test]
fn unlockers_decline_capabilities_they_do_not_provide() {
struct DeadTransport;
impl scsi::ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> scsi::Result<scsi::ScsiResult> {
panic!("a declining unlocker must not issue a CDB");
fn unlocker_names_are_stable() {
let names: Vec<&'static str> = canonical_unlockers().iter().map(|u| u.name()).collect();
assert_eq!(names, vec!["freemkv", "LD", "Renesas", "AACS", "DVD"]);
}
}
let id = DriveId::default(); // matches no bundled profile
let ctx = UnlockCtx::new(&id, DiscKind::Unencrypted, &[]);
let mut t = DeadTransport;
for u in all_unlockers() {
// AACS/DVD provide no drive features; Mt1959Unlocker/Renesas are the
// feature unlockers but decline an unknown drive identity.
/// The uniform contract every unlocker obeys, whatever its mechanism: on a
/// DEAD BUS, `unlock()` either declined before touching it (`Ok(false)`) or
/// engaged it and reported the fault (`Err(Transport)`) — but NEVER claims a
/// dead bus as unlocked, and NEVER surfaces a non-`Transport` hard error.
/// This is the guardrail that was missing: it holds the next new unlocker to
/// the same discipline the loop relies on.
#[test]
fn no_unlocker_claims_a_dead_bus_or_hard_errors() {
let id = DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
for u in canonical_unlockers() {
let name = u.name();
if name == "AACS" || name == "DVD" {
assert_eq!(
u.unlock_features(&mut t, &ctx).unwrap_err(),
UnlockError::NotApplicable,
"{name} must not claim drive features"
);
}
if name == "Renesas" {
assert_eq!(
u.unlock_bus(&mut t, &ctx).unwrap_err(),
UnlockError::NotApplicable,
"{name} leaves bus removal to the cert unlocker"
);
let mut t = scsi::mock::MockTransport::always(scsi::mock::Reply::TransportFault);
match u.unlock(&mut t, &ctx) {
Ok(None) => {} // declined before touching — fine
Err(UnlockError::Transport) => {} // engaged, dead bus — fine
other => panic!("{name} violated the dead-bus contract: {other:?}"),
}
}
}
@@ -261,7 +241,6 @@ mod tests {
let u = Unlocked {
vid: Some([0xAB; 16]),
bus_key: Some([0xCD; 16]),
drive_unlocked: true,
};
let s = format!("{u:?}");
// A derived Debug renders `[171, 171, ...]` (0xAB) / `[205, ...]` (0xCD).
@@ -270,6 +249,5 @@ mod tests {
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
// Presence (Some/None) stays observable.
assert!(s.contains("Some"), "must still show a key WAS present: {s}");
assert!(s.contains("drive_unlocked: true"));
}
}
+52 -52
View File
@@ -57,12 +57,15 @@ pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, Unl
}
}
/// The Renesas-platform unlocker. `pub(crate)` — reached only through
/// [`crate::all_unlockers`].
pub(crate) struct Renesas;
/// The Renesas/Pioneer-platform unlocker. Its vendor "open" CDB puts the drive
/// in the same raw-read / extended-access state the firmware routes reach (this
/// is what MakeMKV's LibreDrive does on stock Pioneer firmware), so the Volume
/// ID is then read with the SAME standard `0xAD` reader as freemkv/MT1959 — no
/// host cert, no AKE. Stateless: `unlock()` returns what it learned.
pub struct Renesas;
impl Renesas {
pub(crate) fn new() -> Self {
pub fn new() -> Self {
Renesas
}
@@ -119,16 +122,19 @@ impl Unlocker for Renesas {
"Renesas"
}
// Gate on the `0xF1` SAT identity, then the vendor open read `0xB0@0x04`;
// `drive_unlocked: false` since AACS bus decryption is the cert's job.
// See docs/renesas-mod.md for the full MakeMKV-parity rationale.
fn unlock_features(
/// Gate on the `0xF1` SAT identity, then the vendor open read `0xB0@0x04`
/// (LOAD-BEARING — it's the unlock), then read the Volume ID with the shared
/// best-effort `0xAD` reader. `Some` once the open succeeds — the drive
/// serves clear content whether or not the bonus VID read did; `None` if it
/// isn't a Renesas drive or the open is refused; `Err(Transport)` on a dead
/// bus. See docs/renesas-mod.md for the MakeMKV-parity rationale.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
) -> std::result::Result<Option<Unlocked>, UnlockError> {
if !is_renesas(scsi)? {
return Err(UnlockError::NotApplicable);
return Ok(None);
}
if !Self::vendor_open(scsi)? {
tracing::debug!(
@@ -136,18 +142,19 @@ impl Unlocker for Renesas {
phase = "renesas_open_rejected",
"Renesas drive recognized but RB 0xB0@0x04 refused; deferring to next unlocker"
);
return Err(UnlockError::NotApplicable);
return Ok(None);
}
// The open enabled raw reads (extended-access) — read the VID exactly as
// the firmware routes do. Best-effort: a VID miss must not discard the
// unlock (only a dead bus propagates via `?`).
let vid = crate::vid::read_aacs_vid(scsi)?;
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_opened",
"Renesas drive opened (RB 0xB0@0x04 GOOD); bus handled by cert"
has_vid = vid.is_some(),
"Renesas drive opened (RB 0xB0@0x04 GOOD, raw reads enabled)"
);
Ok(Unlocked {
vid: None,
bus_key: None,
drive_unlocked: false,
})
Ok(Some(Unlocked { vid, bus_key: None }))
}
}
@@ -240,33 +247,35 @@ mod tests {
}
#[test]
fn features_report_match_without_bus_removal_on_renesas() {
fn opened_renesas_reports_unlocked_and_reads_vid() {
let mut t = RenesasTransport {
payload: renesas_payload(),
};
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
// Recognized → Ok, but a feature-only unlock: bus NOT removed, no VID.
let u = Renesas::new()
.unlock_features(&mut t, &ctx)
.expect("renesas → Ok");
assert!(
!u.drive_unlocked,
"renesas does not remove the bus (cert does)"
);
assert_eq!(u.vid, None);
assert_eq!(u.bus_key, None);
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
// Recognized + vendor-open GOOD → unlocked; the open enables raw reads,
// so the standard 0xAD VID read returns a (non-zero) VID best-effort.
let out = Renesas::new()
.unlock(&mut t, &ctx)
.expect("no fault")
.expect("renesas → unlocked");
assert!(out.vid.is_some(), "opened drive yields a VID via 0xAD");
assert_eq!(out.bus_key, None, "raw-read route needs no bus key");
}
/// A drive REJECTION (ILLEGAL REQUEST, with a sense) is "not a Renesas
/// drive" → fall through to the next unlocker.
/// drive" → `Ok(false)`, fall through to the next unlocker.
#[test]
fn features_not_applicable_on_non_renesas() {
fn declines_non_renesas() {
let mut t = RejectingTransport;
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
let err = Renesas::new().unlock_features(&mut t, &ctx).unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert!(
Renesas::new()
.unlock(&mut t, &ctx)
.expect("non-renesas declines")
.is_none()
);
}
// Same rejection via a CONFORMING transport (`Ok` + CHECK CONDITION);
@@ -289,9 +298,9 @@ mod tests {
let mut t = MockTransport::always(Reply::TransportFault);
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert_eq!(
Renesas::new().unlock_features(&mut t, &ctx).unwrap_err(),
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
}
@@ -333,22 +342,13 @@ mod tests {
}
let mut t = GateOkOpenRefused;
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
assert_eq!(
Renesas::new().unlock_features(&mut t, &ctx).unwrap_err(),
UnlockError::NotApplicable
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert!(
Renesas::new()
.unlock(&mut t, &ctx)
.expect("open refused → declines, not a hard error")
.is_none(),
"a refused vendor open must defer to the next unlocker"
);
}
#[test]
fn does_not_provide_bus_removal() {
// Renesas leaves bus encryption to the cert: unlock_bus is the default.
let mut t = RenesasTransport {
payload: renesas_payload(),
};
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &[]);
let err = Renesas::new().unlock_bus(&mut t, &ctx).unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Shared AACS Volume ID read — the standard `READ DISC STRUCTURE` (`0xAD`,
//! format `0x80`) that returns the VID on a drive whose host-auth / bus has
//! already been opened by a firmware or vendor CDB unlock (freemkv Raw Read,
//! MT1959, or a Pioneer/Renesas vendor open). Because those routes put the
//! drive in the same raw-read state, the VID read is identical across them — so
//! it lives here once.
//!
//! BEST-EFFORT: only a dead bus is an `Err(Transport)`. A CHECK CONDITION, a
//! short response, or an all-zero VID all yield `Ok(None)` — a VID miss must
//! never discard an unlock that already removed the bus (a key source can still
//! supply the key). See docs/freemkv-abi.md for the fixed-length reply rules.
use crate::UnlockError;
use crate::scsi::{DataDirection, ScsiTransport};
/// The 16-byte AACS Volume ID.
const VID_LEN: usize = 16;
/// `READ DISC STRUCTURE` format for the Volume ID.
const DISC_STRUCT_FMT_VID: u8 = 0x80;
/// Response length: a 4-byte header, the 16-byte VID, and a 16-byte MAC. On the
/// bare-read path (no bus key) the MAC can't be verified and is ignored.
const VID_STRUCT_LEN: u16 = 36;
/// The standard `0xAD` fmt-`0x80` (Blu-ray, AGID 0) Volume ID CDB — NOT a vendor
/// knock; valid once the drive's bus/host-auth is open. Mirrors libfreemkv's
/// `read_volume_id`.
pub(crate) fn build_vid_cdb() -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
cdb[1] = 0x01; // Blu-ray
cdb[7] = DISC_STRUCT_FMT_VID;
cdb[8] = (VID_STRUCT_LEN >> 8) as u8;
cdb[9] = (VID_STRUCT_LEN & 0xFF) as u8;
// cdb[10] = agid << 6; AGID 0 on the bare path (no AKE) → 0.
cdb
}
/// A senseless transport-failure status is a genuine dead bus, not a drive
/// rejection surfaced through a non-conforming transport (`Err` with a sense).
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
/// Read the AACS VID with the bare `0xAD` fmt `0x80` (valid only after the drive
/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for
/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on
/// a dead bus.
pub(crate) fn read_aacs_vid(
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<Option<[u8; 16]>, UnlockError> {
let cdb = build_vid_cdb();
let mut buf = [0u8; VID_STRUCT_LEN as usize];
let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => r,
Err(e) => {
if is_dead_bus(&e) {
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "vid_rejected_as_err",
status = e.status,
"bare VID read rejected (via Err); no Volume ID"
);
return Ok(None);
}
};
// A drive sense arrives as Ok with a non-zero status; without this check a
// CHECK CONDITION's zero-filled buffer would parse as a VID.
if result.status != 0 {
tracing::debug!(
target: "freemkv::disc",
phase = "vid_check_condition",
status = result.status,
"bare VID read returned a drive sense (no medium / drive not open)"
);
return Ok(None);
}
// Need the 4-byte header + the 16-byte VID.
if result.bytes_transferred < 4 + VID_LEN {
tracing::debug!(
target: "freemkv::disc",
phase = "vid_short_response",
bytes_transferred = result.bytes_transferred,
"bare VID response too short"
);
return Ok(None);
}
let mut vid = [0u8; VID_LEN];
vid.copy_from_slice(&buf[4..4 + VID_LEN]);
// An all-zero VID is what a permissive stub or an unfilled response leaves
// behind — reject it rather than pass a bogus key downstream.
if vid.iter().all(|&b| b == 0) {
tracing::debug!(
target: "freemkv::disc",
phase = "vid_all_zero",
"bare VID read returned an all-zero Volume ID"
);
return Ok(None);
}
tracing::debug!(target: "freemkv::disc", phase = "vid_ok", "Volume ID retrieved via bare 0xAD read");
Ok(Some(vid))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked).
fn vid_ds_response(vid: [u8; 16]) -> Vec<u8> {
let mut p = vec![0u8; VID_STRUCT_LEN as usize];
p[4..20].copy_from_slice(&vid);
p
}
/// The bare VID read is the STANDARD 0xAD READ DISC STRUCTURE (format 0x80,
/// Blu-ray, AGID 0, len 36) — NOT a vendor knock.
#[test]
fn build_vid_cdb_is_standard_0xad_fmt_80() {
let cdb = build_vid_cdb();
assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
assert_eq!(cdb[1], 0x01); // Blu-ray
assert_eq!(cdb[7], 0x80); // AACS Volume ID
assert_eq!([cdb[8], cdb[9]], [0x00, 0x24]); // 36, BE16
assert_eq!(cdb[10], 0x00); // AGID 0 (no AKE)
}
/// Parses the VID from response[4..20] and issues exactly one 0xAD read.
#[test]
fn reads_and_parses_offset_4() {
let vid = [0x5Au8; 16];
let mut t = MockTransport::always(Reply::good(vid_ds_response(vid)));
let got = read_aacs_vid(&mut t).expect("no fault");
assert_eq!(got, Some(vid));
assert_eq!(t.cdbs.len(), 1, "exactly one command");
assert_eq!(t.cdbs[0][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
assert_eq!(t.cdbs[0][7], 0x80);
}
/// The MAC region (bytes 20..36) is ignored on the bare path.
#[test]
fn ignores_the_unverifiable_mac() {
let vid: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
];
let mut t = MockTransport::always(Reply::good(vid_ds_response(vid)));
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), Some(vid));
}
/// A response too short to carry header + VID → best-effort `Ok(None)`.
#[test]
fn short_response_is_none() {
let mut t = MockTransport::always(Reply::short(vid_ds_response([0x11; 16]), 19));
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
}
/// A CHECK CONDITION (no medium / drive not open) → `Ok(None)`, never a VID
/// parsed from the zero fill.
#[test]
fn check_condition_is_none() {
let mut t = MockTransport::always(Reply::illegal_request());
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
}
/// An all-zero VID (permissive stub / unfilled response) → `Ok(None)`.
#[test]
fn all_zero_is_none() {
let mut t = MockTransport::always(Reply::good(vid_ds_response([0u8; 16])));
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
}
/// Only a dead bus is an error.
#[test]
fn transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(read_aacs_vid(&mut t).unwrap_err(), UnlockError::Transport);
}
}