mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
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:
+60
-70
@@ -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 {
|
||||
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,
|
||||
})
|
||||
crate::fallthrough(
|
||||
handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| Unlocked {
|
||||
vid: Some(h.volume_id),
|
||||
bus_key: h.read_data_key,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+87
-109
@@ -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");
|
||||
}
|
||||
}
|
||||
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.
|
||||
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"]);
|
||||
}
|
||||
|
||||
/// 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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user