Compare commits

..
8 Commits
36 changed files with 8910 additions and 8885 deletions
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [1.8.2] — Unreleased
### Changed
- Released with freemkv 1.8.2 so every component reports the same version. No changes.
## [1.8.1] — 2026-10-07
### Fixed
- Renesas drives: more reliable VID reading from the drive, including drives with an optional VID slot, and a hardened OEM unlock.
### Maintenance
- `pioneer-optical` 0.10 (the 0.7 line is yanked). The vendor CDB bytes are unchanged, and a test pins them.
## [1.8.0] — 2026-10-05
### Changed
Generated
+3 -15
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.7"
version = "1.8.0"
dependencies = [
"aes",
"base64",
@@ -210,9 +210,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pioneer-optical"
version = "0.7.2"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48a5bdb9e9a07422ceb5133d227fbe4ecce77060007e5a9bac0dda3d82d72911"
checksum = "bf5074a27cc39fb664d0b994814ffe8530e3e8590aff0d104723332dbcb1abc4"
[[package]]
name = "proc-macro2"
@@ -411,18 +411,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.7"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.7"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.7"
[[patch.unused]]
name = "libfreemkv"
version = "1.7.7"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-unlock"
version = "1.8.0"
version = "1.8.1"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -31,7 +31,7 @@ rand = "0.10"
# the redacting Debug impls). `derive` gives ZeroizeOnDrop for the key structs.
zeroize = { version = "1", features = ["derive"] }
# Pioneer/Renesas vendor CDB constructors (identity read, gated memory read, knock).
pioneer-optical = "0.7"
pioneer-optical = "0.10"
[lints.clippy]
uninlined_format_args = "allow"
+2 -140
View File
@@ -106,143 +106,5 @@ impl From<crate::scsi::ScsiError> for Error {
}
#[cfg(test)]
mod tests {
use super::*;
/// Every variant has its own stable numeric code. Constructing all 17
/// arms also exercises the `#[allow(dead_code)]` variants that the
/// wired handshake path never builds.
#[test]
fn code_is_stable_per_variant() {
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
assert_eq!(Error::AacsCertRead.code(), 7002);
assert_eq!(Error::AacsCertRejected.code(), 7003);
assert_eq!(Error::AacsCertShort.code(), 7004);
assert_eq!(Error::AacsCertVerify.code(), 7005);
assert_eq!(Error::AacsDataKey.code(), 7006);
assert_eq!(Error::AacsKeyRead.code(), 7007);
assert_eq!(Error::AacsKeyRejected.code(), 7008);
assert_eq!(Error::AacsKeyVerify.code(), 7009);
assert_eq!(Error::AacsHostSignVerify.code(), 7016);
assert_eq!(Error::AacsNoKeys.code(), 7010);
assert_eq!(Error::AacsVidMac.code(), 7011);
assert_eq!(Error::AacsVidRead.code(), 7012);
assert_eq!(Error::HandshakeRejected.code(), 7013);
assert_eq!(Error::VidUnavailable.code(), 7014);
assert_eq!(
Error::ShortTransfer {
opcode: 0xA4,
expected: 16,
got: 4,
}
.code(),
7015
);
assert_eq!(
Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: None,
}
.code(),
7099
);
}
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
/// variant.
#[test]
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
let sense = ScsiSense {
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
};
let with_sense = Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: Some(sense),
};
let sc = with_sense.scsi_sense().unwrap();
assert_eq!(sc.sense_key, sense.sense_key);
assert_eq!(sc.asc, sense.asc);
assert_eq!(sc.ascq, sense.ascq);
let without_sense = Error::Scsi {
opcode: 0xA4,
status: 0xFF,
sense: None,
};
assert!(without_sense.scsi_sense().is_none());
assert!(Error::AacsNoKeys.scsi_sense().is_none());
}
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
/// transport-failure status AND no sense; a drive CHECK CONDITION and
/// every non-`Scsi` variant are false.
#[test]
fn is_scsi_transport_failure_true_and_false_paths() {
let transport = Error::Scsi {
opcode: 0,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(transport.is_scsi_transport_failure());
let check_condition = Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: Some(ScsiSense {
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}),
};
assert!(!check_condition.is_scsi_transport_failure());
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
}
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
/// parsing the sense buffer when present and leaving it `None` otherwise.
#[test]
fn from_scsi_error_maps_status_and_parses_sense() {
let e = Error::from(crate::scsi::ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
match e {
Error::Scsi {
opcode,
status,
sense,
} => {
assert_eq!(opcode, 0);
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
assert!(sense.is_none());
}
_ => panic!("expected Error::Scsi"),
}
let mut buf = [0u8; 32];
buf[2] = 0x05;
buf[12] = 0x24;
buf[13] = 0x01;
let e = Error::from(crate::scsi::ScsiError {
status: 0x02,
sense: Some(buf),
});
match e {
Error::Scsi { status, sense, .. } => {
assert_eq!(status, 0x02);
let s = sense.expect("sense parsed");
assert_eq!(s.sense_key, 0x05);
assert_eq!(s.asc, 0x24);
assert_eq!(s.ascq, 0x01);
}
_ => panic!("expected Error::Scsi"),
}
}
}
#[path = "error_tests.rs"]
mod tests;
+138
View File
@@ -0,0 +1,138 @@
use super::*;
/// Every variant has its own stable numeric code. Constructing all 17
/// arms also exercises the `#[allow(dead_code)]` variants that the
/// wired handshake path never builds.
#[test]
fn code_is_stable_per_variant() {
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
assert_eq!(Error::AacsCertRead.code(), 7002);
assert_eq!(Error::AacsCertRejected.code(), 7003);
assert_eq!(Error::AacsCertShort.code(), 7004);
assert_eq!(Error::AacsCertVerify.code(), 7005);
assert_eq!(Error::AacsDataKey.code(), 7006);
assert_eq!(Error::AacsKeyRead.code(), 7007);
assert_eq!(Error::AacsKeyRejected.code(), 7008);
assert_eq!(Error::AacsKeyVerify.code(), 7009);
assert_eq!(Error::AacsHostSignVerify.code(), 7016);
assert_eq!(Error::AacsNoKeys.code(), 7010);
assert_eq!(Error::AacsVidMac.code(), 7011);
assert_eq!(Error::AacsVidRead.code(), 7012);
assert_eq!(Error::HandshakeRejected.code(), 7013);
assert_eq!(Error::VidUnavailable.code(), 7014);
assert_eq!(
Error::ShortTransfer {
opcode: 0xA4,
expected: 16,
got: 4,
}
.code(),
7015
);
assert_eq!(
Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: None,
}
.code(),
7099
);
}
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
/// variant.
#[test]
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
let sense = ScsiSense {
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
};
let with_sense = Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: Some(sense),
};
let sc = with_sense.scsi_sense().unwrap();
assert_eq!(sc.sense_key, sense.sense_key);
assert_eq!(sc.asc, sense.asc);
assert_eq!(sc.ascq, sense.ascq);
let without_sense = Error::Scsi {
opcode: 0xA4,
status: 0xFF,
sense: None,
};
assert!(without_sense.scsi_sense().is_none());
assert!(Error::AacsNoKeys.scsi_sense().is_none());
}
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
/// transport-failure status AND no sense; a drive CHECK CONDITION and
/// every non-`Scsi` variant are false.
#[test]
fn is_scsi_transport_failure_true_and_false_paths() {
let transport = Error::Scsi {
opcode: 0,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(transport.is_scsi_transport_failure());
let check_condition = Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: Some(ScsiSense {
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}),
};
assert!(!check_condition.is_scsi_transport_failure());
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
}
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
/// parsing the sense buffer when present and leaving it `None` otherwise.
#[test]
fn from_scsi_error_maps_status_and_parses_sense() {
let e = Error::from(crate::scsi::ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
match e {
Error::Scsi {
opcode,
status,
sense,
} => {
assert_eq!(opcode, 0);
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
assert!(sense.is_none());
}
_ => panic!("expected Error::Scsi"),
}
let mut buf = [0u8; 32];
buf[2] = 0x05;
buf[12] = 0x24;
buf[13] = 0x01;
let e = Error::from(crate::scsi::ScsiError {
status: 0x02,
sense: Some(buf),
});
match e {
Error::Scsi { status, sense, .. } => {
assert_eq!(status, 0x02);
let s = sense.expect("sense parsed");
assert_eq!(s.sense_key, 0x05);
assert_eq!(s.asc, 0x24);
assert_eq!(s.ascq, 0x01);
}
_ => panic!("expected Error::Scsi"),
}
}
+2 -3378
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -462
View File
@@ -200,465 +200,5 @@ impl Unlocker for AacsUnlocker {
}
#[cfg(test)]
mod tests {
use super::*;
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
/// test vector: decrypting the published ciphertext with the published key
/// must recover the published plaintext.
#[test]
fn aes_ecb_decrypt_matches_fips197_test_vector() {
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
0x0E, 0x0F,
];
let ciphertext: [u8; 16] = [
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
0xC5, 0x5A,
];
let expected_plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
];
let out = aes_ecb_decrypt(&key, &ciphertext);
assert_eq!(out, expected_plaintext);
}
/// Decrypting a different ciphertext under the same key must not produce
/// the same plaintext — pins that the function actually decrypts the
/// given block rather than returning a constant.
#[test]
fn aes_ecb_decrypt_varies_with_input() {
let key = [0u8; 16];
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
assert_ne!(a, b);
}
fn id() -> crate::DriveId {
crate::DriveId::default()
}
/// 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 declines_non_aacs_kinds() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched on a non-AACS disc");
}
}
let id = id();
let mut t = DeadTransport;
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
assert!(
AacsUnlocker::new(vec![host_cert()])
.unlock(&mut t, &UnlockCtx::new(&id, k))
.expect("declines")
.is_none(),
"declines {k:?}"
);
}
}
// 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]
fn unlock_succeeds_end_to_end() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_data_keys = true;
let (lax, lay) = (t.la_x, t.la_y);
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.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());
}
// The `read_data_key_dropped` path: auth + VID succeed but the drive serves
// no bus key (non-transport). `unlock` still reports Unlocked (unlocked via
// the cert) with `vid: Some` but `bus_key: None` — surfaced, not hidden.
#[test]
fn unlock_without_a_served_read_data_key_yields_vid_but_no_bus_key() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_zero_data_keys = true; // GOOD status, all-zero key block
let (lax, lay) = (t.la_x, t.la_y);
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.unlock(&mut t, &ctx)
.expect("auth + VID succeed even when the bus key is refused")
.expect("the cert route still unlocks the drive");
assert_eq!(out.vid, Some([0x5Au8; 16]), "the VID was learned");
assert!(
out.bus_key.is_none(),
"no read_data_key served ⇒ bus_key: None (reported, not faked)"
);
}
fn host_cert() -> crate::HostCert {
handshake::tests::dummy_cert()
}
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
/// The default (no arm opted-in) NEVER touches the transport for arming.
#[test]
fn no_arm_by_default_does_not_probe_firmware() {
struct PanicTransport;
impl ScsiTransport for PanicTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("default AacsUnlocker must not issue a firmware knock");
}
}
let u = AacsUnlocker::new(vec![host_cert()]);
assert!(u.arm.is_none());
u.maybe_arm(&mut PanicTransport).expect("no-op");
}
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
/// the drive untouched — no recipe SETs are sent.
#[test]
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
assert_eq!(t.cdbs.len(), 1);
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
}
/// A dead bus on the arm probe aborts the whole unlock.
#[test]
fn arm_probe_transport_fault_aborts() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
}
/// A freemkv fw front-end: answers the vendor knock (IDENTITY / SET / GET)
/// like fw `version` does, and delegates every other CDB to `inner`.
struct FwFront<T> {
inner: T,
version: &'static str,
/// Feature flag table indexed by wire id (slot 0 unused).
flags: [u8; 7],
refuse_sets: bool,
dead_after_identity: bool,
cdbs: Vec<Vec<u8>>,
}
impl<T> FwFront<T> {
fn new(inner: T) -> Self {
FwFront {
inner,
version: "0.9.2",
flags: [crate::firmware::STATE_PASSTHROUGH; 7],
refuse_sets: false,
dead_after_identity: false,
cdbs: Vec::new(),
}
}
/// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued.
fn ran_cert_ake(&self) -> bool {
self.cdbs
.iter()
.any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY)
}
fn sent_a_set(&self) -> bool {
use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb};
self.cdbs
.iter()
.any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8)
}
}
impl<T: ScsiTransport> ScsiTransport for FwFront<T> {
fn execute(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
use crate::firmware::{CDB_FEATURE, CDB_STATE, CDB_VERB, READ_BUFFER_OPCODE, Verb};
self.cdbs.push(cdb.to_vec());
if cdb[0] != READ_BUFFER_OPCODE {
return self.inner.execute(cdb, dir, data, timeout_ms);
}
let status = |status: u8, n: usize| {
Ok(crate::scsi::ScsiResult {
status,
bytes_transferred: n,
sense: [0u8; 32],
})
};
let verb = cdb[CDB_VERB];
let f = (cdb[CDB_FEATURE] as usize).min(6);
if verb == Verb::Identity as u8 {
let mut flags = [0u8; 6];
flags.copy_from_slice(&self.flags[1..]);
let mut p = crate::firmware::identity_reply(self.version, flags);
p.resize(data.len(), 0);
data.copy_from_slice(&p[..data.len()]);
return status(0, data.len());
}
if self.dead_after_identity {
return Err(crate::scsi::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
if verb == Verb::Set as u8 {
if self.refuse_sets {
return status(crate::scsi::SCSI_STATUS_CHECK_CONDITION, 0);
}
self.flags[f] = cdb[CDB_STATE];
} else if verb == Verb::Get as u8 {
data.fill(0);
data[0] = self.flags[f];
}
status(0, data.len())
}
// Forward the Stop methods (stop-design-v5 §2.3): the defaults would
// swallow them, so wrapped tests would stop exercising cancellation.
fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> {
self.inner.pause(d)
}
fn begin_critical(&mut self) -> crate::scsi::Result<()> {
self.inner.begin_critical()
}
fn end_critical(&mut self) {
self.inner.end_critical()
}
fn execute_cleanup(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
self.cdbs.push(cdb.to_vec());
self.inner.execute_cleanup(cdb, dir, data, timeout_ms)
}
}
// UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront<T>` … forwards all
// four"): otherwise every FwFront-wrapped test silently stops exercising Stop.
#[test]
fn fwfront_forwards_new_methods() {
use crate::scsi::DataDirection;
use crate::scsi::mock::{Ev, MockTransport, Reply, StopFake};
let inner = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 2])));
let mut t = FwFront::new(inner);
let d = std::time::Duration::from_millis(1);
t.pause(d).expect("forwarded pause");
t.begin_critical().expect("forwarded begin");
t.end_critical();
let rel = [0xA4, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0x3F, 0];
t.execute_cleanup(&rel, DataDirection::FromDevice, &mut [0u8; 2], 5_000)
.expect("forwarded cleanup");
assert_eq!(
t.inner.log,
vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())],
"all four reach the wrapped transport"
);
t.inner.cancelled = true;
assert!(t.pause(d).is_err(), "a cancel reaches through the wrapper");
assert!(t.begin_critical().is_err());
}
fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport {
use crate::scsi::mock::{MockTransport, Reply};
let mut p = vec![0u8; 36];
p[4..20].copy_from_slice(&vid);
MockTransport::always(Reply::good(p))
}
fn aacs_ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Aacs)
}
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
/// Set(Hrl, off) and Set(Encryption, off), each verified, after the
/// IDENTITY gate and BEFORE the handshake would run.
#[test]
fn arm_on_freemkv_drive_applies_the_recipe() {
use crate::firmware::{CDB_VERB, Feature, STATE_OFF, Verb, build_set_cdb};
let mut t = FwFront::new(vid_reply([0x42; 16]));
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
assert!(u.maybe_arm(&mut t).expect("armed"));
assert_eq!(t.flags[Feature::Hrl as usize], STATE_OFF);
assert_eq!(t.flags[Feature::Encryption as usize], STATE_OFF);
assert_eq!(t.cdbs.len(), 5);
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
}
/// Regression: an Encryption=off recipe needs NO host cert. With an empty
/// cert list the drive is still armed and the unlock is the bare VID read —
/// no cert AKE, no bus key (content already comes back de-bussed).
#[test]
fn encryption_off_recipes_unlock_without_certs_via_the_bare_vid() {
for recipe in [ArmRecipe::BypassBd, ArmRecipe::BypassUhd, ArmRecipe::OemUhd] {
let mut t = FwFront::new(vid_reply([0x42; 16]));
let id = id();
let out = AacsUnlocker::new(vec![])
.arm_before_unlock(recipe)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.unwrap_or_else(|| panic!("{recipe:?}: armed drive is unlocked"));
assert_eq!(out.vid, Some([0x42; 16]), "{recipe:?}");
assert_eq!(out.bus_key, None, "{recipe:?}: no double bus-decrypt");
assert!(!t.ran_cert_ake(), "{recipe:?}");
}
}
/// Armed Encryption=off, then the bare VID read: a dead bus there aborts
/// with Transport; a drive rejection is still an unlock, just without a VID.
#[test]
fn encryption_off_arm_then_bare_vid_failure() {
use crate::scsi::mock::{MockTransport, Reply};
for (reply, want) in [
(Reply::TransportFault, Err(UnlockError::Transport)),
(Reply::illegal_request(), Ok(Some((None, None)))),
(Reply::illegal_request_as_err(), Ok(Some((None, None)))),
] {
let mut t = FwFront::new(MockTransport::always(reply));
let id = id();
let got = AacsUnlocker::new(vec![host_cert()])
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.map(|o| o.map(|u| (u.vid, u.bus_key)));
assert_eq!(got, want);
assert!(t.sent_a_set(), "the drive was armed first");
assert!(!t.ran_cert_ake());
}
}
/// Regression: with certs present, an Encryption=off arm still skips the
/// cert AKE and never reports a bus key.
#[test]
fn encryption_off_recipe_with_certs_skips_the_cert_ake() {
let mut t = FwFront::new(vid_reply([0x42; 16]));
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("unlocked");
assert_eq!(out.bus_key, None);
assert!(!t.ran_cert_ake());
}
/// A refused recipe (and OemBd, which leaves encryption on) proceeds to the
/// plain cert AKE, which still unlocks the drive and learns the bus key.
#[test]
fn refused_or_cert_recipes_run_the_cert_ake() {
for (recipe, refuse) in [(ArmRecipe::BypassBd, true), (ArmRecipe::OemBd, false)] {
let mut emu = handshake::tests::DriveEmu::new();
emu.serve_data_keys = true;
let (lax, lay) = (emu.la_x, emu.la_y);
let mut t = FwFront::new(emu);
t.refuse_sets = refuse;
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.arm_before_unlock(recipe)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("the cert route unlocks");
assert_eq!(out.vid, Some([0x5Au8; 16]), "{recipe:?}");
assert!(out.bus_key.is_some(), "{recipe:?}");
assert!(t.ran_cert_ake(), "{recipe:?}");
}
}
/// Regression: fw 0.8.x is below MIN_FW_VERSION, so the recipe is never
/// sent and the plain cert route runs.
#[test]
fn unsupported_firmware_is_not_armed() {
let mut emu = handshake::tests::DriveEmu::new();
emu.serve_data_keys = true;
let (lax, lay) = (emu.la_x, emu.la_y);
let mut t = FwFront::new(emu);
t.version = "0.8.3";
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("the cert route unlocks");
assert!(out.bus_key.is_some());
assert!(!t.sent_a_set(), "no recipe SET on unsupported firmware");
}
/// A dead bus mid-arm aborts the whole unlock, with or without certs.
#[test]
fn dead_bus_during_arm_aborts_the_unlock() {
for certs in [vec![host_cert()], vec![]] {
let mut t = FwFront::new(vid_reply([0x42; 16]));
t.dead_after_identity = true;
let id = id();
let n = certs.len();
let err = AacsUnlocker::new(certs)
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.unwrap_err();
assert_eq!(err, UnlockError::Transport, "{n} certs");
assert!(!t.ran_cert_ake());
}
}
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
/// and the transport is never touched.
#[test]
fn no_host_certs_declines() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched with no host certs");
}
}
let id = id();
let mut t = DeadTransport;
assert!(
AacsUnlocker::new(vec![])
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
.expect("no certs declines")
.is_none()
);
}
}
#[path = "mod_tests.rs"]
mod tests;
+460
View File
@@ -0,0 +1,460 @@
use super::*;
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
/// test vector: decrypting the published ciphertext with the published key
/// must recover the published plaintext.
#[test]
fn aes_ecb_decrypt_matches_fips197_test_vector() {
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
0x0F,
];
let ciphertext: [u8; 16] = [
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4, 0xC5,
0x5A,
];
let expected_plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF,
];
let out = aes_ecb_decrypt(&key, &ciphertext);
assert_eq!(out, expected_plaintext);
}
/// Decrypting a different ciphertext under the same key must not produce
/// the same plaintext — pins that the function actually decrypts the
/// given block rather than returning a constant.
#[test]
fn aes_ecb_decrypt_varies_with_input() {
let key = [0u8; 16];
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
assert_ne!(a, b);
}
fn id() -> crate::DriveId {
crate::DriveId::default()
}
/// 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 declines_non_aacs_kinds() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched on a non-AACS disc");
}
}
let id = id();
let mut t = DeadTransport;
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
assert!(
AacsUnlocker::new(vec![host_cert()])
.unlock(&mut t, &UnlockCtx::new(&id, k))
.expect("declines")
.is_none(),
"declines {k:?}"
);
}
}
// 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]
fn unlock_succeeds_end_to_end() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_data_keys = true;
let (lax, lay) = (t.la_x, t.la_y);
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.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());
}
// The `read_data_key_dropped` path: auth + VID succeed but the drive serves
// no bus key (non-transport). `unlock` still reports Unlocked (unlocked via
// the cert) with `vid: Some` but `bus_key: None` — surfaced, not hidden.
#[test]
fn unlock_without_a_served_read_data_key_yields_vid_but_no_bus_key() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_zero_data_keys = true; // GOOD status, all-zero key block
let (lax, lay) = (t.la_x, t.la_y);
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.unlock(&mut t, &ctx)
.expect("auth + VID succeed even when the bus key is refused")
.expect("the cert route still unlocks the drive");
assert_eq!(out.vid, Some([0x5Au8; 16]), "the VID was learned");
assert!(
out.bus_key.is_none(),
"no read_data_key served ⇒ bus_key: None (reported, not faked)"
);
}
fn host_cert() -> crate::HostCert {
handshake::tests::dummy_cert()
}
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
/// The default (no arm opted-in) NEVER touches the transport for arming.
#[test]
fn no_arm_by_default_does_not_probe_firmware() {
struct PanicTransport;
impl ScsiTransport for PanicTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("default AacsUnlocker must not issue a firmware knock");
}
}
let u = AacsUnlocker::new(vec![host_cert()]);
assert!(u.arm.is_none());
u.maybe_arm(&mut PanicTransport).expect("no-op");
}
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
/// the drive untouched — no recipe SETs are sent.
#[test]
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
assert_eq!(t.cdbs.len(), 1);
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
}
/// A dead bus on the arm probe aborts the whole unlock.
#[test]
fn arm_probe_transport_fault_aborts() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
}
/// A freemkv fw front-end: answers the vendor knock (IDENTITY / SET / GET)
/// like fw `version` does, and delegates every other CDB to `inner`.
struct FwFront<T> {
inner: T,
version: &'static str,
/// Feature flag table indexed by wire id (slot 0 unused).
flags: [u8; 7],
refuse_sets: bool,
dead_after_identity: bool,
cdbs: Vec<Vec<u8>>,
}
impl<T> FwFront<T> {
fn new(inner: T) -> Self {
FwFront {
inner,
version: "0.9.2",
flags: [crate::firmware::STATE_PASSTHROUGH; 7],
refuse_sets: false,
dead_after_identity: false,
cdbs: Vec::new(),
}
}
/// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued.
fn ran_cert_ake(&self) -> bool {
self.cdbs
.iter()
.any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY)
}
fn sent_a_set(&self) -> bool {
use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb};
self.cdbs
.iter()
.any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8)
}
}
impl<T: ScsiTransport> ScsiTransport for FwFront<T> {
fn execute(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
use crate::firmware::{CDB_FEATURE, CDB_STATE, CDB_VERB, READ_BUFFER_OPCODE, Verb};
self.cdbs.push(cdb.to_vec());
if cdb[0] != READ_BUFFER_OPCODE {
return self.inner.execute(cdb, dir, data, timeout_ms);
}
let status = |status: u8, n: usize| {
Ok(crate::scsi::ScsiResult {
status,
bytes_transferred: n,
sense: [0u8; 32],
})
};
let verb = cdb[CDB_VERB];
let f = (cdb[CDB_FEATURE] as usize).min(6);
if verb == Verb::Identity as u8 {
let mut flags = [0u8; 6];
flags.copy_from_slice(&self.flags[1..]);
let mut p = crate::firmware::identity_reply(self.version, flags);
p.resize(data.len(), 0);
data.copy_from_slice(&p[..data.len()]);
return status(0, data.len());
}
if self.dead_after_identity {
return Err(crate::scsi::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
if verb == Verb::Set as u8 {
if self.refuse_sets {
return status(crate::scsi::SCSI_STATUS_CHECK_CONDITION, 0);
}
self.flags[f] = cdb[CDB_STATE];
} else if verb == Verb::Get as u8 {
data.fill(0);
data[0] = self.flags[f];
}
status(0, data.len())
}
// Forward the Stop methods (stop-design-v5 §2.3): the defaults would
// swallow them, so wrapped tests would stop exercising cancellation.
fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> {
self.inner.pause(d)
}
fn begin_critical(&mut self) -> crate::scsi::Result<()> {
self.inner.begin_critical()
}
fn end_critical(&mut self) {
self.inner.end_critical()
}
fn execute_cleanup(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
self.cdbs.push(cdb.to_vec());
self.inner.execute_cleanup(cdb, dir, data, timeout_ms)
}
}
// UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront<T>` … forwards all
// four"): otherwise every FwFront-wrapped test silently stops exercising Stop.
#[test]
fn fwfront_forwards_new_methods() {
use crate::scsi::DataDirection;
use crate::scsi::mock::{Ev, MockTransport, Reply, StopFake};
let inner = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 2])));
let mut t = FwFront::new(inner);
let d = std::time::Duration::from_millis(1);
t.pause(d).expect("forwarded pause");
t.begin_critical().expect("forwarded begin");
t.end_critical();
let rel = [0xA4, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0x3F, 0];
t.execute_cleanup(&rel, DataDirection::FromDevice, &mut [0u8; 2], 5_000)
.expect("forwarded cleanup");
assert_eq!(
t.inner.log,
vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())],
"all four reach the wrapped transport"
);
t.inner.cancelled = true;
assert!(t.pause(d).is_err(), "a cancel reaches through the wrapper");
assert!(t.begin_critical().is_err());
}
fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport {
use crate::scsi::mock::{MockTransport, Reply};
let mut p = vec![0u8; 36];
p[4..20].copy_from_slice(&vid);
MockTransport::always(Reply::good(p))
}
fn aacs_ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Aacs)
}
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
/// Set(Hrl, off) and Set(Encryption, off), each verified, after the
/// IDENTITY gate and BEFORE the handshake would run.
#[test]
fn arm_on_freemkv_drive_applies_the_recipe() {
use crate::firmware::{CDB_VERB, Feature, STATE_OFF, Verb, build_set_cdb};
let mut t = FwFront::new(vid_reply([0x42; 16]));
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
assert!(u.maybe_arm(&mut t).expect("armed"));
assert_eq!(t.flags[Feature::Hrl as usize], STATE_OFF);
assert_eq!(t.flags[Feature::Encryption as usize], STATE_OFF);
assert_eq!(t.cdbs.len(), 5);
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
}
/// Regression: an Encryption=off recipe needs NO host cert. With an empty
/// cert list the drive is still armed and the unlock is the bare VID read —
/// no cert AKE, no bus key (content already comes back de-bussed).
#[test]
fn encryption_off_recipes_unlock_without_certs_via_the_bare_vid() {
for recipe in [ArmRecipe::BypassBd, ArmRecipe::BypassUhd, ArmRecipe::OemUhd] {
let mut t = FwFront::new(vid_reply([0x42; 16]));
let id = id();
let out = AacsUnlocker::new(vec![])
.arm_before_unlock(recipe)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.unwrap_or_else(|| panic!("{recipe:?}: armed drive is unlocked"));
assert_eq!(out.vid, Some([0x42; 16]), "{recipe:?}");
assert_eq!(out.bus_key, None, "{recipe:?}: no double bus-decrypt");
assert!(!t.ran_cert_ake(), "{recipe:?}");
}
}
/// Armed Encryption=off, then the bare VID read: a dead bus there aborts
/// with Transport; a drive rejection is still an unlock, just without a VID.
#[test]
fn encryption_off_arm_then_bare_vid_failure() {
use crate::scsi::mock::{MockTransport, Reply};
for (reply, want) in [
(Reply::TransportFault, Err(UnlockError::Transport)),
(Reply::illegal_request(), Ok(Some((None, None)))),
(Reply::illegal_request_as_err(), Ok(Some((None, None)))),
] {
let mut t = FwFront::new(MockTransport::always(reply));
let id = id();
let got = AacsUnlocker::new(vec![host_cert()])
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.map(|o| o.map(|u| (u.vid, u.bus_key)));
assert_eq!(got, want);
assert!(t.sent_a_set(), "the drive was armed first");
assert!(!t.ran_cert_ake());
}
}
/// Regression: with certs present, an Encryption=off arm still skips the
/// cert AKE and never reports a bus key.
#[test]
fn encryption_off_recipe_with_certs_skips_the_cert_ake() {
let mut t = FwFront::new(vid_reply([0x42; 16]));
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("unlocked");
assert_eq!(out.bus_key, None);
assert!(!t.ran_cert_ake());
}
/// A refused recipe (and OemBd, which leaves encryption on) proceeds to the
/// plain cert AKE, which still unlocks the drive and learns the bus key.
#[test]
fn refused_or_cert_recipes_run_the_cert_ake() {
for (recipe, refuse) in [(ArmRecipe::BypassBd, true), (ArmRecipe::OemBd, false)] {
let mut emu = handshake::tests::DriveEmu::new();
emu.serve_data_keys = true;
let (lax, lay) = (emu.la_x, emu.la_y);
let mut t = FwFront::new(emu);
t.refuse_sets = refuse;
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.arm_before_unlock(recipe)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("the cert route unlocks");
assert_eq!(out.vid, Some([0x5Au8; 16]), "{recipe:?}");
assert!(out.bus_key.is_some(), "{recipe:?}");
assert!(t.ran_cert_ake(), "{recipe:?}");
}
}
/// Regression: fw 0.8.x is below MIN_FW_VERSION, so the recipe is never
/// sent and the plain cert route runs.
#[test]
fn unsupported_firmware_is_not_armed() {
let mut emu = handshake::tests::DriveEmu::new();
emu.serve_data_keys = true;
let (lax, lay) = (emu.la_x, emu.la_y);
let mut t = FwFront::new(emu);
t.version = "0.8.3";
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("the cert route unlocks");
assert!(out.bus_key.is_some());
assert!(!t.sent_a_set(), "no recipe SET on unsupported firmware");
}
/// A dead bus mid-arm aborts the whole unlock, with or without certs.
#[test]
fn dead_bus_during_arm_aborts_the_unlock() {
for certs in [vec![host_cert()], vec![]] {
let mut t = FwFront::new(vid_reply([0x42; 16]));
t.dead_after_identity = true;
let id = id();
let n = certs.len();
let err = AacsUnlocker::new(certs)
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.unwrap_err();
assert_eq!(err, UnlockError::Transport, "{n} certs");
assert!(!t.ran_cert_ake());
}
}
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
/// and the transport is never touched.
#[test]
fn no_host_certs_declines() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched with no host certs");
}
}
let id = id();
let mut t = DeadTransport;
assert!(
AacsUnlocker::new(vec![])
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
.expect("no certs declines")
.is_none()
);
}
+2 -81
View File
@@ -61,84 +61,5 @@ impl From<Error> for crate::UnlockError {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::UnlockError;
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
/// Catches restoring the `.map_err(|_| CssAuthFailed)` that made the
/// `Transport` arm unreachable: a dead bus must classify as `Transport`
/// (consumer aborts), a drive rejection as `NotApplicable` (fall through).
#[test]
fn transport_fault_survives_the_step_mapping() {
let dead = step_err(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(dead.is_transport_failure());
assert_eq!(UnlockError::from(dead), UnlockError::Transport);
let refused = step_err(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
});
assert!(matches!(refused, Error::CssAuthFailed));
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
}
/// Each variant has its own stable numeric code.
#[test]
fn code_is_stable_per_variant() {
assert_eq!(Error::CssAuthFailed.code(), 7201);
assert_eq!(
Error::Scsi(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
})
.code(),
7299
);
}
// False for `CssAuthFailed` and for a `Scsi` error with a sense (a
// drive rejection, not a dead bus) even at transport-failure status;
// true only for the genuine transport fault (status + no sense).
#[test]
fn is_transport_failure_false_paths() {
assert!(!Error::CssAuthFailed.is_transport_failure());
let sensed = Error::Scsi(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: Some([0u8; 32]),
});
assert!(!sensed.is_transport_failure());
let refusal = Error::Scsi(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: None,
});
assert!(!refusal.is_transport_failure());
let dead = Error::Scsi(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(dead.is_transport_failure());
}
/// `From<ScsiError>` wraps the transport error unchanged into `Error::Scsi`.
#[test]
fn from_scsi_error_wraps_into_scsi_variant() {
let e = Error::from(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([1u8; 32]),
});
match e {
Error::Scsi(inner) => {
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
assert_eq!(inner.sense, Some([1u8; 32]));
}
_ => panic!("expected Error::Scsi"),
}
}
}
#[path = "error_tests.rs"]
mod tests;
+79
View File
@@ -0,0 +1,79 @@
use super::*;
use crate::UnlockError;
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
/// Catches restoring the `.map_err(|_| CssAuthFailed)` that made the
/// `Transport` arm unreachable: a dead bus must classify as `Transport`
/// (consumer aborts), a drive rejection as `NotApplicable` (fall through).
#[test]
fn transport_fault_survives_the_step_mapping() {
let dead = step_err(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(dead.is_transport_failure());
assert_eq!(UnlockError::from(dead), UnlockError::Transport);
let refused = step_err(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
});
assert!(matches!(refused, Error::CssAuthFailed));
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
}
/// Each variant has its own stable numeric code.
#[test]
fn code_is_stable_per_variant() {
assert_eq!(Error::CssAuthFailed.code(), 7201);
assert_eq!(
Error::Scsi(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
})
.code(),
7299
);
}
// False for `CssAuthFailed` and for a `Scsi` error with a sense (a
// drive rejection, not a dead bus) even at transport-failure status;
// true only for the genuine transport fault (status + no sense).
#[test]
fn is_transport_failure_false_paths() {
assert!(!Error::CssAuthFailed.is_transport_failure());
let sensed = Error::Scsi(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: Some([0u8; 32]),
});
assert!(!sensed.is_transport_failure());
let refusal = Error::Scsi(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: None,
});
assert!(!refusal.is_transport_failure());
let dead = Error::Scsi(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(dead.is_transport_failure());
}
/// `From<ScsiError>` wraps the transport error unchanged into `Error::Scsi`.
#[test]
fn from_scsi_error_wraps_into_scsi_variant() {
let e = Error::from(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([1u8; 32]),
});
match e {
Error::Scsi(inner) => {
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
assert_eq!(inner.sense, Some([1u8; 32]));
}
_ => panic!("expected Error::Scsi"),
}
}
+2 -909
View File
@@ -624,912 +624,5 @@ fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// SECURITY REGRESSION GUARD: scans source files for a `tracing` field
// binding a forbidden key name to a value expression (only a string
// literal or `_fp` field is allowed).
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
// Forbidden field names whose VALUES must never be logged.
const FORBIDDEN: &[&str] = &[
"title_key",
"disc_key",
"unit_key",
"vuk",
"player_key",
"bus_key",
];
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, forbidden, violations);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
for (lineno, line) in src.lines().enumerate() {
let trimmed = line.trim_start();
// Only inspect tracing instrumentation lines.
if !(trimmed.contains("tracing::")
|| trimmed.starts_with("debug!")
|| trimmed.starts_with("info!")
|| trimmed.starts_with("warn!")
|| trimmed.starts_with("trace!")
|| trimmed.starts_with("error!"))
{
continue;
}
// This guard test itself contains the forbidden names.
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
&& line.contains("FORBIDDEN")
{
continue;
}
for &name in forbidden {
// A fingerprint field (`<name>_fp = ...`) is allowed;
// match `<name>` then `=` with a value that is not a
// string-literal redaction marker.
if let Some(idx) = line.find(name) {
let after = &line[idx + name.len()..];
let after = after.trim_start();
// `<name>_fp` / `<name>_id` etc. are safe.
if after.starts_with('_') {
continue;
}
// Must be a field binding `name = ...`.
let Some(rest) = after.strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
// Redaction string literal is the only allowed value.
if rest.starts_with('"') {
continue;
}
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
violations.push(format!(
"{}:{}: forbidden key field `{}` logged with a value: {}",
path.display(),
lineno + 1,
name,
line.trim()
));
}
}
}
}
}
// Scan this crate's `src` plus sibling workspace crates so the
// guard covers everything that can reach CSS/AACS internals.
// Missing sibling dirs (standalone builds) are simply skipped.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent().unwrap_or(manifest);
let mut violations = Vec::new();
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
let dir = workspace.join(sibling).join("src");
if dir.is_dir() {
scan_dir(&dir, FORBIDDEN, &mut violations);
}
}
assert!(
violations.is_empty(),
"key material logged in instrumentation:\n{}",
violations.join("\n")
);
}
#[test]
fn crypt_key_is_deterministic() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
let r1 = crypt_key(0, v, &challenge);
let r2 = crypt_key(0, v, &challenge);
assert_eq!(r1, r2);
}
}
#[test]
fn crypt_key_varies_by_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
}
#[test]
fn crypt_key_nonzero() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
}
}
// ── CSS constant-table integrity ───────────────────────────────────────
// Each PERM_CHALLENGE row must be a permutation of indices 0..10; a
// non-permutation would drop/duplicate bytes.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
let mut seen = [false; 10];
for &idx in perm.iter() {
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
seen[idx] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_CHALLENGE[{row}] misses an index"
);
}
}
// Each PERM_VARIANT row must map the 32 variants to 32 distinct 5-bit
// values; a collision would make two variants indistinguishable.
#[test]
fn perm_variant_rows_are_permutations_of_0_31() {
for (row, perm) in PERM_VARIANT.iter().enumerate() {
let mut seen = [false; 32];
for &v in perm.iter() {
let v = v as usize;
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
seen[v] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_VARIANT[{row}] misses a value"
);
}
}
// ── crypt_key behaviour ────────────────────────────────────────────────
// crypt_key's result must depend on every challenge byte: flipping any
// single byte must change the output.
#[test]
fn crypt_key_depends_on_every_challenge_byte() {
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let base_out = crypt_key(0, 5, &base);
for i in 0..10 {
let mut c = base;
c[i] ^= 0x55;
assert_ne!(
crypt_key(0, 5, &c),
base_out,
"flipping challenge byte {i} did not change the bus-key derivation"
);
}
}
// crypt_key(0, v, ..) must be distinct for each of the 32 variants:
// bus-auth brute-forces the variant by matching against key1, so a
// collision could select the wrong one.
#[test]
fn crypt_key_type0_distinct_per_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let mut outs = Vec::new();
for v in 0..32u8 {
let k = crypt_key(0, v, &challenge);
assert!(
!outs.contains(&k),
"variant {v} collides with an earlier variant"
);
outs.push(k);
}
}
// crypt_key's `key_type < 3` assert! (not debug_assert!) must fire in
// every profile; `expected` rejects the index-OOB panic that would follow.
#[test]
#[should_panic(expected = "crypt_key: key_type out of range")]
fn crypt_key_rejects_out_of_range_key_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(3, 0, &challenge);
}
// crypt_key's `variant < 32` assert! must fire in every profile, not
// the VARIANTS/PERM_VARIANT index-OOB panic that would follow it.
#[test]
#[should_panic(expected = "crypt_key: variant out of range")]
fn crypt_key_rejects_out_of_range_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(0, 32, &challenge);
}
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
// byte 0=0xA4, bytes 8-9=big-endian len, byte 10=(AGID<<6)|(format&0x3F).
#[test]
fn report_key_cdb_matches_mmc_layout() {
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
assert_eq!(
cdb[10],
(0b10 << 6) | 0x04,
"AGID in bits 6-7, format in bits 0-5"
);
// Every other byte must be zero.
for (i, &b) in cdb.iter().enumerate() {
if ![0, 8, 9, 10].contains(&i) {
assert_eq!(b, 0, "CDB byte {i} must be zero");
}
}
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
}
// The key-format field is masked to 6 bits so a format with high bits
// set (e.g. 0xFF) cannot corrupt the AGID bits of byte 10.
#[test]
fn report_key_cdb_masks_format_to_6_bits() {
let cdb = report_key_cdb(0, 0xFF, 8);
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
}
// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
// parameter-list length at bytes 8-9 (big-endian) and AGID/format at
// byte 10.
#[test]
fn send_key_cdb_matches_mmc_layout() {
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
assert_eq!(cdb[8], 0x00, "param_len high byte");
assert_eq!(cdb[9], 0x0C, "param_len low byte");
assert_eq!(
cdb[10],
(0b11 << 6) | 0x03,
"AGID bits 6-7, format bits 0-5"
);
assert_eq!(cdb.len(), 12);
}
// Allocation length > 255 must split across bytes 8 (high) and 9 (low)
// as a 16-bit big-endian field, e.g. 0x0804 (the disc-key block size).
#[test]
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
let cdb = report_key_cdb(0, 0x00, 0x0804);
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
}
// The unlocker's user-facing name is "DVD" (the medium), not "CSS" (the
// scheme); apps render the unlocker report from this name, so it is a
// stable contract.
#[test]
fn dvd_unlocker_is_named_dvd() {
use crate::Unlocker;
assert_eq!(DvdUnlocker::new().name(), "DVD");
}
/// `Default` must delegate to `new()` — there is only one way to build a
/// `DvdUnlocker` (it is a unit struct), so this pins the two never drift.
#[test]
#[allow(clippy::default_constructed_unit_structs)]
fn default_matches_new() {
let _ = DvdUnlocker::default();
let _ = DvdUnlocker::new();
}
/// DvdUnlocker provides bus removal only — it never provides drive features.
// 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, Unlocker};
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
/// other CDB (i.e. CSS bus-auth activity).
struct BdTransport {
non_config_cdbs: usize,
}
impl ScsiTransport for BdTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
if data.len() >= 8 {
data[6] = 0x00;
data[7] = 0x40; // BD-ROM current profile
}
return Ok(ScsiResult {
status: 0,
bytes_transferred: 8,
sense: [0u8; 32],
});
}
self.non_config_cdbs += 1;
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
let id = DriveId {
vendor_id: "FAKEVNDR".to_string(),
..Default::default()
};
let mut t = BdTransport { non_config_cdbs: 0 };
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!(
t.non_config_cdbs, 0,
"no CSS CDB may be issued at a non-DVD drive"
);
}
// ── Transport-contract tests ────────────────────────────────────────────
use crate::scsi::mock::{MockTransport, Reply};
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
// Defect-7 regression: a transport fault on the first probe command
// must abort as Transport, not fall through to NotApplicable (which
// let the consumer keep probing a dead bus).
#[test]
fn transport_fault_probing_for_a_dvd_aborts() {
let id = DriveId::default();
let mut t = MockTransport::always(Reply::TransportFault);
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(r.unwrap_err(), UnlockError::Transport);
assert_eq!(
t.calls(),
1,
"must abort on the first command, not probe on"
);
}
/// A drive that REFUSES GET CONFIGURATION (CHECK CONDITION, delivered as
/// `Ok` per the contract) is inconclusive, not a dead bus → decline. Guards
/// against over-correcting defect 7 into "any probe failure aborts the rip".
#[test]
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(&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
// bus-auth — must abort as Transport, not collapse to CssAuthFailed /
// NotApplicable.
#[test]
fn transport_fault_during_bus_auth_aborts() {
let id = DriveId::default();
// GET CONFIGURATION reports a DVD-ROM profile (0x0010), then the bus dies.
let mut config = vec![0u8; 8];
config[6] = 0x00;
config[7] = 0x10;
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::TransportFault);
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(r.unwrap_err(), UnlockError::Transport);
}
/// The same shape with the drive REFUSING the bus-auth commands: a CSS
/// auth failure is a fall-through, not an abort.
#[test]
fn drive_refusing_bus_auth_is_not_applicable() {
let id = DriveId::default();
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(&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
// on off the caller's own zero-filled buffer; catches a dropped
// `status` check in `css_scsi`.
#[test]
fn agid_allocation_check_condition_fails_the_auth() {
// 4 AGID invalidations are best-effort; the 5th command is the alloc.
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
],
Reply::illegal_request(),
);
let e = establish_authenticated_session(&mut t).expect_err("refused alloc");
assert!(matches!(e, Error::CssAuthFailed));
}
// Defect 18: an AGID lost to a failed challenge must be RELEASED
// (REPORT KEY format 0x3F), not abandoned, even though a drive's four
// AGIDs self-heal on the next session's invalidation pass.
#[test]
fn a_failed_handshake_releases_the_agid_it_allocated() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]), // 4 × AGID invalidate
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]), // AGID allocated
],
Reply::illegal_request(), // every challenge step is refused
);
establish_authenticated_session(&mut t).expect_err("refused challenge");
let last = t.cdbs.last().expect("commands were issued");
assert_eq!(last[0], crate::scsi::SCSI_REPORT_KEY);
assert_eq!(last[10] & 0x3F, 0x3F, "AGID released on the failure path");
}
/// A short AGID-allocation response is equally unusable — the AGID would be
/// read out of bytes the drive never sent.
#[test]
fn short_agid_allocation_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
],
Reply::short(vec![0u8; 8], 3),
);
let e = establish_authenticated_session(&mut t).expect_err("short alloc");
assert!(matches!(e, Error::CssAuthFailed));
}
// ── authenticate_with_agid step failures ────────────────────────────────
/// The drive refuses the host-challenge SEND KEY (step 1) — the very first
/// command `authenticate_with_agid` issues.
#[test]
fn host_challenge_send_failure_fails_the_auth() {
let mut t =
MockTransport::scripted(vec![Reply::illegal_request()], Reply::illegal_request());
let e = authenticate_with_agid(&mut t, 0).expect_err("refused host challenge");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(t.calls(), 1, "must not proceed past the first refused step");
}
/// The host challenge SEND KEY succeeds but the Key1 REPORT KEY (step 2) is
/// refused.
#[test]
fn key1_report_failure_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![Reply::good(vec![]), Reply::illegal_request()],
Reply::illegal_request(),
);
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key1 report");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(t.calls(), 2);
}
/// Both SCSI steps succeed but the drive's Key1 does not match any of the
/// 32 CryptKey variants for the (randomly generated) host challenge — the
/// brute-force loop exhausts and `variant.ok_or(CssAuthFailed)` fires.
#[test]
fn key1_matching_no_variant_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![Reply::good(vec![]), Reply::good(vec![0xABu8; 12])],
Reply::illegal_request(),
);
let e = authenticate_with_agid(&mut t, 0).expect_err("no variant matches");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(
t.calls(),
2,
"the brute-force loop issues no further SCSI commands"
);
}
// Host challenge + Key1 succeed but the drive-challenge REPORT KEY
// (step 3) is refused; distinct from the step-2 and step-4 failure
// tests, each pinning a different `css_scsi` call site's `?`.
#[test]
fn drive_challenge_report_failure_fails_the_auth() {
struct FailAtDriveChallenge(FakeDvdDrive);
impl ScsiTransport for FailAtDriveChallenge {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_REPORT_KEY && cdb[10] & 0x3F == 0x01 {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
let mut t = FailAtDriveChallenge(FakeDvdDrive {
variant: 4,
host_challenge: [0u8; 10],
});
let e = authenticate_with_agid(&mut t, 0).expect_err("refused drive challenge");
assert!(matches!(e, Error::CssAuthFailed));
}
/// Host challenge, Key1, and the drive challenge all succeed, but the
/// Key2 SEND KEY (step 4, the last command in the handshake) is refused.
#[test]
fn key2_send_failure_fails_the_auth() {
struct FailAtKey2Send(FakeDvdDrive);
impl ScsiTransport for FailAtKey2Send {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_SEND_KEY && cdb[10] & 0x3F == 0x03 {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
let mut t = FailAtKey2Send(FakeDvdDrive {
variant: 4,
host_challenge: [0u8; 10],
});
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key2 send");
assert!(matches!(e, Error::CssAuthFailed));
}
// ── read_disc_key ────────────────────────────────────────────────────────
/// The best-effort disc-key REPORT KEY is refused — `read_disc_key` must
/// surface the failure to its caller (who treats it as non-fatal), not
/// silently return `Ok`.
#[test]
fn read_disc_key_refused_is_an_error() {
let mut t = MockTransport::always(Reply::illegal_request());
let e = read_disc_key(&mut t, 0).expect_err("drive refused disc-key read");
assert!(matches!(e, Error::CssAuthFailed));
}
/// A transport fault reading the disc key must classify as a transport
/// failure, not a generic auth failure.
#[test]
fn read_disc_key_transport_fault_is_transport_failure() {
let mut t = MockTransport::always(Reply::TransportFault);
let e = read_disc_key(&mut t, 0).expect_err("dead bus");
assert!(e.is_transport_failure());
}
// ── Full happy-path bus-auth ────────────────────────────────────────────
// A fake drive that plays its half of the CSS handshake for real:
// answers Key1 honestly, and DVD/disc-key probes as success.
struct FakeDvdDrive {
variant: u8,
host_challenge: [u8; 10],
}
impl ScsiTransport for FakeDvdDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
use crate::scsi::ScsiResult;
let ok = |bytes_transferred: usize| ScsiResult {
status: 0,
bytes_transferred,
sense: [0u8; 32],
};
match cdb[0] {
crate::scsi::SCSI_GET_CONFIGURATION => {
data[6] = 0x00;
data[7] = 0x10; // DVD-ROM current profile
Ok(ok(8))
}
crate::scsi::SCSI_SEND_KEY => {
let format = cdb[10] & 0x3F;
if format == 0x01 {
// Host challenge: capture it for the Key1 answer.
for i in 0..10 {
self.host_challenge[i] = data[4 + (9 - i)];
}
}
// format 0x03 (Key2) is accepted unconditionally: the
// real handshake never verifies it drive-side in this
// primitive (ASF=1 is set on the drive's own say-so).
Ok(ok(0))
}
crate::scsi::SCSI_REPORT_KEY => {
match cdb[10] & 0x3F {
0x00 => {
// Allocate AGID 0.
data[7] = 0x00;
Ok(ok(8))
}
0x02 => {
// Key1, honestly derived from the captured challenge.
let key1 = crypt_key(0, self.variant, &self.host_challenge);
for i in 0..5 {
data[4 + (4 - i)] = key1[i];
}
Ok(ok(12))
}
0x01 => {
// Drive challenge (arbitrary, fixed).
let drive_challenge: [u8; 10] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
for i in 0..10 {
data[4 + (9 - i)] = drive_challenge[i];
}
Ok(ok(16))
}
_ => Ok(ok(0)), // 0x3F release, best-effort
}
}
crate::scsi::SCSI_READ_DISC_STRUCTURE => Ok(ok(2048 + 4)),
_ => Ok(ok(0)),
}
}
}
// End-to-end happy path for the bus-auth handshake (AGID, challenge,
// Key1/Key2, disc-key REPORT KEY), exercising the `Ok` returns that
// the failure-path tests above never reach.
#[test]
fn full_bus_auth_and_disc_key_succeed() {
let mut t = FakeDvdDrive {
variant: 7,
host_challenge: [0u8; 10],
};
let agid =
establish_authenticated_session(&mut t).expect("full bus-auth handshake succeeds");
assert_eq!(agid, 0);
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
}
// 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();
let mut t = FakeDvdDrive {
variant: 3,
host_challenge: [0u8; 10],
};
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");
}
/// `unlock_css_reads` (the crate-level public entry point, distinct from
/// the `DvdUnlocker` wrapper) also succeeds end to end.
#[test]
fn unlock_css_reads_succeeds() {
let mut t = FakeDvdDrive {
variant: 11,
host_challenge: [0u8; 10],
};
// unlock_css_reads doesn't probe GET CONFIGURATION itself (that's
// DvdUnlocker's job); it goes straight to bus-auth.
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
}
// A drive whose bus-auth succeeds but refuses the best-effort disc-key
// REPORT KEY; `unlock_css_reads_inner` must swallow that failure since
// the read barrier is already open from bus-auth.
struct DiscKeyRefusingDrive(FakeDvdDrive);
impl ScsiTransport for DiscKeyRefusingDrive {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
#[test]
fn disc_key_refusal_is_non_fatal_to_the_overall_unlock() {
let mut t = DiscKeyRefusingDrive(FakeDvdDrive {
variant: 5,
host_challenge: [0u8; 10],
});
unlock_css_reads(&mut t, 0)
.expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it");
}
// `crypt_key`'s key_type==2 arm (`PERM_VARIANT[1]`) has no production
// caller (only 0 and 1 are invoked from `authenticate_with_agid`) but
// is a pure, directly-testable function.
#[test]
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let a = crypt_key(2, 9, &challenge);
let b = crypt_key(2, 9, &challenge);
assert_eq!(a, b, "crypt_key(2, ..) must be deterministic");
assert_ne!(
crypt_key(1, 9, &challenge),
crypt_key(2, 9, &challenge),
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
);
}
// ── Stop (stop-design-v5 §2.3, ST-U1) ───────────────────────────────────
/// A drive that completes the CSS bus-auth for real: it answers Key1 for the
/// host challenge under `variant`, serves a challenge, and allocates AGID 1.
struct CssEmu {
variant: u8,
host_challenge: [u8; 10],
/// Answer Key1 with zeros, which matches no variant → CssAuthFailed.
bad_key1: bool,
}
impl ScsiTransport for CssEmu {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
let mut r = vec![0u8; data.len()];
match (cdb[0], cdb[10] & 0x3F) {
(crate::scsi::SCSI_REPORT_KEY, 0x00) => r[7] = 1 << 6, // AGID 1
(crate::scsi::SCSI_REPORT_KEY, 0x02) if !self.bad_key1 => {
let k = crypt_key(0, self.variant, &self.host_challenge);
(0..5).for_each(|j| r[4 + j] = k[4 - j]);
}
(crate::scsi::SCSI_SEND_KEY, 0x01) => {
(0..10).for_each(|i| self.host_challenge[i] = data[4 + 9 - i]);
}
_ => {}
}
if cdb[0] != crate::scsi::SCSI_SEND_KEY {
data.copy_from_slice(&r);
}
Ok(crate::scsi::ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
fn css_emu() -> CssEmu {
CssEmu {
variant: 7,
host_challenge: [0u8; 10],
bad_key1: false,
}
}
// UT4 / G2 (stop-design-v5 §5.2, D9 "Keep holding it"): on success the AGID is
// `defuse`d, so it is still held for `read_disc_key` and never released.
// Per design D9; do not change without a design citation proving otherwise.
#[test]
fn css_success_keeps_agid() {
use crate::scsi::mock::{StopFake, agid_ledger, is_agid_release};
let mut t = StopFake::new(css_emu());
unlock_css_reads(&mut t, 0).expect("the bus-auth completes");
let (allocs, held) = agid_ledger(&t.log);
assert_eq!(
(allocs, held),
(1, true),
"one AGID, still held after success"
);
assert!(t.cleanups().is_empty(), "no release on success");
let execs = t.execs();
let alloc = execs.iter().position(|c| c[0] == 0xA4 && c[10] & 0x3F == 0);
let after = &execs[alloc.expect("allocated") + 1..];
assert!(
!after.iter().any(|c| is_agid_release(c)),
"no 0x3F after success"
);
let disc_key = after.last().expect("the disc-key read follows");
assert_eq!(
(disc_key[0], disc_key[7]),
(0xAD, 0x02),
"READ DVD STRUCTURE fmt 2"
);
assert_eq!(disc_key[10], 1 << 6, "issued under the still-held AGID 1");
}
// SS-7 (evidence): a CSS challenge that fails after allocation releases the
// AGID exactly once, through `execute_cleanup`, even when a Stop caused it.
#[test]
fn css_failed_challenge_releases_agid_once_via_cleanup() {
use crate::scsi::mock::{StopFake, agid_ledger};
let stop_at_challenge: fn(&[u8]) -> bool =
|c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01;
for cancel in [false, true] {
let mut emu = css_emu();
if !cancel {
emu.bad_key1 = true;
}
let mut t = StopFake::new(emu);
if cancel {
t.cancel_after = Some(stop_at_challenge);
}
let e = establish_authenticated_session(&mut t).expect_err("auth fails");
assert_eq!(e.is_transport_failure(), cancel, "a Stop is a refusal");
assert_eq!(agid_ledger(&t.log), (1, false), "cancel={cancel}");
assert_eq!(
t.cleanups().len(),
1,
"exactly one release, cancel={cancel}"
);
}
}
// UT5 (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" — the
// pre-allocation invalidate loop uses `execute`, so a Stop refuses it.
#[test]
fn invalidate_loops_refused_after_cancel() {
use crate::scsi::mock::StopFake;
let mut t = StopFake::new(css_emu());
t.cancelled = true;
let e = establish_authenticated_session(&mut t).expect_err("cancelled");
assert!(e.is_transport_failure());
assert!(
t.execs().is_empty(),
"no CDB reached the drive: {:?}",
t.log
);
assert!(
t.cleanups().is_empty(),
"nothing allocated, nothing released"
);
}
}
#[path = "mod_tests.rs"]
mod tests;
+905
View File
@@ -0,0 +1,905 @@
use super::*;
// SECURITY REGRESSION GUARD: scans source files for a `tracing` field
// binding a forbidden key name to a value expression (only a string
// literal or `_fp` field is allowed).
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
// Forbidden field names whose VALUES must never be logged.
const FORBIDDEN: &[&str] = &[
"title_key",
"disc_key",
"unit_key",
"vuk",
"player_key",
"bus_key",
];
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, forbidden, violations);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
for (lineno, line) in src.lines().enumerate() {
let trimmed = line.trim_start();
// Only inspect tracing instrumentation lines.
if !(trimmed.contains("tracing::")
|| trimmed.starts_with("debug!")
|| trimmed.starts_with("info!")
|| trimmed.starts_with("warn!")
|| trimmed.starts_with("trace!")
|| trimmed.starts_with("error!"))
{
continue;
}
// This guard test itself contains the forbidden names.
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
&& line.contains("FORBIDDEN")
{
continue;
}
for &name in forbidden {
// A fingerprint field (`<name>_fp = ...`) is allowed;
// match `<name>` then `=` with a value that is not a
// string-literal redaction marker.
if let Some(idx) = line.find(name) {
let after = &line[idx + name.len()..];
let after = after.trim_start();
// `<name>_fp` / `<name>_id` etc. are safe.
if after.starts_with('_') {
continue;
}
// Must be a field binding `name = ...`.
let Some(rest) = after.strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
// Redaction string literal is the only allowed value.
if rest.starts_with('"') {
continue;
}
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
violations.push(format!(
"{}:{}: forbidden key field `{}` logged with a value: {}",
path.display(),
lineno + 1,
name,
line.trim()
));
}
}
}
}
}
// Scan this crate's `src` plus sibling workspace crates so the
// guard covers everything that can reach CSS/AACS internals.
// Missing sibling dirs (standalone builds) are simply skipped.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent().unwrap_or(manifest);
let mut violations = Vec::new();
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
let dir = workspace.join(sibling).join("src");
if dir.is_dir() {
scan_dir(&dir, FORBIDDEN, &mut violations);
}
}
assert!(
violations.is_empty(),
"key material logged in instrumentation:\n{}",
violations.join("\n")
);
}
#[test]
fn crypt_key_is_deterministic() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
let r1 = crypt_key(0, v, &challenge);
let r2 = crypt_key(0, v, &challenge);
assert_eq!(r1, r2);
}
}
#[test]
fn crypt_key_varies_by_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
}
#[test]
fn crypt_key_nonzero() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
}
}
// ── CSS constant-table integrity ───────────────────────────────────────
// Each PERM_CHALLENGE row must be a permutation of indices 0..10; a
// non-permutation would drop/duplicate bytes.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
let mut seen = [false; 10];
for &idx in perm.iter() {
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
seen[idx] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_CHALLENGE[{row}] misses an index"
);
}
}
// Each PERM_VARIANT row must map the 32 variants to 32 distinct 5-bit
// values; a collision would make two variants indistinguishable.
#[test]
fn perm_variant_rows_are_permutations_of_0_31() {
for (row, perm) in PERM_VARIANT.iter().enumerate() {
let mut seen = [false; 32];
for &v in perm.iter() {
let v = v as usize;
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
seen[v] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_VARIANT[{row}] misses a value"
);
}
}
// ── crypt_key behaviour ────────────────────────────────────────────────
// crypt_key's result must depend on every challenge byte: flipping any
// single byte must change the output.
#[test]
fn crypt_key_depends_on_every_challenge_byte() {
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let base_out = crypt_key(0, 5, &base);
for i in 0..10 {
let mut c = base;
c[i] ^= 0x55;
assert_ne!(
crypt_key(0, 5, &c),
base_out,
"flipping challenge byte {i} did not change the bus-key derivation"
);
}
}
// crypt_key(0, v, ..) must be distinct for each of the 32 variants:
// bus-auth brute-forces the variant by matching against key1, so a
// collision could select the wrong one.
#[test]
fn crypt_key_type0_distinct_per_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let mut outs = Vec::new();
for v in 0..32u8 {
let k = crypt_key(0, v, &challenge);
assert!(
!outs.contains(&k),
"variant {v} collides with an earlier variant"
);
outs.push(k);
}
}
// crypt_key's `key_type < 3` assert! (not debug_assert!) must fire in
// every profile; `expected` rejects the index-OOB panic that would follow.
#[test]
#[should_panic(expected = "crypt_key: key_type out of range")]
fn crypt_key_rejects_out_of_range_key_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(3, 0, &challenge);
}
// crypt_key's `variant < 32` assert! must fire in every profile, not
// the VARIANTS/PERM_VARIANT index-OOB panic that would follow it.
#[test]
#[should_panic(expected = "crypt_key: variant out of range")]
fn crypt_key_rejects_out_of_range_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(0, 32, &challenge);
}
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
// byte 0=0xA4, bytes 8-9=big-endian len, byte 10=(AGID<<6)|(format&0x3F).
#[test]
fn report_key_cdb_matches_mmc_layout() {
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
assert_eq!(
cdb[10],
(0b10 << 6) | 0x04,
"AGID in bits 6-7, format in bits 0-5"
);
// Every other byte must be zero.
for (i, &b) in cdb.iter().enumerate() {
if ![0, 8, 9, 10].contains(&i) {
assert_eq!(b, 0, "CDB byte {i} must be zero");
}
}
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
}
// The key-format field is masked to 6 bits so a format with high bits
// set (e.g. 0xFF) cannot corrupt the AGID bits of byte 10.
#[test]
fn report_key_cdb_masks_format_to_6_bits() {
let cdb = report_key_cdb(0, 0xFF, 8);
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
}
// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
// parameter-list length at bytes 8-9 (big-endian) and AGID/format at
// byte 10.
#[test]
fn send_key_cdb_matches_mmc_layout() {
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
assert_eq!(cdb[8], 0x00, "param_len high byte");
assert_eq!(cdb[9], 0x0C, "param_len low byte");
assert_eq!(
cdb[10],
(0b11 << 6) | 0x03,
"AGID bits 6-7, format bits 0-5"
);
assert_eq!(cdb.len(), 12);
}
// Allocation length > 255 must split across bytes 8 (high) and 9 (low)
// as a 16-bit big-endian field, e.g. 0x0804 (the disc-key block size).
#[test]
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
let cdb = report_key_cdb(0, 0x00, 0x0804);
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
}
// The unlocker's user-facing name is "DVD" (the medium), not "CSS" (the
// scheme); apps render the unlocker report from this name, so it is a
// stable contract.
#[test]
fn dvd_unlocker_is_named_dvd() {
use crate::Unlocker;
assert_eq!(DvdUnlocker::new().name(), "DVD");
}
/// `Default` must delegate to `new()` — there is only one way to build a
/// `DvdUnlocker` (it is a unit struct), so this pins the two never drift.
#[test]
#[allow(clippy::default_constructed_unit_structs)]
fn default_matches_new() {
let _ = DvdUnlocker::default();
let _ = DvdUnlocker::new();
}
/// DvdUnlocker provides bus removal only — it never provides drive features.
// 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, Unlocker};
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
/// other CDB (i.e. CSS bus-auth activity).
struct BdTransport {
non_config_cdbs: usize,
}
impl ScsiTransport for BdTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
if data.len() >= 8 {
data[6] = 0x00;
data[7] = 0x40; // BD-ROM current profile
}
return Ok(ScsiResult {
status: 0,
bytes_transferred: 8,
sense: [0u8; 32],
});
}
self.non_config_cdbs += 1;
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
let id = DriveId {
vendor_id: "FAKEVNDR".to_string(),
..Default::default()
};
let mut t = BdTransport { non_config_cdbs: 0 };
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!(
t.non_config_cdbs, 0,
"no CSS CDB may be issued at a non-DVD drive"
);
}
// ── Transport-contract tests ────────────────────────────────────────────
use crate::scsi::mock::{MockTransport, Reply};
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
// Defect-7 regression: a transport fault on the first probe command
// must abort as Transport, not fall through to NotApplicable (which
// let the consumer keep probing a dead bus).
#[test]
fn transport_fault_probing_for_a_dvd_aborts() {
let id = DriveId::default();
let mut t = MockTransport::always(Reply::TransportFault);
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(r.unwrap_err(), UnlockError::Transport);
assert_eq!(
t.calls(),
1,
"must abort on the first command, not probe on"
);
}
/// A drive that REFUSES GET CONFIGURATION (CHECK CONDITION, delivered as
/// `Ok` per the contract) is inconclusive, not a dead bus → decline. Guards
/// against over-correcting defect 7 into "any probe failure aborts the rip".
#[test]
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(&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
// bus-auth — must abort as Transport, not collapse to CssAuthFailed /
// NotApplicable.
#[test]
fn transport_fault_during_bus_auth_aborts() {
let id = DriveId::default();
// GET CONFIGURATION reports a DVD-ROM profile (0x0010), then the bus dies.
let mut config = vec![0u8; 8];
config[6] = 0x00;
config[7] = 0x10;
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::TransportFault);
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(r.unwrap_err(), UnlockError::Transport);
}
/// The same shape with the drive REFUSING the bus-auth commands: a CSS
/// auth failure is a fall-through, not an abort.
#[test]
fn drive_refusing_bus_auth_is_not_applicable() {
let id = DriveId::default();
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(&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
// on off the caller's own zero-filled buffer; catches a dropped
// `status` check in `css_scsi`.
#[test]
fn agid_allocation_check_condition_fails_the_auth() {
// 4 AGID invalidations are best-effort; the 5th command is the alloc.
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
],
Reply::illegal_request(),
);
let e = establish_authenticated_session(&mut t).expect_err("refused alloc");
assert!(matches!(e, Error::CssAuthFailed));
}
// Defect 18: an AGID lost to a failed challenge must be RELEASED
// (REPORT KEY format 0x3F), not abandoned, even though a drive's four
// AGIDs self-heal on the next session's invalidation pass.
#[test]
fn a_failed_handshake_releases_the_agid_it_allocated() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]), // 4 × AGID invalidate
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]), // AGID allocated
],
Reply::illegal_request(), // every challenge step is refused
);
establish_authenticated_session(&mut t).expect_err("refused challenge");
let last = t.cdbs.last().expect("commands were issued");
assert_eq!(last[0], crate::scsi::SCSI_REPORT_KEY);
assert_eq!(last[10] & 0x3F, 0x3F, "AGID released on the failure path");
}
/// A short AGID-allocation response is equally unusable — the AGID would be
/// read out of bytes the drive never sent.
#[test]
fn short_agid_allocation_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
],
Reply::short(vec![0u8; 8], 3),
);
let e = establish_authenticated_session(&mut t).expect_err("short alloc");
assert!(matches!(e, Error::CssAuthFailed));
}
// ── authenticate_with_agid step failures ────────────────────────────────
/// The drive refuses the host-challenge SEND KEY (step 1) — the very first
/// command `authenticate_with_agid` issues.
#[test]
fn host_challenge_send_failure_fails_the_auth() {
let mut t = MockTransport::scripted(vec![Reply::illegal_request()], Reply::illegal_request());
let e = authenticate_with_agid(&mut t, 0).expect_err("refused host challenge");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(t.calls(), 1, "must not proceed past the first refused step");
}
/// The host challenge SEND KEY succeeds but the Key1 REPORT KEY (step 2) is
/// refused.
#[test]
fn key1_report_failure_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![Reply::good(vec![]), Reply::illegal_request()],
Reply::illegal_request(),
);
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key1 report");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(t.calls(), 2);
}
/// Both SCSI steps succeed but the drive's Key1 does not match any of the
/// 32 CryptKey variants for the (randomly generated) host challenge — the
/// brute-force loop exhausts and `variant.ok_or(CssAuthFailed)` fires.
#[test]
fn key1_matching_no_variant_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![Reply::good(vec![]), Reply::good(vec![0xABu8; 12])],
Reply::illegal_request(),
);
let e = authenticate_with_agid(&mut t, 0).expect_err("no variant matches");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(
t.calls(),
2,
"the brute-force loop issues no further SCSI commands"
);
}
// Host challenge + Key1 succeed but the drive-challenge REPORT KEY
// (step 3) is refused; distinct from the step-2 and step-4 failure
// tests, each pinning a different `css_scsi` call site's `?`.
#[test]
fn drive_challenge_report_failure_fails_the_auth() {
struct FailAtDriveChallenge(FakeDvdDrive);
impl ScsiTransport for FailAtDriveChallenge {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_REPORT_KEY && cdb[10] & 0x3F == 0x01 {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
let mut t = FailAtDriveChallenge(FakeDvdDrive {
variant: 4,
host_challenge: [0u8; 10],
});
let e = authenticate_with_agid(&mut t, 0).expect_err("refused drive challenge");
assert!(matches!(e, Error::CssAuthFailed));
}
/// Host challenge, Key1, and the drive challenge all succeed, but the
/// Key2 SEND KEY (step 4, the last command in the handshake) is refused.
#[test]
fn key2_send_failure_fails_the_auth() {
struct FailAtKey2Send(FakeDvdDrive);
impl ScsiTransport for FailAtKey2Send {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_SEND_KEY && cdb[10] & 0x3F == 0x03 {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
let mut t = FailAtKey2Send(FakeDvdDrive {
variant: 4,
host_challenge: [0u8; 10],
});
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key2 send");
assert!(matches!(e, Error::CssAuthFailed));
}
// ── read_disc_key ────────────────────────────────────────────────────────
/// The best-effort disc-key REPORT KEY is refused — `read_disc_key` must
/// surface the failure to its caller (who treats it as non-fatal), not
/// silently return `Ok`.
#[test]
fn read_disc_key_refused_is_an_error() {
let mut t = MockTransport::always(Reply::illegal_request());
let e = read_disc_key(&mut t, 0).expect_err("drive refused disc-key read");
assert!(matches!(e, Error::CssAuthFailed));
}
/// A transport fault reading the disc key must classify as a transport
/// failure, not a generic auth failure.
#[test]
fn read_disc_key_transport_fault_is_transport_failure() {
let mut t = MockTransport::always(Reply::TransportFault);
let e = read_disc_key(&mut t, 0).expect_err("dead bus");
assert!(e.is_transport_failure());
}
// ── Full happy-path bus-auth ────────────────────────────────────────────
// A fake drive that plays its half of the CSS handshake for real:
// answers Key1 honestly, and DVD/disc-key probes as success.
struct FakeDvdDrive {
variant: u8,
host_challenge: [u8; 10],
}
impl ScsiTransport for FakeDvdDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
use crate::scsi::ScsiResult;
let ok = |bytes_transferred: usize| ScsiResult {
status: 0,
bytes_transferred,
sense: [0u8; 32],
};
match cdb[0] {
crate::scsi::SCSI_GET_CONFIGURATION => {
data[6] = 0x00;
data[7] = 0x10; // DVD-ROM current profile
Ok(ok(8))
}
crate::scsi::SCSI_SEND_KEY => {
let format = cdb[10] & 0x3F;
if format == 0x01 {
// Host challenge: capture it for the Key1 answer.
for i in 0..10 {
self.host_challenge[i] = data[4 + (9 - i)];
}
}
// format 0x03 (Key2) is accepted unconditionally: the
// real handshake never verifies it drive-side in this
// primitive (ASF=1 is set on the drive's own say-so).
Ok(ok(0))
}
crate::scsi::SCSI_REPORT_KEY => {
match cdb[10] & 0x3F {
0x00 => {
// Allocate AGID 0.
data[7] = 0x00;
Ok(ok(8))
}
0x02 => {
// Key1, honestly derived from the captured challenge.
let key1 = crypt_key(0, self.variant, &self.host_challenge);
for i in 0..5 {
data[4 + (4 - i)] = key1[i];
}
Ok(ok(12))
}
0x01 => {
// Drive challenge (arbitrary, fixed).
let drive_challenge: [u8; 10] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
for i in 0..10 {
data[4 + (9 - i)] = drive_challenge[i];
}
Ok(ok(16))
}
_ => Ok(ok(0)), // 0x3F release, best-effort
}
}
crate::scsi::SCSI_READ_DISC_STRUCTURE => Ok(ok(2048 + 4)),
_ => Ok(ok(0)),
}
}
}
// End-to-end happy path for the bus-auth handshake (AGID, challenge,
// Key1/Key2, disc-key REPORT KEY), exercising the `Ok` returns that
// the failure-path tests above never reach.
#[test]
fn full_bus_auth_and_disc_key_succeed() {
let mut t = FakeDvdDrive {
variant: 7,
host_challenge: [0u8; 10],
};
let agid = establish_authenticated_session(&mut t).expect("full bus-auth handshake succeeds");
assert_eq!(agid, 0);
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
}
// 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();
let mut t = FakeDvdDrive {
variant: 3,
host_challenge: [0u8; 10],
};
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");
}
/// `unlock_css_reads` (the crate-level public entry point, distinct from
/// the `DvdUnlocker` wrapper) also succeeds end to end.
#[test]
fn unlock_css_reads_succeeds() {
let mut t = FakeDvdDrive {
variant: 11,
host_challenge: [0u8; 10],
};
// unlock_css_reads doesn't probe GET CONFIGURATION itself (that's
// DvdUnlocker's job); it goes straight to bus-auth.
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
}
// A drive whose bus-auth succeeds but refuses the best-effort disc-key
// REPORT KEY; `unlock_css_reads_inner` must swallow that failure since
// the read barrier is already open from bus-auth.
struct DiscKeyRefusingDrive(FakeDvdDrive);
impl ScsiTransport for DiscKeyRefusingDrive {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
#[test]
fn disc_key_refusal_is_non_fatal_to_the_overall_unlock() {
let mut t = DiscKeyRefusingDrive(FakeDvdDrive {
variant: 5,
host_challenge: [0u8; 10],
});
unlock_css_reads(&mut t, 0)
.expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it");
}
// `crypt_key`'s key_type==2 arm (`PERM_VARIANT[1]`) has no production
// caller (only 0 and 1 are invoked from `authenticate_with_agid`) but
// is a pure, directly-testable function.
#[test]
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let a = crypt_key(2, 9, &challenge);
let b = crypt_key(2, 9, &challenge);
assert_eq!(a, b, "crypt_key(2, ..) must be deterministic");
assert_ne!(
crypt_key(1, 9, &challenge),
crypt_key(2, 9, &challenge),
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
);
}
// ── Stop (stop-design-v5 §2.3, ST-U1) ───────────────────────────────────
/// A drive that completes the CSS bus-auth for real: it answers Key1 for the
/// host challenge under `variant`, serves a challenge, and allocates AGID 1.
struct CssEmu {
variant: u8,
host_challenge: [u8; 10],
/// Answer Key1 with zeros, which matches no variant → CssAuthFailed.
bad_key1: bool,
}
impl ScsiTransport for CssEmu {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
let mut r = vec![0u8; data.len()];
match (cdb[0], cdb[10] & 0x3F) {
(crate::scsi::SCSI_REPORT_KEY, 0x00) => r[7] = 1 << 6, // AGID 1
(crate::scsi::SCSI_REPORT_KEY, 0x02) if !self.bad_key1 => {
let k = crypt_key(0, self.variant, &self.host_challenge);
(0..5).for_each(|j| r[4 + j] = k[4 - j]);
}
(crate::scsi::SCSI_SEND_KEY, 0x01) => {
(0..10).for_each(|i| self.host_challenge[i] = data[4 + 9 - i]);
}
_ => {}
}
if cdb[0] != crate::scsi::SCSI_SEND_KEY {
data.copy_from_slice(&r);
}
Ok(crate::scsi::ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
fn css_emu() -> CssEmu {
CssEmu {
variant: 7,
host_challenge: [0u8; 10],
bad_key1: false,
}
}
// UT4 / G2 (stop-design-v5 §5.2, D9 "Keep holding it"): on success the AGID is
// `defuse`d, so it is still held for `read_disc_key` and never released.
// Per design D9; do not change without a design citation proving otherwise.
#[test]
fn css_success_keeps_agid() {
use crate::scsi::mock::{StopFake, agid_ledger, is_agid_release};
let mut t = StopFake::new(css_emu());
unlock_css_reads(&mut t, 0).expect("the bus-auth completes");
let (allocs, held) = agid_ledger(&t.log);
assert_eq!(
(allocs, held),
(1, true),
"one AGID, still held after success"
);
assert!(t.cleanups().is_empty(), "no release on success");
let execs = t.execs();
let alloc = execs.iter().position(|c| c[0] == 0xA4 && c[10] & 0x3F == 0);
let after = &execs[alloc.expect("allocated") + 1..];
assert!(
!after.iter().any(|c| is_agid_release(c)),
"no 0x3F after success"
);
let disc_key = after.last().expect("the disc-key read follows");
assert_eq!(
(disc_key[0], disc_key[7]),
(0xAD, 0x02),
"READ DVD STRUCTURE fmt 2"
);
assert_eq!(disc_key[10], 1 << 6, "issued under the still-held AGID 1");
}
// SS-7 (evidence): a CSS challenge that fails after allocation releases the
// AGID exactly once, through `execute_cleanup`, even when a Stop caused it.
#[test]
fn css_failed_challenge_releases_agid_once_via_cleanup() {
use crate::scsi::mock::{StopFake, agid_ledger};
let stop_at_challenge: fn(&[u8]) -> bool =
|c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01;
for cancel in [false, true] {
let mut emu = css_emu();
if !cancel {
emu.bad_key1 = true;
}
let mut t = StopFake::new(emu);
if cancel {
t.cancel_after = Some(stop_at_challenge);
}
let e = establish_authenticated_session(&mut t).expect_err("auth fails");
assert_eq!(e.is_transport_failure(), cancel, "a Stop is a refusal");
assert_eq!(agid_ledger(&t.log), (1, false), "cancel={cancel}");
assert_eq!(
t.cleanups().len(),
1,
"exactly one release, cancel={cancel}"
);
}
}
// UT5 (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" — the
// pre-allocation invalidate loop uses `execute`, so a Stop refuses it.
#[test]
fn invalidate_loops_refused_after_cancel() {
use crate::scsi::mock::StopFake;
let mut t = StopFake::new(css_emu());
t.cancelled = true;
let e = establish_authenticated_session(&mut t).expect_err("cancelled");
assert!(e.is_transport_failure());
assert!(
t.execs().is_empty(),
"no CDB reached the drive: {:?}",
t.log
);
assert!(
t.cleanups().is_empty(),
"nothing allocated, nothing released"
);
}
+2 -346
View File
@@ -233,349 +233,5 @@ impl Unlocker for FreemkvUnlocker {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DiscKind;
use crate::firmware::{REGION_FREE, STATE_OFF, build_identity_cdb, build_memread_cdb};
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Unknown)
}
/// The fw IDENTITY wire shape: `freemkv <ver>` (no NUL), the raw flag
/// table, then zero padding to the 64-byte window.
fn identity_payload(version: &str, flags: &[u8]) -> Vec<u8> {
let mut p = b"freemkv ".to_vec();
p.extend_from_slice(version.as_bytes());
p.extend_from_slice(flags);
p.resize(RESP_LEN, 0);
p
}
/// A fresh-boot fw 0.9.2 drive (DEFAULT_FLAGS: Unrestricted + Bd on).
fn freemkv_identity_payload() -> Vec<u8> {
identity_payload("0.9.2", &[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF])
}
/// 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). 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; 36];
p[4..20].copy_from_slice(&vid);
p
}
fn unlock(t: &mut MockTransport) -> std::result::Result<Option<Unlocked>, UnlockError> {
let id = crate::DriveId::default();
FreemkvUnlocker::new().unlock(t, &ctx(&id))
}
/// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its
/// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command),
/// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like
/// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix
/// the whole freemkv unlock failed at the first SET and fell back to AACS.
#[test]
fn set_issues_the_required_data_in_phase() {
struct DataInContract;
impl ScsiTransport for DataInContract {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
assert_eq!(cdb[4], 0x02, "test exercises only the SET verb");
// Model the drive: a SET with no from-device phase is aborted.
if !matches!(dir, DataDirection::FromDevice) || data.is_empty() {
return Ok(ScsiResult {
status: 2,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
FreemkvUnlocker::new()
.set(&mut DataInContract, Feature::Region, REGION_FREE)
.expect("SET must succeed by issuing the data-in phase the firmware requires");
}
// ── Detection ────────────────────────────────────────────────────────
#[test]
fn identify_true_on_supported_firmware_and_issues_identity_cdb() {
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
}
#[test]
fn identify_false_on_non_matching_payload() {
let mut t = MockTransport::always(Reply::good(vec![0u8; RESP_LEN]));
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_false_on_short_response() {
let mut t = MockTransport::always(Reply::short(freemkv_identity_payload(), 3));
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
/// The status guard decides: a CHECK CONDITION that nonetheless reports a
/// full magic-bearing transfer (residual/echoed buffer) is not freemkv.
#[test]
fn identify_false_on_check_condition_with_a_magic_buffer() {
struct CheckConditionWithData;
impl ScsiTransport for CheckConditionWithData {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let p = freemkv_identity_payload();
data.copy_from_slice(&p[..data.len()]);
Ok(ScsiResult {
status: 2,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
assert!(
!FreemkvUnlocker::new()
.identify(&mut CheckConditionWithData)
.expect("no fault")
);
}
#[test]
fn identify_false_when_command_rejected() {
let mut t = MockTransport::always(Reply::illegal_request());
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_false_when_command_rejected_as_err() {
let mut t = MockTransport::always(Reply::illegal_request_as_err());
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_transport_fault_aborts() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(
FreemkvUnlocker::new().identify(&mut t).unwrap_err(),
UnlockError::Transport
);
}
/// Regression: released fw 0.7.x (`freemkv 0.7.1` reply, old sub-function
/// grammar where 0x02 = Speed) answers GOOD to every SET. The unlocker must
/// decline it, not claim a drive it never unlocked.
#[test]
fn declines_legacy_07_firmware_without_sending_a_set() {
let mut t = MockTransport::always(Reply::good(identity_payload("0.7.1", &[])));
assert!(unlock(&mut t).expect("no fault").is_none());
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
}
/// Regression: fw 0.8.x (0x06 = `Ake`, separate `Bus`): de-bus via 0x06
/// alone is not validated (hardware-dependent), so decline.
#[test]
fn declines_08_firmware_without_sending_a_set() {
let flags = [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF, 0xFF];
let mut t = MockTransport::always(Reply::good(identity_payload("0.8.3", &flags)));
assert!(unlock(&mut t).expect("no fault").is_none());
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
}
// ── Feature sets ──────────────────────────────────────────────────────
#[test]
fn set_drive_rejection_is_not_applicable() {
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::always(reply.clone());
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Encryption, STATE_OFF)
.unwrap_err();
assert_eq!(err, UnlockError::NotApplicable, "{reply:?}");
}
}
#[test]
fn set_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Speed, SPEED_MAX)
.unwrap_err();
assert_eq!(err, UnlockError::Transport);
}
// ── full_unlock / unlock ─────────────────────────────────────────────
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
/// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire
/// bytes for every vendor SET.
#[test]
fn full_unlock_issues_identity_region_speed_unrestricted_encryption_then_bare_vid() {
let vid = [0x7Cu8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // IDENTITY
Reply::good(vec![]), // Set Region
Reply::good(vec![]), // Set Speed
Reply::good(vec![]), // Set Unrestricted
Reply::good(vec![]), // Set Encryption (load-bearing)
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
],
Reply::TransportFault,
);
let out = unlock(&mut t)
.expect("no fault")
.expect("encryption off ⇒ unlocked");
assert_eq!(out.vid, Some(vid));
assert_eq!(out.bus_key, None);
assert_eq!(t.cdbs.len(), 6);
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
let set = |f: u8, s: u8| vec![0x3C, 0x0E, 0xC0, 0xDE, 0x02, f, s, 0x00, 0x40, 0x00];
assert_eq!(t.cdbs[1], set(0x02, 0x0F), "Region = free");
assert_eq!(t.cdbs[2], set(0x01, 0x00), "Speed = max");
assert_eq!(t.cdbs[3], set(0x03, 0x01), "Unrestricted = on");
assert_eq!(t.cdbs[4], set(0x06, 0x00), "Encryption = off");
assert_eq!(t.cdbs[5][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
}
/// Missing region/speed/unrestricted features do not fail the unlock
/// (best-effort), whichever rejection shape the transport uses.
#[test]
fn full_unlock_tolerates_missing_best_effort_features() {
let vid = [0x3Cu8; 16];
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
reject.clone(), // region
reject.clone(), // speed
reject.clone(), // unrestricted
Reply::good(vec![]),
Reply::good(vid_ds_response(vid)),
],
Reply::TransportFault,
);
let out = unlock(&mut t).expect("no fault").expect("unlocked");
assert_eq!(out.vid, Some(vid), "{reject:?}");
}
}
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it (either
/// shape), `unlock()` declines (`None`) and falls through.
#[test]
fn declines_when_disable_encryption_rejected() {
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]),
reject.clone(), // Set(Encryption,off) REJECTED
],
Reply::TransportFault,
);
assert!(unlock(&mut t).expect("no fault").is_none(), "{reject:?}");
assert_eq!(t.calls(), 5, "no VID read after the refusal");
}
}
/// A dead bus at ANY step after IDENTITY aborts with `Transport` (never a
/// fall-through): each best-effort SET, the load-bearing SET, the VID read.
#[test]
fn dead_bus_after_identity_aborts_at_every_step() {
for good_before_fault in 0..5 {
let mut script = vec![Reply::good(freemkv_identity_payload())];
script.extend(std::iter::repeat_n(Reply::good(vec![]), good_before_fault));
let mut t = MockTransport::scripted(script, Reply::TransportFault);
assert_eq!(
unlock(&mut t).unwrap_err(),
UnlockError::Transport,
"fault after {good_before_fault} SETs"
);
assert_eq!(t.calls(), good_before_fault + 2, "aborted at the fault");
}
}
/// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the
/// bare VID read was rejected — `unlock()` still returns `Some`, with
/// `vid: None`.
#[test]
fn unlocks_without_vid_when_bare_read_fails() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]), // Set(Encryption,off)
Reply::illegal_request(), // bare VID: no medium
],
Reply::TransportFault,
);
let out = unlock(&mut t)
.expect("no fault")
.expect("unlocked despite no VID");
assert_eq!(out.vid, None);
}
#[test]
fn declines_non_freemkv_drive() {
let mut t = MockTransport::always(Reply::illegal_request());
assert!(unlock(&mut t).expect("no fault").is_none());
}
#[test]
fn transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport);
}
// ── DumpAll ──────────────────────────────────────────────────────────
#[test]
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
let mut payload = vec![0xABu8; MEMREAD_LEN];
payload[0] = 0xEE;
let mut t = MockTransport::always(Reply::good(payload));
let got = FreemkvUnlocker::new()
.dump_ram(&mut t, 0x01F8_1234)
.expect("dump ok");
assert_eq!(got[0], 0xEE);
assert_eq!(t.cdbs[0], build_memread_cdb(0x01F8_1234).to_vec());
}
/// A GOOD-status short DUMPALL is not a zero-padded RAM window.
#[test]
fn dump_ram_rejects_a_short_transfer() {
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
assert_eq!(
FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(),
UnlockError::NotApplicable
);
}
#[test]
fn name_is_freemkv() {
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
}
}
#[path = "mod_tests.rs"]
mod tests;
+344
View File
@@ -0,0 +1,344 @@
use super::*;
use crate::DiscKind;
use crate::firmware::{REGION_FREE, STATE_OFF, build_identity_cdb, build_memread_cdb};
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Unknown)
}
/// The fw IDENTITY wire shape: `freemkv <ver>` (no NUL), the raw flag
/// table, then zero padding to the 64-byte window.
fn identity_payload(version: &str, flags: &[u8]) -> Vec<u8> {
let mut p = b"freemkv ".to_vec();
p.extend_from_slice(version.as_bytes());
p.extend_from_slice(flags);
p.resize(RESP_LEN, 0);
p
}
/// A fresh-boot fw 0.9.2 drive (DEFAULT_FLAGS: Unrestricted + Bd on).
fn freemkv_identity_payload() -> Vec<u8> {
identity_payload("0.9.2", &[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF])
}
/// 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). 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; 36];
p[4..20].copy_from_slice(&vid);
p
}
fn unlock(t: &mut MockTransport) -> std::result::Result<Option<Unlocked>, UnlockError> {
let id = crate::DriveId::default();
FreemkvUnlocker::new().unlock(t, &ctx(&id))
}
/// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its
/// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command),
/// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like
/// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix
/// the whole freemkv unlock failed at the first SET and fell back to AACS.
#[test]
fn set_issues_the_required_data_in_phase() {
struct DataInContract;
impl ScsiTransport for DataInContract {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
assert_eq!(cdb[4], 0x02, "test exercises only the SET verb");
// Model the drive: a SET with no from-device phase is aborted.
if !matches!(dir, DataDirection::FromDevice) || data.is_empty() {
return Ok(ScsiResult {
status: 2,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
FreemkvUnlocker::new()
.set(&mut DataInContract, Feature::Region, REGION_FREE)
.expect("SET must succeed by issuing the data-in phase the firmware requires");
}
// ── Detection ────────────────────────────────────────────────────────
#[test]
fn identify_true_on_supported_firmware_and_issues_identity_cdb() {
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
}
#[test]
fn identify_false_on_non_matching_payload() {
let mut t = MockTransport::always(Reply::good(vec![0u8; RESP_LEN]));
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_false_on_short_response() {
let mut t = MockTransport::always(Reply::short(freemkv_identity_payload(), 3));
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
/// The status guard decides: a CHECK CONDITION that nonetheless reports a
/// full magic-bearing transfer (residual/echoed buffer) is not freemkv.
#[test]
fn identify_false_on_check_condition_with_a_magic_buffer() {
struct CheckConditionWithData;
impl ScsiTransport for CheckConditionWithData {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let p = freemkv_identity_payload();
data.copy_from_slice(&p[..data.len()]);
Ok(ScsiResult {
status: 2,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
assert!(
!FreemkvUnlocker::new()
.identify(&mut CheckConditionWithData)
.expect("no fault")
);
}
#[test]
fn identify_false_when_command_rejected() {
let mut t = MockTransport::always(Reply::illegal_request());
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_false_when_command_rejected_as_err() {
let mut t = MockTransport::always(Reply::illegal_request_as_err());
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_transport_fault_aborts() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(
FreemkvUnlocker::new().identify(&mut t).unwrap_err(),
UnlockError::Transport
);
}
/// Regression: released fw 0.7.x (`freemkv 0.7.1` reply, old sub-function
/// grammar where 0x02 = Speed) answers GOOD to every SET. The unlocker must
/// decline it, not claim a drive it never unlocked.
#[test]
fn declines_legacy_07_firmware_without_sending_a_set() {
let mut t = MockTransport::always(Reply::good(identity_payload("0.7.1", &[])));
assert!(unlock(&mut t).expect("no fault").is_none());
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
}
/// Regression: fw 0.8.x (0x06 = `Ake`, separate `Bus`): de-bus via 0x06
/// alone is not validated (hardware-dependent), so decline.
#[test]
fn declines_08_firmware_without_sending_a_set() {
let flags = [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF, 0xFF];
let mut t = MockTransport::always(Reply::good(identity_payload("0.8.3", &flags)));
assert!(unlock(&mut t).expect("no fault").is_none());
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
}
// ── Feature sets ──────────────────────────────────────────────────────
#[test]
fn set_drive_rejection_is_not_applicable() {
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::always(reply.clone());
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Encryption, STATE_OFF)
.unwrap_err();
assert_eq!(err, UnlockError::NotApplicable, "{reply:?}");
}
}
#[test]
fn set_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Speed, SPEED_MAX)
.unwrap_err();
assert_eq!(err, UnlockError::Transport);
}
// ── full_unlock / unlock ─────────────────────────────────────────────
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
/// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire
/// bytes for every vendor SET.
#[test]
fn full_unlock_issues_identity_region_speed_unrestricted_encryption_then_bare_vid() {
let vid = [0x7Cu8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // IDENTITY
Reply::good(vec![]), // Set Region
Reply::good(vec![]), // Set Speed
Reply::good(vec![]), // Set Unrestricted
Reply::good(vec![]), // Set Encryption (load-bearing)
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
],
Reply::TransportFault,
);
let out = unlock(&mut t)
.expect("no fault")
.expect("encryption off ⇒ unlocked");
assert_eq!(out.vid, Some(vid));
assert_eq!(out.bus_key, None);
assert_eq!(t.cdbs.len(), 6);
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
let set = |f: u8, s: u8| vec![0x3C, 0x0E, 0xC0, 0xDE, 0x02, f, s, 0x00, 0x40, 0x00];
assert_eq!(t.cdbs[1], set(0x02, 0x0F), "Region = free");
assert_eq!(t.cdbs[2], set(0x01, 0x00), "Speed = max");
assert_eq!(t.cdbs[3], set(0x03, 0x01), "Unrestricted = on");
assert_eq!(t.cdbs[4], set(0x06, 0x00), "Encryption = off");
assert_eq!(t.cdbs[5][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
}
/// Missing region/speed/unrestricted features do not fail the unlock
/// (best-effort), whichever rejection shape the transport uses.
#[test]
fn full_unlock_tolerates_missing_best_effort_features() {
let vid = [0x3Cu8; 16];
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
reject.clone(), // region
reject.clone(), // speed
reject.clone(), // unrestricted
Reply::good(vec![]),
Reply::good(vid_ds_response(vid)),
],
Reply::TransportFault,
);
let out = unlock(&mut t).expect("no fault").expect("unlocked");
assert_eq!(out.vid, Some(vid), "{reject:?}");
}
}
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it (either
/// shape), `unlock()` declines (`None`) and falls through.
#[test]
fn declines_when_disable_encryption_rejected() {
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]),
reject.clone(), // Set(Encryption,off) REJECTED
],
Reply::TransportFault,
);
assert!(unlock(&mut t).expect("no fault").is_none(), "{reject:?}");
assert_eq!(t.calls(), 5, "no VID read after the refusal");
}
}
/// A dead bus at ANY step after IDENTITY aborts with `Transport` (never a
/// fall-through): each best-effort SET, the load-bearing SET, the VID read.
#[test]
fn dead_bus_after_identity_aborts_at_every_step() {
for good_before_fault in 0..5 {
let mut script = vec![Reply::good(freemkv_identity_payload())];
script.extend(std::iter::repeat_n(Reply::good(vec![]), good_before_fault));
let mut t = MockTransport::scripted(script, Reply::TransportFault);
assert_eq!(
unlock(&mut t).unwrap_err(),
UnlockError::Transport,
"fault after {good_before_fault} SETs"
);
assert_eq!(t.calls(), good_before_fault + 2, "aborted at the fault");
}
}
/// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the
/// bare VID read was rejected — `unlock()` still returns `Some`, with
/// `vid: None`.
#[test]
fn unlocks_without_vid_when_bare_read_fails() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]), // Set(Encryption,off)
Reply::illegal_request(), // bare VID: no medium
],
Reply::TransportFault,
);
let out = unlock(&mut t)
.expect("no fault")
.expect("unlocked despite no VID");
assert_eq!(out.vid, None);
}
#[test]
fn declines_non_freemkv_drive() {
let mut t = MockTransport::always(Reply::illegal_request());
assert!(unlock(&mut t).expect("no fault").is_none());
}
#[test]
fn transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport);
}
// ── DumpAll ──────────────────────────────────────────────────────────
#[test]
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
let mut payload = vec![0xABu8; MEMREAD_LEN];
payload[0] = 0xEE;
let mut t = MockTransport::always(Reply::good(payload));
let got = FreemkvUnlocker::new()
.dump_ram(&mut t, 0x01F8_1234)
.expect("dump ok");
assert_eq!(got[0], 0xEE);
assert_eq!(t.cdbs[0], build_memread_cdb(0x01F8_1234).to_vec());
}
/// A GOOD-status short DUMPALL is not a zero-padded RAM window.
#[test]
fn dump_ram_rejects_a_short_transfer() {
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
assert_eq!(
FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(),
UnlockError::NotApplicable
);
}
#[test]
fn name_is_freemkv() {
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
}
+2 -30
View File
@@ -25,33 +25,5 @@ pub fn is_unlock_read_buffer(mode: u8, buf_id: u8) -> bool {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn marker_is_four_bytes() {
assert_eq!(UNLOCK_MARKER, b"MMkv");
assert_eq!(UNLOCK_MARKER.len(), 4);
}
#[test]
fn unlock_variants_match() {
// Variant A and variant B.
assert!(is_unlock_read_buffer(1, 0x44));
assert!(is_unlock_read_buffer(2, 0x77));
}
#[test]
fn non_unlock_cdbs_do_not_match() {
// Right mode, wrong buffer id.
assert!(!is_unlock_read_buffer(1, 0x77));
assert!(!is_unlock_read_buffer(2, 0x44));
// Wrong mode, right buffer id.
assert!(!is_unlock_read_buffer(0, 0x77));
assert!(!is_unlock_read_buffer(0, 0x44));
assert!(!is_unlock_read_buffer(3, 0x77));
// Ordinary data reads.
assert!(!is_unlock_read_buffer(2, 0x00));
assert!(!is_unlock_read_buffer(0, 0x00));
}
}
#[path = "cdb_tests.rs"]
mod tests;
+28
View File
@@ -0,0 +1,28 @@
use super::*;
#[test]
fn marker_is_four_bytes() {
assert_eq!(UNLOCK_MARKER, b"MMkv");
assert_eq!(UNLOCK_MARKER.len(), 4);
}
#[test]
fn unlock_variants_match() {
// Variant A and variant B.
assert!(is_unlock_read_buffer(1, 0x44));
assert!(is_unlock_read_buffer(2, 0x77));
}
#[test]
fn non_unlock_cdbs_do_not_match() {
// Right mode, wrong buffer id.
assert!(!is_unlock_read_buffer(1, 0x77));
assert!(!is_unlock_read_buffer(2, 0x44));
// Wrong mode, right buffer id.
assert!(!is_unlock_read_buffer(0, 0x77));
assert!(!is_unlock_read_buffer(0, 0x44));
assert!(!is_unlock_read_buffer(3, 0x77));
// Ordinary data reads.
assert!(!is_unlock_read_buffer(2, 0x00));
assert!(!is_unlock_read_buffer(0, 0x00));
}
+2 -115
View File
@@ -87,118 +87,5 @@ impl From<Error> for crate::UnlockError {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::UnlockError;
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
/// The abort/fall-through boundary, which had no test at all. Catches
/// widening `is_transport_failure` (every failure would abort every rip) or
/// narrowing it (a dead bus would be probed forever).
#[test]
fn only_a_senseless_transport_status_is_a_transport_failure() {
let dead_bus = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(dead_bus.is_transport_failure());
assert_eq!(UnlockError::from(dead_bus), UnlockError::Transport);
// A CHECK CONDITION is the DRIVE refusing, not the bus dying.
let refused = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
};
assert!(!refused.is_transport_failure());
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
// The transport-failure status WITH a sense is a drive that reported
// 0xFF, not a bus fault — it must not abort the consumer.
let odd = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: Some([0u8; 32]),
};
assert!(!odd.is_transport_failure());
for e in [
Error::ProfileParse,
Error::UnlockFailed,
Error::SignatureMismatch {
expected: [0; 4],
got: [1; 4],
},
] {
assert!(!e.is_transport_failure(), "{e:?} is not a bus fault");
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
}
}
/// `Display` is what shows up in logs — every variant must render without
/// panicking and the SCSI variant must carry the opcode/status through.
#[test]
fn display_renders_every_variant() {
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
assert_eq!(
Error::SignatureMismatch {
expected: [0; 4],
got: [1; 4],
}
.to_string(),
"signature mismatch"
);
assert_eq!(
Error::Scsi {
opcode: 0x3C,
status: 0x02,
sense: None,
}
.to_string(),
"SCSI error (opcode 0x3c, status 0x02)"
);
}
/// `From<ScsiError>` must carry the status AND sense across the seam —
/// collapsing either one destroys the transport-vs-rejection distinction the
/// whole abort/fall-through split rests on.
#[test]
fn scsi_error_conversion_preserves_status_and_sense() {
let e = Error::from(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(e.is_transport_failure());
let e = Error::from(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
});
assert!(!e.is_transport_failure());
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
}
/// Error codes are stable and distinct — they are the numeric contract the
/// logs carry instead of English.
#[test]
fn error_codes_are_distinct_and_stable() {
let codes = [
Error::ProfileParse.code(),
Error::UnlockFailed.code(),
Error::SignatureMismatch {
expected: [0; 4],
got: [0; 4],
}
.code(),
Error::Scsi {
opcode: 0,
status: 0,
sense: None,
}
.code(),
];
assert_eq!(codes, [7101, 7102, 7103, 7199]);
}
}
#[path = "error_tests.rs"]
mod tests;
+113
View File
@@ -0,0 +1,113 @@
use super::*;
use crate::UnlockError;
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
/// The abort/fall-through boundary, which had no test at all. Catches
/// widening `is_transport_failure` (every failure would abort every rip) or
/// narrowing it (a dead bus would be probed forever).
#[test]
fn only_a_senseless_transport_status_is_a_transport_failure() {
let dead_bus = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(dead_bus.is_transport_failure());
assert_eq!(UnlockError::from(dead_bus), UnlockError::Transport);
// A CHECK CONDITION is the DRIVE refusing, not the bus dying.
let refused = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
};
assert!(!refused.is_transport_failure());
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
// The transport-failure status WITH a sense is a drive that reported
// 0xFF, not a bus fault — it must not abort the consumer.
let odd = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: Some([0u8; 32]),
};
assert!(!odd.is_transport_failure());
for e in [
Error::ProfileParse,
Error::UnlockFailed,
Error::SignatureMismatch {
expected: [0; 4],
got: [1; 4],
},
] {
assert!(!e.is_transport_failure(), "{e:?} is not a bus fault");
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
}
}
/// `Display` is what shows up in logs — every variant must render without
/// panicking and the SCSI variant must carry the opcode/status through.
#[test]
fn display_renders_every_variant() {
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
assert_eq!(
Error::SignatureMismatch {
expected: [0; 4],
got: [1; 4],
}
.to_string(),
"signature mismatch"
);
assert_eq!(
Error::Scsi {
opcode: 0x3C,
status: 0x02,
sense: None,
}
.to_string(),
"SCSI error (opcode 0x3c, status 0x02)"
);
}
/// `From<ScsiError>` must carry the status AND sense across the seam —
/// collapsing either one destroys the transport-vs-rejection distinction the
/// whole abort/fall-through split rests on.
#[test]
fn scsi_error_conversion_preserves_status_and_sense() {
let e = Error::from(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(e.is_transport_failure());
let e = Error::from(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
});
assert!(!e.is_transport_failure());
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
}
/// Error codes are stable and distinct — they are the numeric contract the
/// logs carry instead of English.
#[test]
fn error_codes_are_distinct_and_stable() {
let codes = [
Error::ProfileParse.code(),
Error::UnlockFailed.code(),
Error::SignatureMismatch {
expected: [0; 4],
got: [0; 4],
}
.code(),
Error::Scsi {
opcode: 0,
status: 0,
sense: None,
}
.code(),
];
assert_eq!(codes, [7101, 7102, 7103, 7199]);
}
+2 -431
View File
@@ -200,434 +200,5 @@ impl Unlocker for LdUnlocker {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DiscKind;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
/// Unlock context for a fake drive id (kind/host-certs irrelevant to the
/// firmware unlocker — it keys off the drive identity).
fn ctx(id: &DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Unknown)
}
/// A fake transport that fills the response buffer from a fixed payload and
/// reports a configurable transferred-byte count.
struct FakeTransport {
payload: Vec<u8>,
bytes_transferred: usize,
}
impl ScsiTransport for FakeTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: self.bytes_transferred,
sense: [0u8; 32],
})
}
}
/// A DriveId for the bundled HL-DT-ST profile that carries a real
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
fn known_vid_drive_id() -> DriveId {
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
}
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
DriveId {
vendor_id: vendor.to_string(),
product_id: String::new(),
product_revision: rev.to_string(),
vendor_specific: vs.to_string(),
firmware_date: date.to_string(),
}
}
/// The bundled profile of the fixture drive (the one carrying a real
/// `read_vid_cdb`).
fn known_vid_profile() -> profile::DriveProfile {
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
assert!(
m.profile.read_vid_cdb.is_some(),
"test fixture drive must carry an OEM VID CDB"
);
m.profile
}
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
/// to `Some(vid)`.
#[test]
fn read_oem_vid_parses_well_formed_response() {
let mut payload = vec![0u8; 36];
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
let vid = [0x3Cu8; 16];
payload[4..20].copy_from_slice(&vid);
let mut t = FakeTransport {
payload,
bytes_transferred: 36,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("parse ok");
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
}
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
#[test]
fn read_oem_vid_short_response_is_none() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 20,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("short response is Ok(None)");
assert_eq!(got, None);
}
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
#[test]
fn read_oem_vid_bad_header_is_none() {
let mut payload = vec![0u8; 36];
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
let mut t = FakeTransport {
payload,
bytes_transferred: 36,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("bad header is Ok(None)");
assert_eq!(got, None);
}
/// Catches dropping the `result.status` check: a CHECK CONDITION arrives as
/// `Ok` per the transport contract, so without it the caller's zero-filled
/// buffer is parsed as a real 36-byte response.
#[test]
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 = 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");
}
/// Catches swallowing a transport fault in the OEM-VID read: a dead bus must
/// propagate (→ `UnlockError::Transport`), never become `Ok(None)`.
#[test]
fn read_oem_vid_transport_fault_propagates() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
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());
assert_eq!(UnlockError::from(err), UnlockError::Transport);
}
/// A profile with no OEM-VID CDB → `Ok(None)` without touching the drive.
#[test]
fn read_oem_vid_no_cdb_is_none() {
let mut p = known_vid_profile();
p.read_vid_cdb = None;
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &p)
.expect("no CDB is Ok(None)");
assert_eq!(got, None);
}
/// Public catalog accessors: `profiles()` returns the bundled catalog and
/// `profile()` is `profiles().and_then(get)` for a known drive.
#[test]
fn public_catalog_accessors_find_the_bundled_fixture_drive() {
assert!(super::profiles().is_some(), "bundled catalog must parse");
let m = super::profile(&known_vid_drive_id()).expect("known fixture drive matches");
assert!(m.profile.read_vid_cdb.is_some());
}
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
/// (fall through), short-circuiting before any firmware handshake.
#[test]
fn unlock_no_profile_is_not_applicable() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let unlocked = LdUnlocker::new()
.unlock(
&mut t,
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
)
.expect("no profile → declines, not a hard error");
assert!(unlocked.is_none());
}
// THE defect-1 test: response carries the signature + primary marker but not
// the secondary one, so init() succeeds but the drive isn't in extended-access
// state. Catches removing the `is_unlocked()` gate in `firmware_unlock`.
#[test]
fn half_unlocked_drive_falls_through_instead_of_claiming_unlocked() {
use crate::scsi::mock::{MockTransport, Reply};
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
// 64-byte unlock response: signature + primary marker "MMkv" at [12..16],
// secondary marker at [16..20] left zeroed.
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
let mut t = MockTransport::always(Reply::good(resp));
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 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_unlocked() {
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));
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
// full unlock; must abort with Transport, not report a successful unlock.
#[test]
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
// A drive that unlocks fully but whose bus dies on the speed probe.
struct ProbeFaultsDrive {
resp: Vec<u8>,
}
impl ScsiTransport for ProbeFaultsDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
return Err(crate::scsi::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
let n = self.resp.len().min(data.len());
data[..n].copy_from_slice(&self.resp[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
let mut t = ProbeFaultsDrive { resp };
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);
}
// A drive-sense (not a dead bus) rejecting the speed probe is a genuine
// calibration miss: warn-and-continue on the default speed table, still
// reporting a full unlock. Non-transport sibling of the probe-fault test.
#[test]
fn drive_sense_during_probe_disc_still_reports_a_successful_unlock() {
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]); // primary marker
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
struct ProbeSenseDrive {
resp: Vec<u8>,
}
impl ScsiTransport for ProbeSenseDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
return Ok(ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
let n = self.resp.len().min(data.len());
data[..n].copy_from_slice(&self.resp[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut t = ProbeSenseDrive { resp };
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
/// first unlock command faulting at the transport layer must abort the
/// consumer (`Transport`), not fall through to the next unlocker.
// Review of ST-D10: after a confirmed firmware upload, a drive whose media never
// becomes ready (the same 02/04/01 for 60 s, T6) must still report the unlock,
// and probe_disc + the VID read must still run (not `NotApplicable`).
#[test]
fn readiness_stall_after_upload_keeps_the_unlock() {
use crate::scsi::{DataDirection, ScsiResult};
use platform::mt1959::tests::{FakeClock, TEST_READY_CLOCK};
struct UploadThenNotReady {
sig: [u8; 4],
unlock_calls: usize,
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for UploadThenNotReady {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
let unlock = cdb[0] == 0x3C && matches!(cdb[2], 0x44 | 0x77) && cdb[3] == 0;
let (status, n) = if cdb[0] == 0x00 {
let mut sense = [0u8; 32];
(sense[2], sense[12], sense[13]) = (0x02, 0x04, 0x01);
return Ok(ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense,
});
} else if unlock && cdb[8] == 64 {
self.unlock_calls += 1;
data.fill(0);
data[0..4].copy_from_slice(&self.sig);
data[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
data[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
// The first unlock fails short, forcing the firmware upload.
(0, if self.unlock_calls == 1 { 10 } else { 64 })
} else {
data.fill(0);
(0, data.len())
};
Ok(ScsiResult {
status,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
let mut t = UploadThenNotReady {
sig,
unlock_calls: 0,
cdbs: Vec::new(),
};
TEST_READY_CLOCK.set(Some(|| Box::new(FakeClock::new())));
let r = LdUnlocker::new().unlock(&mut t, &ctx(&id));
TEST_READY_CLOCK.set(None);
let unlocked = r.expect("no fault");
assert!(
unlocked.is_some(),
"the confirmed unlock stands despite the stall"
);
let uploaded = t.cdbs.iter().any(|c| matches!(c[0], 0x3B | 0x55));
assert!(uploaded, "the firmware upload ran");
let last_tur = t
.cdbs
.iter()
.rposition(|c| c[0] == 0x00)
.expect("polled TUR");
let turs = t.cdbs.iter().filter(|c| c[0] == 0x00).count();
assert_eq!(turs, 121, "polled until the 60 s stall (virtual clock)");
let capacity = t
.cdbs
.iter()
.rposition(|c| c[0] == 0x25)
.expect("probe_disc ran");
assert!(capacity > last_tur, "probe_disc runs after the poll");
}
#[test]
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 = LdUnlocker::new()
.unlock(&mut t, &ctx(&known_vid_drive_id()))
.expect_err("dead bus");
assert_eq!(err, UnlockError::Transport);
}
}
#[path = "mod_tests.rs"]
mod tests;
+429
View File
@@ -0,0 +1,429 @@
use super::*;
use crate::DiscKind;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
/// Unlock context for a fake drive id (kind/host-certs irrelevant to the
/// firmware unlocker — it keys off the drive identity).
fn ctx(id: &DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Unknown)
}
/// A fake transport that fills the response buffer from a fixed payload and
/// reports a configurable transferred-byte count.
struct FakeTransport {
payload: Vec<u8>,
bytes_transferred: usize,
}
impl ScsiTransport for FakeTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: self.bytes_transferred,
sense: [0u8; 32],
})
}
}
/// A DriveId for the bundled HL-DT-ST profile that carries a real
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
fn known_vid_drive_id() -> DriveId {
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
}
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
DriveId {
vendor_id: vendor.to_string(),
product_id: String::new(),
product_revision: rev.to_string(),
vendor_specific: vs.to_string(),
firmware_date: date.to_string(),
}
}
/// The bundled profile of the fixture drive (the one carrying a real
/// `read_vid_cdb`).
fn known_vid_profile() -> profile::DriveProfile {
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
assert!(
m.profile.read_vid_cdb.is_some(),
"test fixture drive must carry an OEM VID CDB"
);
m.profile
}
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
/// to `Some(vid)`.
#[test]
fn read_oem_vid_parses_well_formed_response() {
let mut payload = vec![0u8; 36];
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
let vid = [0x3Cu8; 16];
payload[4..20].copy_from_slice(&vid);
let mut t = FakeTransport {
payload,
bytes_transferred: 36,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("parse ok");
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
}
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
#[test]
fn read_oem_vid_short_response_is_none() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 20,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("short response is Ok(None)");
assert_eq!(got, None);
}
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
#[test]
fn read_oem_vid_bad_header_is_none() {
let mut payload = vec![0u8; 36];
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
let mut t = FakeTransport {
payload,
bytes_transferred: 36,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("bad header is Ok(None)");
assert_eq!(got, None);
}
/// Catches dropping the `result.status` check: a CHECK CONDITION arrives as
/// `Ok` per the transport contract, so without it the caller's zero-filled
/// buffer is parsed as a real 36-byte response.
#[test]
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 = 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");
}
/// Catches swallowing a transport fault in the OEM-VID read: a dead bus must
/// propagate (→ `UnlockError::Transport`), never become `Ok(None)`.
#[test]
fn read_oem_vid_transport_fault_propagates() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
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());
assert_eq!(UnlockError::from(err), UnlockError::Transport);
}
/// A profile with no OEM-VID CDB → `Ok(None)` without touching the drive.
#[test]
fn read_oem_vid_no_cdb_is_none() {
let mut p = known_vid_profile();
p.read_vid_cdb = None;
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &p)
.expect("no CDB is Ok(None)");
assert_eq!(got, None);
}
/// Public catalog accessors: `profiles()` returns the bundled catalog and
/// `profile()` is `profiles().and_then(get)` for a known drive.
#[test]
fn public_catalog_accessors_find_the_bundled_fixture_drive() {
assert!(super::profiles().is_some(), "bundled catalog must parse");
let m = super::profile(&known_vid_drive_id()).expect("known fixture drive matches");
assert!(m.profile.read_vid_cdb.is_some());
}
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
/// (fall through), short-circuiting before any firmware handshake.
#[test]
fn unlock_no_profile_is_not_applicable() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let unlocked = LdUnlocker::new()
.unlock(
&mut t,
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
)
.expect("no profile → declines, not a hard error");
assert!(unlocked.is_none());
}
// THE defect-1 test: response carries the signature + primary marker but not
// the secondary one, so init() succeeds but the drive isn't in extended-access
// state. Catches removing the `is_unlocked()` gate in `firmware_unlock`.
#[test]
fn half_unlocked_drive_falls_through_instead_of_claiming_unlocked() {
use crate::scsi::mock::{MockTransport, Reply};
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
// 64-byte unlock response: signature + primary marker "MMkv" at [12..16],
// secondary marker at [16..20] left zeroed.
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
let mut t = MockTransport::always(Reply::good(resp));
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 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_unlocked() {
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));
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
// full unlock; must abort with Transport, not report a successful unlock.
#[test]
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
// A drive that unlocks fully but whose bus dies on the speed probe.
struct ProbeFaultsDrive {
resp: Vec<u8>,
}
impl ScsiTransport for ProbeFaultsDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
return Err(crate::scsi::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
let n = self.resp.len().min(data.len());
data[..n].copy_from_slice(&self.resp[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
let mut t = ProbeFaultsDrive { resp };
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);
}
// A drive-sense (not a dead bus) rejecting the speed probe is a genuine
// calibration miss: warn-and-continue on the default speed table, still
// reporting a full unlock. Non-transport sibling of the probe-fault test.
#[test]
fn drive_sense_during_probe_disc_still_reports_a_successful_unlock() {
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]); // primary marker
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
struct ProbeSenseDrive {
resp: Vec<u8>,
}
impl ScsiTransport for ProbeSenseDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
return Ok(ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
let n = self.resp.len().min(data.len());
data[..n].copy_from_slice(&self.resp[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut t = ProbeSenseDrive { resp };
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
/// first unlock command faulting at the transport layer must abort the
/// consumer (`Transport`), not fall through to the next unlocker.
// Review of ST-D10: after a confirmed firmware upload, a drive whose media never
// becomes ready (the same 02/04/01 for 60 s, T6) must still report the unlock,
// and probe_disc + the VID read must still run (not `NotApplicable`).
#[test]
fn readiness_stall_after_upload_keeps_the_unlock() {
use crate::scsi::{DataDirection, ScsiResult};
use platform::mt1959::tests::{FakeClock, TEST_READY_CLOCK};
struct UploadThenNotReady {
sig: [u8; 4],
unlock_calls: usize,
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for UploadThenNotReady {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
let unlock = cdb[0] == 0x3C && matches!(cdb[2], 0x44 | 0x77) && cdb[3] == 0;
let (status, n) = if cdb[0] == 0x00 {
let mut sense = [0u8; 32];
(sense[2], sense[12], sense[13]) = (0x02, 0x04, 0x01);
return Ok(ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense,
});
} else if unlock && cdb[8] == 64 {
self.unlock_calls += 1;
data.fill(0);
data[0..4].copy_from_slice(&self.sig);
data[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
data[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
// The first unlock fails short, forcing the firmware upload.
(0, if self.unlock_calls == 1 { 10 } else { 64 })
} else {
data.fill(0);
(0, data.len())
};
Ok(ScsiResult {
status,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
let mut t = UploadThenNotReady {
sig,
unlock_calls: 0,
cdbs: Vec::new(),
};
TEST_READY_CLOCK.set(Some(|| Box::new(FakeClock::new())));
let r = LdUnlocker::new().unlock(&mut t, &ctx(&id));
TEST_READY_CLOCK.set(None);
let unlocked = r.expect("no fault");
assert!(
unlocked.is_some(),
"the confirmed unlock stands despite the stall"
);
let uploaded = t.cdbs.iter().any(|c| matches!(c[0], 0x3B | 0x55));
assert!(uploaded, "the firmware upload ran");
let last_tur = t
.cdbs
.iter()
.rposition(|c| c[0] == 0x00)
.expect("polled TUR");
let turs = t.cdbs.iter().filter(|c| c[0] == 0x00).count();
assert_eq!(turs, 121, "polled until the 60 s stall (virtual clock)");
let capacity = t
.cdbs
.iter()
.rposition(|c| c[0] == 0x25)
.expect("probe_disc ran");
assert!(capacity > last_tur, "probe_disc runs after the poll");
}
#[test]
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 = LdUnlocker::new()
.unlock(&mut t, &ctx(&known_vid_drive_id()))
.expect_err("dead bus");
assert_eq!(err, UnlockError::Transport);
}
+2 -34
View File
@@ -27,37 +27,5 @@ pub(crate) trait PlatformDriver: Send {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
// Minimal driver taking the `PlatformDriver` default `is_unlocked`, so
// the default's `false` path is exercised (real drivers override it).
struct StubDriver;
impl PlatformDriver for StubDriver {
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
Ok(())
}
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
Ok(())
}
fn is_ready(&self) -> bool {
true
}
}
#[test]
fn default_is_unlocked_is_false_and_trait_methods_dispatch() {
let mut drv = StubDriver;
let mut scsi = MockTransport::always(Reply::good(vec![]));
assert!(drv.init(&mut scsi).is_ok());
assert!(drv.probe_disc(&mut scsi).is_ok());
assert!(drv.is_ready());
assert!(!drv.is_unlocked());
}
}
#[path = "mod_tests.rs"]
mod tests;
+32
View File
@@ -0,0 +1,32 @@
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
// Minimal driver taking the `PlatformDriver` default `is_unlocked`, so
// the default's `false` path is exercised (real drivers override it).
struct StubDriver;
impl PlatformDriver for StubDriver {
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
Ok(())
}
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
Ok(())
}
fn is_ready(&self) -> bool {
true
}
}
#[test]
fn default_is_unlocked_is_false_and_trait_methods_dispatch() {
let mut drv = StubDriver;
let mut scsi = MockTransport::always(Reply::good(vec![]));
assert!(drv.init(&mut scsi).is_ok());
assert!(drv.probe_disc(&mut scsi).is_ok());
assert!(drv.is_ready());
assert!(!drv.is_unlocked());
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -447
View File
@@ -363,450 +363,5 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DriveId;
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
DriveId {
vendor_id: vendor.to_string(),
product_id: String::new(),
product_revision: rev.to_string(),
vendor_specific: vs.to_string(),
firmware_date: date.to_string(),
}
}
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
/// the full pass binds to the one whose product matches; the looser passes
/// would return the first regardless.
#[test]
fn find_by_drive_id_product_id_breaks_a_tie() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
"product_revision":"1.00","vendor_specific":"XX00000",
"firmware_date":"200001010000"},
"signature":"aaaaaaaa","firmware":""},
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
"product_revision":"1.00","vendor_specific":"XX00000",
"firmware_date":"200001010000"},
"signature":"bbbbbbbb","firmware":""}
]
})
.to_string(),
)
.unwrap();
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
id.product_id = "MODEL-B".to_string();
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(
m.profile.signature,
[0xbb, 0xbb, 0xbb, 0xbb],
"product_id must select MODEL-B over the first entry"
);
// No product_id → falls back to the 4-field pass → first entry.
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
// A REPORTED product_id that matches no cataloged entry must NOT fall
// through to the four-field pass and bind to a sibling (MODEL-A/B);
// an uncataloged variant is no match, not a wrong match.
let mut idc = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
idc.product_id = "MODEL-C".to_string();
assert!(
find_by_drive_id(&profiles, &idc).is_none(),
"an uncataloged product_id must not mis-bind to a same-vendor sibling",
);
}
/// REGRESSION (1.7.0): a real drive reports a SPECIFIC product_id (e.g.
/// "BD-RE BU40N") while the catalog stores a GENERIC one ("BD-RE"), so the
/// exact pass misses. When the four-field identity is UNIQUE it must still
/// bind — the pre-1.7.0 behaviour a UHD LG BU40N relies on for LibreDrive
/// unlock. 1.7.0 gated the four-field pass behind an empty product_id, so it
/// silently stopped matching and the firmware unlocker was skipped entirely.
#[test]
fn find_by_drive_id_specific_product_id_binds_unique_four_field() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [
{"identity": {"vendor_id":"HL-DT-ST","product_id":"BD-RE",
"product_revision":"1.03","vendor_specific":"NM00000",
"firmware_date":"211810241934"},
"signature":"12345678","firmware":""}
]
})
.to_string(),
)
.unwrap();
// Drive reports the specific marketing product_id; catalog has "BD-RE".
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
id.product_id = "BD-RE BU40N".to_string();
let m = find_by_drive_id(&profiles, &id)
.expect("unique four-field identity must match despite the product_id mismatch");
assert_eq!(m.profile.signature, [0x12, 0x34, 0x56, 0x78]);
assert_eq!(m.platform, Platform::Mt1959A);
}
/// An exact product_id match in a LATER section must beat a four-field
/// fallback in an earlier one; fallback uniqueness spans all sections.
#[test]
fn find_by_drive_id_exact_match_beats_earlier_section_fallback() {
use serde_json::json;
let entry = |prod: &str, sig: &str| {
json!({"identity": {"vendor_id":"HL-DT-ST","product_id":prod,
"product_revision":"1.03","vendor_specific":"NM00000",
"firmware_date":"211810241934"},
"signature":sig,"firmware":""})
};
let profiles: Profiles = serde_json::from_value(json!({
"mt1959_a": [entry("BD-RE", "aaaaaaaa")],
"mt1959_b": [entry("BD-RE BU40N", "bbbbbbbb")],
}))
.unwrap();
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
id.product_id = "BD-RE BU40N".to_string();
let m = find_by_drive_id(&profiles, &id).expect("B's exact match");
assert_eq!(m.platform, Platform::Mt1959B);
assert_eq!(m.profile.signature, [0xbb, 0xbb, 0xbb, 0xbb]);
// Tuple shared across sections + uncataloged product_id: no match.
id.product_id = "BD-RE WH16NS".to_string();
assert!(find_by_drive_id(&profiles, &id).is_none());
// No product_id: shared tuple binds the first section's entry.
id.product_id.clear();
let m0 = find_by_drive_id(&profiles, &id).expect("empty product_id binds");
assert_eq!(m0.platform, Platform::Mt1959A);
}
#[test]
fn test_find_known_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
assert_eq!(m.platform, Platform::Mt1959A);
}
#[test]
fn test_find_unknown_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("FAKE-VND", "9.99", "XX12345", "");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
#[test]
fn decode_hex_rejects_non_ascii_without_panic() {
// A multi-byte char of even byte-length must not slice inside a
// char boundary; it must decode-fail gracefully.
assert!(decode_hex("中中").is_err()); // 6 bytes, none hex
assert!(parse_hex4("中中").is_err()); // 6 bytes != 8 anyway
// An 8-byte non-ASCII string (two 4-byte chars) hits the exact-len
// path of parse_hex4; must still error, not panic.
assert!(parse_hex4("𝕏𝕏").is_err());
}
#[test]
fn decode_hex_roundtrips_valid_hex() {
assert_eq!(decode_hex("00ff10").unwrap(), vec![0x00, 0xff, 0x10]);
assert_eq!(parse_hex4("deadbeef").unwrap(), [0xde, 0xad, 0xbe, 0xef]);
assert!(decode_hex("abc").is_err()); // odd length
assert!(decode_hex("zz").is_err()); // non-hex
}
#[test]
fn bundled_is_cached_and_matches_fresh_parse() {
let cached = bundled().expect("bundled profiles parse");
let fresh = load_bundled().unwrap();
// Same data either way (compare section sizes — Profiles isn't Eq).
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
// Cached accessor returns a stable address across calls.
let a = bundled().unwrap() as *const Profiles;
let b = bundled().unwrap() as *const Profiles;
assert_eq!(a, b);
}
#[test]
fn find_bundled_matches_known_drive() {
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_bundled(&id).unwrap();
assert_eq!(m.platform, Platform::Mt1959A);
}
// ── New comprehensive tests ────────────────────────────────────────────────
/// decode_hex accepts empty string → empty Vec.
/// Mutation: returning an error on empty input breaks empty-field handling.
#[test]
fn decode_hex_accepts_empty_string() {
assert_eq!(decode_hex("").unwrap(), Vec::<u8>::new());
}
/// decode_hex handles all valid hex digit characters (0-9, a-f, A-F).
/// Mutation: not supporting uppercase A-F means uppercase-encoded profiles fail.
#[test]
fn decode_hex_handles_upper_and_lower_case() {
assert_eq!(
decode_hex("DEADBEEF").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("deadbeef").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("DeAdBeEf").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
}
/// parse_hex4 rejects an 8-hex-char string (4 bytes) correctly.
/// Spec: the signature field is exactly 4 bytes = 8 hex chars.
/// Mutation: accepting 6 hex chars (3 bytes) would pass a wrong-length signature.
#[test]
fn parse_hex4_rejects_wrong_byte_length() {
// 6 hex chars = 3 bytes ≠ 4.
assert!(
parse_hex4("aabbcc").is_err(),
"3 bytes must be rejected for 4-byte field"
);
// 10 hex chars = 5 bytes ≠ 4.
assert!(
parse_hex4("aabbccddee").is_err(),
"5 bytes must be rejected for 4-byte field"
);
// Exactly 8 hex chars = 4 bytes: must succeed.
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
}
// Platform::name() strings are logged/keyed by callers; changing them is
// a breaking change. Mutation: swapping A/B names misroutes firmware upload.
#[test]
fn platform_name_is_stable() {
// The exact strings are part of the public stable API (logged/keyed).
assert_eq!(Platform::Mt1959A.name(), "MediaTek MT1959-A");
assert_eq!(Platform::Mt1959B.name(), "MediaTek MT1959-B");
assert_eq!(Platform::Renesas.name(), "Renesas");
}
// find_by_drive_id: two-pass — exact match (incl. firmware_date) wins
// over loose. Mutation: skipping the exact pass returns the first entry
// regardless of date.
#[test]
fn find_by_drive_id_exact_date_wins_over_loose() {
use serde_json::json;
// Use an 8-char vendor_id (padded with a trailing space so `trim()` strips
// the pad, matching the same trimmed form the JSON profile stores).
// "TESTDRV " fills INQUIRY [8..16] exactly; `ascii_field.trim()` → "TESTDRV".
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200001010000"
},
"signature": "aabbccdd",
"firmware": ""
},
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200006150000"
},
"signature": "11223344",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
let id_date2 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200006150000");
let m1 = find_by_drive_id(&profiles, &id_date1).unwrap();
let m2 = find_by_drive_id(&profiles, &id_date2).unwrap();
// Each must bind to its own profile by exact date match.
assert_eq!(
m1.profile.signature,
[0xaa, 0xbb, 0xcc, 0xdd],
"id_date1 must match first profile"
);
assert_eq!(
m2.profile.signature,
[0x11, 0x22, 0x33, 0x44],
"id_date2 must match second profile"
);
}
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
/// the wrong same-model variant.
#[test]
fn find_by_drive_id_no_match_when_date_differs() {
use serde_json::json;
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "LOOSEDR",
"product_revision": "2.00",
"vendor_specific": "YY11111",
"firmware_date": "210101010000"
},
"signature": "deadbeef",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// Same vendor/rev/vs but a different date — must NOT match.
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
/// Mutation: returning an empty Profiles instead of an error silently
/// leaves the drive-profile database empty.
#[test]
fn load_from_str_returns_profile_parse_on_bad_json() {
let result: Result<Profiles> =
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
assert!(matches!(result, Err(Error::ProfileParse)));
}
// Pins the embedded JSON: if profiles.json is emptied/truncated, this
// goes red.
#[test]
fn bundled_profiles_has_entries() {
let profiles = load_bundled().unwrap();
assert!(
!profiles.mt1959_a.is_empty(),
"bundled profiles must have at least one mt1959_a entry"
);
}
// All CDB fields are `#[serde(default)]`. Mutation: making one required
// breaks backward-compat with old blobs.
#[test]
fn profile_optional_cdb_fields_default_to_none() {
use serde_json::json;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "00000000",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
let p = &profiles.mt1959_a[0]; // DriveProfile directly
// All optional CDB fields must be None when absent from JSON.
assert!(
p.read_vid_cdb.is_none(),
"read_vid_cdb must default to None"
);
assert!(
p.read_disc_keys_cdb.is_none(),
"read_disc_keys_cdb must default to None"
);
assert!(
p.drive_nominal_speed_cdb.is_none(),
"drive_nominal_speed_cdb must default to None"
);
assert!(
p.set_speed_max_cdb.is_none(),
"set_speed_max_cdb must default to None"
);
assert!(
p.speed_zone_table.is_none(),
"speed_zone_table must default to None"
);
assert!(
p.speed_calc_table.is_none(),
"speed_calc_table must default to None"
);
}
// `Debug` must not render firmware/CDB bytes. Mutation: restoring
// `#[derive(Debug)]` prints them (e.g. the 0xEE marker below), goes red.
#[test]
fn drive_profile_debug_redacts_firmware_and_cdbs() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [{
"identity": {"vendor_id":"VIS","product_revision":"1.00",
"vendor_specific":"AA00000","firmware_date":"200001010000"},
"signature":"aabbccdd",
"firmware":"7u7u7u7u", // base64 → six 0xEE bytes
"read_vid_cdb":"3c014410e29100002400"
}]
})
.to_string(),
)
.unwrap();
let s = format!("{:?}", profiles.mt1959_a[0]);
// Firmware byte 0xEE renders as `238` under a derived Debug.
assert!(!s.contains("238"), "firmware bytes must not appear: {s}");
assert!(
!s.contains("read_vid_cdb"),
"CDB templates must not appear: {s}"
);
assert!(s.contains("redacted"), "must mark redaction: {s}");
// The PUBLIC fields stay visible.
assert!(s.contains("VIS"), "identity must remain visible: {s}");
}
// deserialize_hex4("") must produce [0;4]. Mutation: treating empty as
// an error blocks profiles with no captured signature from loading.
#[test]
fn profile_empty_signature_deserialises_as_zeroes() {
use serde_json::json;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
assert_eq!(
profiles.mt1959_a[0].signature, [0u8; 4],
"empty signature must deserialise as [0;4]"
);
}
}
#[path = "profile_tests.rs"]
mod tests;
+445
View File
@@ -0,0 +1,445 @@
use super::*;
use crate::DriveId;
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
DriveId {
vendor_id: vendor.to_string(),
product_id: String::new(),
product_revision: rev.to_string(),
vendor_specific: vs.to_string(),
firmware_date: date.to_string(),
}
}
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
/// the full pass binds to the one whose product matches; the looser passes
/// would return the first regardless.
#[test]
fn find_by_drive_id_product_id_breaks_a_tie() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
"product_revision":"1.00","vendor_specific":"XX00000",
"firmware_date":"200001010000"},
"signature":"aaaaaaaa","firmware":""},
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
"product_revision":"1.00","vendor_specific":"XX00000",
"firmware_date":"200001010000"},
"signature":"bbbbbbbb","firmware":""}
]
})
.to_string(),
)
.unwrap();
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
id.product_id = "MODEL-B".to_string();
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(
m.profile.signature,
[0xbb, 0xbb, 0xbb, 0xbb],
"product_id must select MODEL-B over the first entry"
);
// No product_id → falls back to the 4-field pass → first entry.
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
// A REPORTED product_id that matches no cataloged entry must NOT fall
// through to the four-field pass and bind to a sibling (MODEL-A/B);
// an uncataloged variant is no match, not a wrong match.
let mut idc = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
idc.product_id = "MODEL-C".to_string();
assert!(
find_by_drive_id(&profiles, &idc).is_none(),
"an uncataloged product_id must not mis-bind to a same-vendor sibling",
);
}
/// REGRESSION (1.7.0): a real drive reports a SPECIFIC product_id (e.g.
/// "BD-RE BU40N") while the catalog stores a GENERIC one ("BD-RE"), so the
/// exact pass misses. When the four-field identity is UNIQUE it must still
/// bind — the pre-1.7.0 behaviour a UHD LG BU40N relies on for LibreDrive
/// unlock. 1.7.0 gated the four-field pass behind an empty product_id, so it
/// silently stopped matching and the firmware unlocker was skipped entirely.
#[test]
fn find_by_drive_id_specific_product_id_binds_unique_four_field() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [
{"identity": {"vendor_id":"HL-DT-ST","product_id":"BD-RE",
"product_revision":"1.03","vendor_specific":"NM00000",
"firmware_date":"211810241934"},
"signature":"12345678","firmware":""}
]
})
.to_string(),
)
.unwrap();
// Drive reports the specific marketing product_id; catalog has "BD-RE".
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
id.product_id = "BD-RE BU40N".to_string();
let m = find_by_drive_id(&profiles, &id)
.expect("unique four-field identity must match despite the product_id mismatch");
assert_eq!(m.profile.signature, [0x12, 0x34, 0x56, 0x78]);
assert_eq!(m.platform, Platform::Mt1959A);
}
/// An exact product_id match in a LATER section must beat a four-field
/// fallback in an earlier one; fallback uniqueness spans all sections.
#[test]
fn find_by_drive_id_exact_match_beats_earlier_section_fallback() {
use serde_json::json;
let entry = |prod: &str, sig: &str| {
json!({"identity": {"vendor_id":"HL-DT-ST","product_id":prod,
"product_revision":"1.03","vendor_specific":"NM00000",
"firmware_date":"211810241934"},
"signature":sig,"firmware":""})
};
let profiles: Profiles = serde_json::from_value(json!({
"mt1959_a": [entry("BD-RE", "aaaaaaaa")],
"mt1959_b": [entry("BD-RE BU40N", "bbbbbbbb")],
}))
.unwrap();
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
id.product_id = "BD-RE BU40N".to_string();
let m = find_by_drive_id(&profiles, &id).expect("B's exact match");
assert_eq!(m.platform, Platform::Mt1959B);
assert_eq!(m.profile.signature, [0xbb, 0xbb, 0xbb, 0xbb]);
// Tuple shared across sections + uncataloged product_id: no match.
id.product_id = "BD-RE WH16NS".to_string();
assert!(find_by_drive_id(&profiles, &id).is_none());
// No product_id: shared tuple binds the first section's entry.
id.product_id.clear();
let m0 = find_by_drive_id(&profiles, &id).expect("empty product_id binds");
assert_eq!(m0.platform, Platform::Mt1959A);
}
#[test]
fn test_find_known_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
assert_eq!(m.platform, Platform::Mt1959A);
}
#[test]
fn test_find_unknown_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("FAKE-VND", "9.99", "XX12345", "");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
#[test]
fn decode_hex_rejects_non_ascii_without_panic() {
// A multi-byte char of even byte-length must not slice inside a
// char boundary; it must decode-fail gracefully.
assert!(decode_hex("中中").is_err()); // 6 bytes, none hex
assert!(parse_hex4("中中").is_err()); // 6 bytes != 8 anyway
// An 8-byte non-ASCII string (two 4-byte chars) hits the exact-len
// path of parse_hex4; must still error, not panic.
assert!(parse_hex4("𝕏𝕏").is_err());
}
#[test]
fn decode_hex_roundtrips_valid_hex() {
assert_eq!(decode_hex("00ff10").unwrap(), vec![0x00, 0xff, 0x10]);
assert_eq!(parse_hex4("deadbeef").unwrap(), [0xde, 0xad, 0xbe, 0xef]);
assert!(decode_hex("abc").is_err()); // odd length
assert!(decode_hex("zz").is_err()); // non-hex
}
#[test]
fn bundled_is_cached_and_matches_fresh_parse() {
let cached = bundled().expect("bundled profiles parse");
let fresh = load_bundled().unwrap();
// Same data either way (compare section sizes — Profiles isn't Eq).
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
// Cached accessor returns a stable address across calls.
let a = bundled().unwrap() as *const Profiles;
let b = bundled().unwrap() as *const Profiles;
assert_eq!(a, b);
}
#[test]
fn find_bundled_matches_known_drive() {
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_bundled(&id).unwrap();
assert_eq!(m.platform, Platform::Mt1959A);
}
// ── New comprehensive tests ────────────────────────────────────────────────
/// decode_hex accepts empty string → empty Vec.
/// Mutation: returning an error on empty input breaks empty-field handling.
#[test]
fn decode_hex_accepts_empty_string() {
assert_eq!(decode_hex("").unwrap(), Vec::<u8>::new());
}
/// decode_hex handles all valid hex digit characters (0-9, a-f, A-F).
/// Mutation: not supporting uppercase A-F means uppercase-encoded profiles fail.
#[test]
fn decode_hex_handles_upper_and_lower_case() {
assert_eq!(
decode_hex("DEADBEEF").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("deadbeef").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("DeAdBeEf").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
}
/// parse_hex4 rejects an 8-hex-char string (4 bytes) correctly.
/// Spec: the signature field is exactly 4 bytes = 8 hex chars.
/// Mutation: accepting 6 hex chars (3 bytes) would pass a wrong-length signature.
#[test]
fn parse_hex4_rejects_wrong_byte_length() {
// 6 hex chars = 3 bytes ≠ 4.
assert!(
parse_hex4("aabbcc").is_err(),
"3 bytes must be rejected for 4-byte field"
);
// 10 hex chars = 5 bytes ≠ 4.
assert!(
parse_hex4("aabbccddee").is_err(),
"5 bytes must be rejected for 4-byte field"
);
// Exactly 8 hex chars = 4 bytes: must succeed.
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
}
// Platform::name() strings are logged/keyed by callers; changing them is
// a breaking change. Mutation: swapping A/B names misroutes firmware upload.
#[test]
fn platform_name_is_stable() {
// The exact strings are part of the public stable API (logged/keyed).
assert_eq!(Platform::Mt1959A.name(), "MediaTek MT1959-A");
assert_eq!(Platform::Mt1959B.name(), "MediaTek MT1959-B");
assert_eq!(Platform::Renesas.name(), "Renesas");
}
// find_by_drive_id: two-pass — exact match (incl. firmware_date) wins
// over loose. Mutation: skipping the exact pass returns the first entry
// regardless of date.
#[test]
fn find_by_drive_id_exact_date_wins_over_loose() {
use serde_json::json;
// Use an 8-char vendor_id (padded with a trailing space so `trim()` strips
// the pad, matching the same trimmed form the JSON profile stores).
// "TESTDRV " fills INQUIRY [8..16] exactly; `ascii_field.trim()` → "TESTDRV".
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200001010000"
},
"signature": "aabbccdd",
"firmware": ""
},
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200006150000"
},
"signature": "11223344",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
let id_date2 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200006150000");
let m1 = find_by_drive_id(&profiles, &id_date1).unwrap();
let m2 = find_by_drive_id(&profiles, &id_date2).unwrap();
// Each must bind to its own profile by exact date match.
assert_eq!(
m1.profile.signature,
[0xaa, 0xbb, 0xcc, 0xdd],
"id_date1 must match first profile"
);
assert_eq!(
m2.profile.signature,
[0x11, 0x22, 0x33, 0x44],
"id_date2 must match second profile"
);
}
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
/// the wrong same-model variant.
#[test]
fn find_by_drive_id_no_match_when_date_differs() {
use serde_json::json;
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "LOOSEDR",
"product_revision": "2.00",
"vendor_specific": "YY11111",
"firmware_date": "210101010000"
},
"signature": "deadbeef",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// Same vendor/rev/vs but a different date — must NOT match.
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
/// Mutation: returning an empty Profiles instead of an error silently
/// leaves the drive-profile database empty.
#[test]
fn load_from_str_returns_profile_parse_on_bad_json() {
let result: Result<Profiles> =
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
assert!(matches!(result, Err(Error::ProfileParse)));
}
// Pins the embedded JSON: if profiles.json is emptied/truncated, this
// goes red.
#[test]
fn bundled_profiles_has_entries() {
let profiles = load_bundled().unwrap();
assert!(
!profiles.mt1959_a.is_empty(),
"bundled profiles must have at least one mt1959_a entry"
);
}
// All CDB fields are `#[serde(default)]`. Mutation: making one required
// breaks backward-compat with old blobs.
#[test]
fn profile_optional_cdb_fields_default_to_none() {
use serde_json::json;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "00000000",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
let p = &profiles.mt1959_a[0]; // DriveProfile directly
// All optional CDB fields must be None when absent from JSON.
assert!(
p.read_vid_cdb.is_none(),
"read_vid_cdb must default to None"
);
assert!(
p.read_disc_keys_cdb.is_none(),
"read_disc_keys_cdb must default to None"
);
assert!(
p.drive_nominal_speed_cdb.is_none(),
"drive_nominal_speed_cdb must default to None"
);
assert!(
p.set_speed_max_cdb.is_none(),
"set_speed_max_cdb must default to None"
);
assert!(
p.speed_zone_table.is_none(),
"speed_zone_table must default to None"
);
assert!(
p.speed_calc_table.is_none(),
"speed_calc_table must default to None"
);
}
// `Debug` must not render firmware/CDB bytes. Mutation: restoring
// `#[derive(Debug)]` prints them (e.g. the 0xEE marker below), goes red.
#[test]
fn drive_profile_debug_redacts_firmware_and_cdbs() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [{
"identity": {"vendor_id":"VIS","product_revision":"1.00",
"vendor_specific":"AA00000","firmware_date":"200001010000"},
"signature":"aabbccdd",
"firmware":"7u7u7u7u", // base64 → six 0xEE bytes
"read_vid_cdb":"3c014410e29100002400"
}]
})
.to_string(),
)
.unwrap();
let s = format!("{:?}", profiles.mt1959_a[0]);
// Firmware byte 0xEE renders as `238` under a derived Debug.
assert!(!s.contains("238"), "firmware bytes must not appear: {s}");
assert!(
!s.contains("read_vid_cdb"),
"CDB templates must not appear: {s}"
);
assert!(s.contains("redacted"), "must mark redaction: {s}");
// The PUBLIC fields stay visible.
assert!(s.contains("VIS"), "identity must remain visible: {s}");
}
// deserialize_hex4("") must produce [0;4]. Mutation: treating empty as
// an error blocks profiles with no captured signature from loading.
#[test]
fn profile_empty_signature_deserialises_as_zeroes() {
use serde_json::json;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
assert_eq!(
profiles.mt1959_a[0].signature, [0u8; 4],
"empty signature must deserialise as [0;4]"
);
}
+2 -101
View File
@@ -214,104 +214,5 @@ pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
}
#[cfg(test)]
mod tests {
use super::*;
// 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()),
]
}
#[test]
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();
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:?}"),
}
}
}
/// `unlocker_name` is a PURE lookup — it must answer from the `DriveId`
/// alone, and only the identity-keyed unlocker can claim a drive this way.
#[test]
fn unlocker_name_is_a_pure_identity_lookup() {
assert_eq!(unlocker_name(&DriveId::default()), None);
}
// `bus_key`/`vid` are key material; `Debug` must NOT print those bytes.
// MUTATION: restoring `#[derive(Debug)]` prints the raw byte arrays, so
// the marker bytes appear in the output and this test goes red.
#[test]
fn unlocked_debug_redacts_key_material() {
let u = Unlocked {
vid: Some([0xAB; 16]),
bus_key: Some([0xCD; 16]),
};
let s = format!("{u:?}");
// A derived Debug renders `[171, 171, ...]` (0xAB) / `[205, ...]` (0xCD).
assert!(!s.contains("171"), "vid bytes must not appear: {s}");
assert!(!s.contains("205"), "bus_key bytes must not appear: {s}");
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}");
}
// `vid`/`bus_key` leave the crate in `Unlocked`; wipe them (and every
// clone) on drop. Fails to compile if the derive is removed.
#[test]
fn unlocked_zeroizes_on_drop() {
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<Unlocked>();
}
// `private_key`/`private_key_v2` are the raw host private keys; `Debug` must
// NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte
// arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red.
#[test]
fn host_cert_debug_redacts_private_keys() {
let c = HostCert {
private_key: [0xAB; 20],
certificate: vec![0x01, 0x02],
private_key_v2: Some([0xCD; 32]),
certificate_v2: None,
};
let s = format!("{c:?}");
assert!(!s.contains("171"), "private_key bytes must not appear: {s}");
assert!(
!s.contains("205"),
"private_key_v2 bytes must not appear: {s}"
);
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
// Presence of the v2 key stays observable.
assert!(
s.contains("Some"),
"must still show the v2 key WAS present: {s}"
);
}
}
#[path = "lib_tests.rs"]
mod tests;
+99
View File
@@ -0,0 +1,99 @@
use super::*;
// 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()),
]
}
#[test]
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();
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:?}"),
}
}
}
/// `unlocker_name` is a PURE lookup — it must answer from the `DriveId`
/// alone, and only the identity-keyed unlocker can claim a drive this way.
#[test]
fn unlocker_name_is_a_pure_identity_lookup() {
assert_eq!(unlocker_name(&DriveId::default()), None);
}
// `bus_key`/`vid` are key material; `Debug` must NOT print those bytes.
// MUTATION: restoring `#[derive(Debug)]` prints the raw byte arrays, so
// the marker bytes appear in the output and this test goes red.
#[test]
fn unlocked_debug_redacts_key_material() {
let u = Unlocked {
vid: Some([0xAB; 16]),
bus_key: Some([0xCD; 16]),
};
let s = format!("{u:?}");
// A derived Debug renders `[171, 171, ...]` (0xAB) / `[205, ...]` (0xCD).
assert!(!s.contains("171"), "vid bytes must not appear: {s}");
assert!(!s.contains("205"), "bus_key bytes must not appear: {s}");
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}");
}
// `vid`/`bus_key` leave the crate in `Unlocked`; wipe them (and every
// clone) on drop. Fails to compile if the derive is removed.
#[test]
fn unlocked_zeroizes_on_drop() {
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<Unlocked>();
}
// `private_key`/`private_key_v2` are the raw host private keys; `Debug` must
// NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte
// arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red.
#[test]
fn host_cert_debug_redacts_private_keys() {
let c = HostCert {
private_key: [0xAB; 20],
certificate: vec![0x01, 0x02],
private_key_v2: Some([0xCD; 32]),
certificate_v2: None,
};
let s = format!("{c:?}");
assert!(!s.contains("171"), "private_key bytes must not appear: {s}");
assert!(
!s.contains("205"),
"private_key_v2 bytes must not appear: {s}"
);
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
// Presence of the v2 key stays observable.
assert!(
s.contains("Some"),
"must still show the v2 key WAS present: {s}"
);
}
+73 -374
View File
@@ -2,15 +2,16 @@
//!
//! Optical drives split into two controller families: MediaTek (handled by
//! [`crate::ld`]) and Renesas. This module identifies the Renesas side via a
//! single vendor identity probe (see [`is_renesas`]) and reports the match so a
//! Renesas drive is named honestly. It does not modify drive state; AACS bus
//! decryption is handled by the host cert.
//! single vendor identity probe (see [`is_renesas`]). Renesas drives are
//! OEM-unlocked: identity establishes unlocked status without a host certificate.
//! Vendor register access is used only for optional VID retrieval; a read
//! miss preserves unlocked status, while transport faults and cancellation abort.
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
/// The Renesas vendor identity block length (READ_BUFFER 0x02/0xF1).
const RB_F1_LEN: usize = pioneer_optical::IDENTITY_LEN as usize;
const RB_F1_LEN: usize = pioneer_optical::IDENTITY_LEN;
/// The ASCII interface marker a Renesas controller returns at `[16..19]`.
const RENESAS_MARKER: &[u8] = b"SAT";
const RENESAS_MARKER_OFFSET: usize = 16;
@@ -26,12 +27,12 @@ const RENESAS_MARKER_OFFSET: usize = 16;
/// `Err(Transport)` on a dead bus.
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
let mut buf = [0u8; RB_F1_LEN];
let cdb = pioneer_optical::vendor_identity();
let cdb = pioneer_optical::cdb::vendor_identity();
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => {
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
Ok(r.status == 0
&& r.bytes_transferred >= end
&& (end..=buf.len()).contains(&r.bytes_transferred)
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER)
}
// Only a senseless transport-failure status is a dead bus; anything
@@ -56,11 +57,8 @@ pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, Unl
}
}
/// 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.
/// Recognizes OEM-unlocked Renesas drives and optionally reads their VID from
/// hardware registers. This does not enable standard AACS VID reads.
#[derive(Default)]
pub struct Renesas;
@@ -68,53 +66,68 @@ impl Renesas {
pub fn new() -> Self {
Renesas
}
// MakeMKV's vendor "open" sequence: primary read (A), and on refusal a
// knock + second read (B).
fn vendor_open(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
// A: primary open read.
if read_is_good(scsi, &pioneer_optical::read_memory(0x04, OPEN_READ_LEN))? {
return Ok(true);
}
// B: MakeMKV's fallback — knock, then the second-window read. The knock is
// fire-and-forget (payload-less); its own status is not the signal.
match scsi.execute(
&pioneer_optical::knock(),
DataDirection::None,
&mut [],
5_000,
) {
Ok(_) => {}
Err(e) if is_dead_bus(&e) => return Err(UnlockError::Transport),
Err(_) => {} // a drive that refuses the knock still gets the B read tried
}
read_is_good(
scsi,
&pioneer_optical::read_memory(0x50_0000, OPEN_READ_LEN),
)
}
}
/// The vendor "open" reads' length (0xA4 = 164 bytes).
const OPEN_READ_LEN: u32 = 0xA4;
// Selector 0x92 maps offset - 0x2000 to high CPU memory; no AAAA gate.
const VID_STATUS_CDB: [u8; 10] = [0x3c, 2, 0x92, 0, 0x0d, 0x3c, 0, 0, 1, 0];
const VID_CDB: [u8; 10] = [0x3c, 2, 0x92, 0, 0x0d, 0x20, 0, 0, 16, 0];
/// Issue a 164-byte vendor READ_BUFFER; `Ok(true)` on GOOD status, `Ok(false)`
/// on CHECK CONDITION (drive refused), `Err(Transport)` on a senseless
/// transport failure (dead bus).
fn read_is_good(
scsi: &mut dyn ScsiTransport,
cdb: &[u8; 10],
) -> std::result::Result<bool, UnlockError> {
let mut buf = [0u8; OPEN_READ_LEN as usize];
match scsi.execute(cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => Ok(r.status == 0),
Err(e) => {
if is_dead_bus(&e) {
return Err(UnlockError::Transport);
}
Ok(false)
}
fn get_vid(scsi: &mut dyn ScsiTransport) -> std::result::Result<Option<[u8; 16]>, UnlockError> {
// Mirror the OEM hardware-copy gate. Other status bits are not interpreted.
let mut status = [0];
let result = scsi.execute(
&VID_STATUS_CDB,
DataDirection::FromDevice,
&mut status,
5_000,
);
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_status",
cdb = ?VID_STATUS_CDB, requested = 1, ?result, payload = ?status,
"Reading Renesas VID hardware status");
match result {
Ok(r) if r.status == 0 && r.bytes_transferred == 1 && status[0] & 2 != 0 => {}
Err(e) if is_dead_bus(&e) => return Err(UnlockError::Transport),
_ => return Ok(None),
}
let mut vid = [0u8; 16];
let cdb = VID_CDB;
let address = 0xffff_ed20u32;
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_request",
address = format_args!("{address:#x}"), cdb = format_args!("{cdb:02x?}"), requested = vid.len(), "Reading VID from Renesas hardware registers");
let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut vid, 5_000) {
Ok(r) => r,
Err(e) => {
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_error",
address = format_args!("{address:#x}"), status = e.status, sense = ?e.sense,
dead_bus = is_dead_bus(&e),
"Renesas hardware VID read failed");
return if is_dead_bus(&e) {
Err(UnlockError::Transport)
} else {
Ok(None)
};
}
};
let rejection = if result.status != 0 {
Some("scsi_status")
} else if result.bytes_transferred != vid.len() {
Some("transfer_length")
} else if vid.iter().all(|&b| b == 0) {
Some("all_zero")
} else if vid.iter().all(|&b| b == 0xff) {
Some("all_ff")
} else {
None
};
let valid = rejection.is_none();
// Only show bytes the transport reports receiving, never buffer padding.
let payload = &vid[..result.bytes_transferred.min(vid.len())];
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_result",
address = format_args!("{address:#x}"), status = result.status,
bytes_transferred = result.bytes_transferred, sense = ?result.sense,
payload = format_args!("{payload:02x?}"), valid, rejection,
"Renesas hardware VID response");
Ok(valid.then_some(vid))
}
impl Unlocker for Renesas {
@@ -122,12 +135,8 @@ impl Unlocker for Renesas {
"Renesas"
}
/// 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.
/// SAT identity establishes OEM-unlocked status. Hardware VID reads are
/// best-effort; a missing VID preserves success, dead buses abort.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
@@ -136,327 +145,17 @@ impl Unlocker for Renesas {
if !is_renesas(scsi)? {
return Ok(None);
}
if !Self::vendor_open(scsi)? {
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_open_rejected",
"Renesas drive recognized but RB 0xB0@0x04 refused; deferring to next unlocker"
);
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)?;
let vid = get_vid(scsi)?;
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_opened",
has_vid = vid.is_some(),
"Renesas drive opened (RB 0xB0@0x04 GOOD, raw reads enabled)"
"Renesas VID discovery completed"
);
Ok(Some(Unlocked { vid, bus_key: None }))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DiscKind;
use crate::scsi::{DataDirection, Result, ScsiError, ScsiResult, ScsiTransport};
/// Serves a fixed READ_BUFFER payload (Renesas-like) with a Good status.
struct RenesasTransport {
payload: Vec<u8>,
}
impl ScsiTransport for RenesasTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
// Rejects like a MediaTek drive: ILLEGAL REQUEST with a sense, which is
// what distinguishes this from a dead bus.
struct RejectingTransport;
impl ScsiTransport for RejectingTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let mut sense = [0u8; 32];
sense[2] = 0x05; // ILLEGAL REQUEST
sense[12] = 0x20; // invalid command operation code
Err(ScsiError {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(sense),
})
}
}
fn renesas_payload() -> Vec<u8> {
// 48-byte RB 0xF1 block with "SAT" at [16..19] (the real S13JX shape).
let mut p = vec![0x20u8; 48];
p[16..19].copy_from_slice(b"SAT");
p
}
#[test]
fn is_renesas_true_on_sat_marker() {
let mut t = RenesasTransport {
payload: renesas_payload(),
};
assert!(is_renesas(&mut t).expect("no transport fault"));
}
#[test]
fn is_renesas_false_when_command_rejected() {
let mut t = RejectingTransport;
assert!(!is_renesas(&mut t).expect("a drive rejection is not a bus fault"));
}
#[test]
fn is_renesas_false_on_missing_marker() {
// Good status but no "SAT" at [16..19] (e.g. a stray buffer).
let mut t = RenesasTransport {
payload: vec![0u8; 48],
};
assert!(!is_renesas(&mut t).expect("no transport fault"));
}
#[test]
fn is_renesas_false_on_short_response() {
// Fewer than 19 bytes returned — can't carry the marker.
let mut t = RenesasTransport {
payload: vec![0x20u8; 8],
};
assert!(!is_renesas(&mut t).expect("no transport fault"));
}
#[test]
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 + 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" → `Ok(false)`, fall through to the next unlocker.
#[test]
fn declines_non_renesas() {
let mut t = RejectingTransport;
let id = crate::DriveId::default();
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);
// must reach the same answer.
#[test]
fn check_condition_is_not_a_renesas_drive() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
assert!(!is_renesas(&mut t).expect("a drive sense is not a bus fault"));
}
/// THE defect-8 test: a dead bus on the FIRST command the unlocker issues
/// must abort the consumer, not be reported as "not a Renesas drive".
/// Catches restoring the `Err(_) => false` arm.
#[test]
fn transport_fault_aborts_instead_of_declining() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(is_renesas(&mut t).unwrap_err(), UnlockError::Transport);
let mut t = MockTransport::always(Reply::TransportFault);
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert_eq!(
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
}
// A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but
// whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the
// knock CDB has to be issued BETWEEN A and B.
#[test]
fn knock_runs_between_a_and_b_and_b_opens() {
// The exact knock CDB the code issues (pinned wire format).
const KNOCK_A5AAAA_CDB: [u8; 10] =
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
struct AFailsBSucceeds {
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for AFailsBSucceeds {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Gate 0xF1 → SAT identity.
if cdb.get(2) == Some(&0xF1) {
let p = renesas_payload();
let n = p.len().min(data.len());
data[..n].copy_from_slice(&p[..n]);
return Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
});
}
// A read (RB 0xB0@0x04) → refuse with a drive sense.
if cdb.get(2) == Some(&0xB0) && cdb.get(3) == Some(&0x00) {
let mut sense = [0u8; 32];
sense[2] = 0x05; // ILLEGAL REQUEST
sense[12] = 0x20;
return Err(ScsiError {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(sense),
});
}
// B read (RB 0xB0@0x50), the knock (0x3B/0x41), and the 0xAD VID
// read all return GOOD so the open + VID read succeed.
let n = data.len().min(36);
if !data.is_empty() {
// Non-zero VID so read_aacs_vid yields Some.
for b in data.iter_mut().take(n) {
*b = 0x5A;
}
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
let mut t = AFailsBSucceeds { cdbs: Vec::new() };
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
let out = Renesas::new()
.unlock(&mut t, &ctx)
.expect("no fault")
.expect("B read opens the drive → unlocked");
assert_eq!(out.bus_key, None);
// The knock must appear, between the A read (0xB0@0x04) and the B read
// (0xB0@0x50).
let a = t
.cdbs
.iter()
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x00))
.expect("A read issued");
let knock = t
.cdbs
.iter()
.position(|c| c.as_slice() == KNOCK_A5AAAA_CDB)
.expect("knock CDB issued");
let b = t
.cdbs
.iter()
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x50))
.expect("B read issued");
assert!(a < knock && knock < b, "knock must run between A and B");
}
/// A dead bus only at the knock (recovered afterwards) still aborts with
/// Transport: the B read must never be sent.
#[test]
fn dead_bus_at_the_knock_aborts_before_the_b_read() {
use crate::scsi::mock::{MockTransport, Reply};
let script = vec![
Reply::good(renesas_payload()),
Reply::illegal_request(),
Reply::TransportFault,
];
let mut t = MockTransport::scripted(script, Reply::good(vec![0u8; 164]));
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert_eq!(
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
assert_eq!(t.calls(), 3, "no B read after the dead knock");
}
// A recognized Renesas drive that REFUSES the vendor open read must
// defer to the next unlocker, not claim the drive.
#[test]
fn open_rejection_defers_to_next_unlocker() {
struct GateOkOpenRefused;
impl ScsiTransport for GateOkOpenRefused {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
if cdb.get(2) == Some(&0xF1) {
// Serve the SAT identity so is_renesas() matches.
let p = renesas_payload();
let n = p.len().min(data.len());
data[..n].copy_from_slice(&p[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
} else {
// RB 0xB0@0x04 (the open read) → CHECK CONDITION.
let mut sense = [0u8; 32];
sense[2] = 0x05; // ILLEGAL REQUEST
sense[12] = 0x20; // invalid command operation code
Err(ScsiError {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(sense),
})
}
}
}
let mut t = GateOkOpenRefused;
let id = crate::DriveId::default();
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"
);
}
}
#[path = "mod_tests.rs"]
mod mod_tests;
+346
View File
@@ -0,0 +1,346 @@
use super::*;
mod tests {
use super::*;
use crate::DiscKind;
use crate::scsi::{DataDirection, Result, ScsiError, ScsiResult, ScsiTransport};
#[test]
fn memory_vid_rejects_invalid_responses_and_propagates_dead_bus() {
use crate::scsi::mock::{MockTransport, Reply};
for reply in [
Reply::zero_transfer(16),
Reply::short(vec![0x25; 16], 15),
Reply::good(vec![0; 16]),
Reply::good(vec![0xff; 16]),
Reply::illegal_request(),
Reply::illegal_request_as_err(),
] {
let mut t =
MockTransport::scripted(vec![Reply::good(vec![2]), reply], Reply::TransportFault);
assert_eq!(get_vid(&mut t).unwrap(), None);
}
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(get_vid(&mut t).unwrap_err(), UnlockError::Transport);
}
#[test]
fn memory_vid_rejects_failed_status_with_payload_and_impossible_length() {
struct Response {
status: u8,
transferred: usize,
}
impl ScsiTransport for Response {
fn execute(
&mut self,
_: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout: u32,
) -> Result<ScsiResult> {
assert_eq!(dir, DataDirection::FromDevice);
if data.len() == 1 {
data[0] = 2;
return Ok(ScsiResult {
status: 0,
bytes_transferred: 1,
sense: [0; 32],
});
}
assert_eq!(data.len(), 16);
assert_eq!(timeout, 5_000);
data.fill(0x25);
Ok(ScsiResult {
status: self.status,
bytes_transferred: self.transferred,
sense: [0; 32],
})
}
}
for (status, transferred) in [(2, 16), (0, 17), (0, usize::MAX)] {
assert_eq!(
get_vid(&mut Response {
status,
transferred
})
.unwrap(),
None
);
}
}
#[test]
fn cancellation_after_identity_aborts_before_register_read() {
use crate::scsi::mock::{MockTransport, Reply, StopFake};
let mut t = StopFake::new(MockTransport::always(Reply::good(renesas_payload())));
t.cancel_after = Some(|cdb| cdb == pioneer_optical::cdb::vendor_identity());
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert_eq!(
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
assert_eq!(t.inner.calls(), 1);
}
#[test]
fn vendor_cdbs_are_the_wire_bytes() {
assert_eq!(
pioneer_optical::cdb::vendor_identity(),
[0x3C, 0x02, 0xF1, 0, 0, 0, 0, 0, 0x30, 0]
);
assert_eq!(
pioneer_optical::cdb::read_memory(0x50_0000, 0x10),
[0x3C, 0x02, 0xB0, 0x50, 0, 0, 0, 0, 0x10, 0]
);
assert_eq!(
pioneer_optical::cdb::knock(),
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0, 0, 0, 0]
);
assert_eq!(RB_F1_LEN, 48);
}
/// Serves a fixed READ_BUFFER payload (Renesas-like) with a Good status.
struct RenesasTransport {
payload: Vec<u8>,
}
impl ScsiTransport for RenesasTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
// Rejects like a MediaTek drive: ILLEGAL REQUEST with a sense, which is
// what distinguishes this from a dead bus.
struct RejectingTransport;
impl ScsiTransport for RejectingTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let mut sense = [0u8; 32];
sense[2] = 0x05; // ILLEGAL REQUEST
sense[12] = 0x20; // invalid command operation code
Err(ScsiError {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(sense),
})
}
}
fn renesas_payload() -> Vec<u8> {
// 48-byte RB 0xF1 block with "SAT" at [16..19] (the real S13JX shape).
let mut p = vec![0x20u8; 48];
p[16..19].copy_from_slice(b"SAT");
p
}
#[test]
fn is_renesas_true_on_sat_marker() {
let mut t = RenesasTransport {
payload: renesas_payload(),
};
assert!(is_renesas(&mut t).expect("no transport fault"));
}
#[test]
fn is_renesas_false_when_command_rejected() {
let mut t = RejectingTransport;
assert!(!is_renesas(&mut t).expect("a drive rejection is not a bus fault"));
}
#[test]
fn is_renesas_false_on_missing_marker() {
// Good status but no "SAT" at [16..19] (e.g. a stray buffer).
let mut t = RenesasTransport {
payload: vec![0u8; 48],
};
assert!(!is_renesas(&mut t).expect("no transport fault"));
}
#[test]
fn is_renesas_false_on_short_response() {
// Fewer than 19 bytes returned — can't carry the marker.
let mut t = RenesasTransport {
payload: vec![0x20u8; 8],
};
assert!(!is_renesas(&mut t).expect("no transport fault"));
}
#[test]
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, but the hardware status bit is clear.
let out = Renesas::new()
.unlock(&mut t, &ctx)
.expect("no fault")
.expect("renesas → unlocked");
assert!(
out.vid.is_none(),
"clear hardware status must not fabricate a VID"
);
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" → `Ok(false)`, fall through to the next unlocker.
#[test]
fn declines_non_renesas() {
let mut t = RejectingTransport;
let id = crate::DriveId::default();
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);
// must reach the same answer.
#[test]
fn check_condition_is_not_a_renesas_drive() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
assert!(!is_renesas(&mut t).expect("a drive sense is not a bus fault"));
}
/// THE defect-8 test: a dead bus on the FIRST command the unlocker issues
/// must abort the consumer, not be reported as "not a Renesas drive".
/// Catches restoring the `Err(_) => false` arm.
#[test]
fn transport_fault_aborts_instead_of_declining() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(is_renesas(&mut t).unwrap_err(), UnlockError::Transport);
let mut t = MockTransport::always(Reply::TransportFault);
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert_eq!(
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
}
}
mod hardware_tests {
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
#[test]
fn reads_fresh_vid_with_status_mask_and_exact_wire_commands() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![3]),
Reply::good(vec![0x25; 16]),
Reply::good(vec![7]),
Reply::good(vec![0x42; 16]),
],
Reply::TransportFault,
);
assert_eq!(get_vid(&mut t).unwrap(), Some([0x25; 16]));
assert_eq!(get_vid(&mut t).unwrap(), Some([0x42; 16]));
assert_eq!(
t.cdbs,
vec![
vec![0x3c, 2, 0x92, 0, 0x0d, 0x3c, 0, 0, 1, 0],
vec![0x3c, 2, 0x92, 0, 0x0d, 0x20, 0, 0, 16, 0],
VID_STATUS_CDB.to_vec(),
VID_CDB.to_vec(),
]
);
}
#[test]
fn invalid_status_stops_before_vid() {
for reply in [
Reply::good(vec![0]),
Reply::good(vec![1]),
Reply::zero_transfer(1),
Reply::short(vec![2], 2),
Reply::illegal_request(),
Reply::illegal_request_as_err(),
] {
let mut t = MockTransport::scripted(vec![reply], Reply::TransportFault);
assert_eq!(get_vid(&mut t).unwrap(), None);
assert_eq!(t.calls(), 1);
}
}
#[test]
fn unlock_preserves_oem_success_on_rejections_and_aborts_transport_faults() {
let mut identity = vec![0; 48];
identity[16..19].copy_from_slice(b"SAT");
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, crate::DiscKind::Unknown);
for step in 1..=2 {
for reply in [
Reply::illegal_request(),
Reply::illegal_request_as_err(),
Reply::TransportFault,
] {
let fault = matches!(reply, Reply::TransportFault);
let mut replies = vec![
Reply::good(identity.clone()),
Reply::good(vec![2]),
Reply::good(vec![0x25; 16]),
];
replies[step] = reply;
let mut t = MockTransport::scripted(replies, Reply::TransportFault);
let result = Renesas::new().unlock(&mut t, &ctx);
if fault {
assert_eq!(result.unwrap_err(), UnlockError::Transport);
} else {
let out = result.unwrap().unwrap();
assert_eq!(out.vid, None);
assert_eq!(out.bus_key, None);
}
assert_eq!(t.calls(), step + 1);
}
}
let mut t = MockTransport::scripted(
vec![
Reply::good(identity),
Reply::good(vec![2]),
Reply::good(vec![0x25; 16]),
],
Reply::TransportFault,
);
assert_eq!(
Renesas::new().unlock(&mut t, &ctx).unwrap().unwrap().vid,
Some([0x25; 16])
);
assert_eq!(t.calls(), 3);
assert!(t.cdbs.iter().all(|cdb| cdb[0] == 0x3c));
}
#[test]
fn cancellation_after_status_prevents_vid_command() {
use crate::scsi::mock::StopFake;
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![2])));
t.cancel_after = Some(|cdb| cdb == VID_STATUS_CDB);
assert_eq!(get_vid(&mut t).unwrap_err(), UnlockError::Transport);
assert_eq!(t.inner.calls(), 1);
}
}
+4 -485
View File
@@ -276,490 +276,9 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
// transport, able to express all three transport outcomes.
#[cfg(test)]
#[allow(dead_code)] // a fixture: each helper is used by a subset of the modules
pub(crate) mod mock {
use super::*;
use std::collections::VecDeque;
/// One scripted answer to one `execute()` call.
#[derive(Debug, Clone)]
pub(crate) enum Reply {
/// `Ok` + GOOD status. `payload` is copied into the caller's buffer;
/// `bytes_transferred` defaults to the number of bytes copied.
Data {
payload: Vec<u8>,
bytes_transferred: Option<usize>,
},
/// `Ok` + a NON-ZERO SCSI status (CHECK CONDITION) with a drive sense.
/// Per the contract at [`ScsiTransport::execute`] this is NOT a
/// transport fault — the caller must inspect `status`, and a caller that
/// doesn't will consume the caller's zero-filled buffer as drive data.
Sense {
status: u8,
sense_key: u8,
asc: u8,
ascq: u8,
},
/// `Err` with the transport-failure status and no sense — a genuine
/// transport-layer fault (bridge crash / disconnect). MUST abort.
TransportFault,
/// `Err` carrying a real status + parsed sense. This is what a
/// NON-conforming transport does (libfreemkv's adapter returns `Err` for
/// any non-zero SCSI status); classification must still treat it as a
/// drive rejection, not a dead bus.
ErrWithSense {
status: u8,
sense_key: u8,
asc: u8,
ascq: u8,
},
}
impl Reply {
/// `Ok`, GOOD status, full transfer of `payload`.
pub(crate) fn good(payload: Vec<u8>) -> Reply {
Reply::Data {
payload,
bytes_transferred: None,
}
}
/// `Ok`, GOOD status, but the drive only delivered `n` bytes.
pub(crate) fn short(payload: Vec<u8>, n: usize) -> Reply {
Reply::Data {
payload,
bytes_transferred: Some(n),
}
}
/// `Ok`, GOOD status, ZERO bytes delivered — the buffer the caller reads
/// is entirely its own zero fill.
pub(crate) fn zero_transfer(len: usize) -> Reply {
Reply::Data {
payload: vec![0u8; len],
bytes_transferred: Some(0),
}
}
/// `Ok` + CHECK CONDITION / ILLEGAL REQUEST (0x05, ASC 0x20 invalid
/// command) — the ordinary way a drive refuses a vendor command.
pub(crate) fn illegal_request() -> Reply {
Reply::Sense {
status: SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}
}
/// `Err` + CHECK CONDITION / ILLEGAL REQUEST — the same drive refusal as
/// seen through a non-conforming transport.
pub(crate) fn illegal_request_as_err() -> Reply {
Reply::ErrWithSense {
status: SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}
}
}
fn sense_buf(sense_key: u8, asc: u8, ascq: u8) -> [u8; 32] {
let mut b = [0u8; 32];
b[2] = sense_key & 0x0F;
b[12] = asc;
b[13] = ascq;
b
}
/// A scripted transport: answers each `execute()` from `script`, falling
/// back to `default` once the script runs out, and records every CDB.
pub(crate) struct MockTransport {
pub(crate) script: VecDeque<Reply>,
pub(crate) default: Reply,
pub(crate) cdbs: Vec<Vec<u8>>,
}
impl MockTransport {
/// Every command gets the same answer.
pub(crate) fn always(reply: Reply) -> Self {
MockTransport {
script: VecDeque::new(),
default: reply,
cdbs: Vec::new(),
}
}
/// The first N commands follow `script`; the rest get `default`.
pub(crate) fn scripted(script: Vec<Reply>, default: Reply) -> Self {
MockTransport {
script: script.into(),
default,
cdbs: Vec::new(),
}
}
/// How many CDBs were issued (lets a test assert a dead bus aborted
/// instead of retrying).
pub(crate) fn calls(&self) -> usize {
self.cdbs.len()
}
}
impl ScsiTransport for MockTransport {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
let reply = self
.script
.pop_front()
.unwrap_or_else(|| self.default.clone());
match reply {
Reply::Data {
payload,
bytes_transferred,
} => {
let n = payload.len().min(data.len());
data[..n].copy_from_slice(&payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: bytes_transferred.unwrap_or(n),
sense: [0u8; 32],
})
}
Reply::Sense {
status,
sense_key,
asc,
ascq,
} => Ok(ScsiResult {
status,
bytes_transferred: 0,
sense: sense_buf(sense_key, asc, ascq),
}),
Reply::TransportFault => Err(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
Reply::ErrWithSense {
status,
sense_key,
asc,
ascq,
} => Err(ScsiError {
status,
sense: Some(sense_buf(sense_key, asc, ascq)),
}),
}
}
}
/// One transport call as seen by [`StopFake`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Ev {
/// `execute` reached the drive.
Exec(Vec<u8>),
/// `execute` refused because the operation was cancelled.
Refused(Vec<u8>),
/// `execute_cleanup` reached the drive.
Cleanup(Vec<u8>),
Pause(std::time::Duration),
PauseRefused(std::time::Duration),
Begin,
BeginRefused,
End,
}
/// A cancellable host, as libfreemkv's adapter behaves (stop-design-v5 §2.3,
/// §2.4): after a cancel, `execute` refuses with 0xFF / no sense unless a
/// critical span opened BEFORE the cancel is still open; `begin_critical` and
/// `pause` fail; `execute_cleanup` still runs. Wraps a drive emulator `inner`.
pub(crate) struct StopFake<T> {
pub(crate) inner: T,
pub(crate) cancelled: bool,
critical: u32,
/// Cancel right after a CDB matching this predicate reaches the drive
/// (a Stop that lands while that CDB is in flight).
pub(crate) cancel_after: Option<fn(&[u8]) -> bool>,
/// Cancel when the next `pause` starts (a Stop during the wait).
pub(crate) cancel_on_pause: bool,
pub(crate) log: Vec<Ev>,
}
fn refused() -> ScsiError {
ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}
}
impl<T> StopFake<T> {
pub(crate) fn new(inner: T) -> Self {
StopFake {
inner,
cancelled: false,
critical: 0,
cancel_after: None,
cancel_on_pause: false,
log: Vec::new(),
}
}
/// CDBs that reached the drive through `execute`.
pub(crate) fn execs(&self) -> Vec<&Vec<u8>> {
self.log
.iter()
.filter_map(|e| match e {
Ev::Exec(c) => Some(c),
_ => None,
})
.collect()
}
/// CDBs that reached the drive through `execute_cleanup`.
pub(crate) fn cleanups(&self) -> Vec<&Vec<u8>> {
self.log
.iter()
.filter_map(|e| match e {
Ev::Cleanup(c) => Some(c),
_ => None,
})
.collect()
}
}
impl<T: ScsiTransport> ScsiTransport for StopFake<T> {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
if self.cancelled && self.critical == 0 {
self.log.push(Ev::Refused(cdb.to_vec()));
return Err(refused());
}
self.log.push(Ev::Exec(cdb.to_vec()));
let r = self.inner.execute(cdb, dir, data, timeout_ms);
if self.cancel_after.is_some_and(|p| p(cdb)) {
self.cancelled = true;
}
r
}
fn pause(&mut self, d: std::time::Duration) -> Result<()> {
if self.cancel_on_pause {
self.cancelled = true;
}
if self.cancelled {
self.log.push(Ev::PauseRefused(d));
return Err(refused());
}
self.log.push(Ev::Pause(d)); // no real sleep: tests run in microseconds
Ok(())
}
fn begin_critical(&mut self) -> Result<()> {
if self.cancelled {
self.log.push(Ev::BeginRefused);
return Err(refused());
}
self.critical += 1;
self.log.push(Ev::Begin);
Ok(())
}
fn end_critical(&mut self) {
self.critical -= 1;
self.log.push(Ev::End);
}
fn execute_cleanup(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
self.log.push(Ev::Cleanup(cdb.to_vec()));
self.inner.execute(cdb, dir, data, timeout_ms)
}
}
/// REPORT KEY (0xA4) key format 0x3F (AGID invalidate / release).
pub(crate) fn is_agid_release(cdb: &[u8]) -> bool {
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x3F)
}
/// REPORT KEY (0xA4) key format 0x00 (AGID allocation).
pub(crate) fn is_agid_alloc(cdb: &[u8]) -> bool {
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x00)
}
/// SS-7 (evidence): every allocated AGID is released exactly once, through
/// `execute_cleanup`, before the next allocation; `execute` never releases a
/// held AGID (it only invalidates beforehand). Returns (allocations, held at end).
pub(crate) fn agid_ledger(log: &[Ev]) -> (usize, bool) {
let (mut allocs, mut held) = (0usize, false);
for (i, e) in log.iter().enumerate() {
match e {
Ev::Exec(c) if is_agid_alloc(c) => {
assert!(!held, "event {i}: allocated while still holding an AGID");
held = true;
allocs += 1;
}
Ev::Exec(c) if is_agid_release(c) => {
assert!(!held, "event {i}: a held AGID was released via execute");
}
Ev::Cleanup(c) => {
assert!(
is_agid_release(c),
"event {i}: cleanup of a non-release CDB"
);
assert!(held, "event {i}: released an AGID that was not held");
held = false;
}
_ => {}
}
}
(allocs, held)
}
}
#[path = "scsi_mock_tests.rs"]
pub(crate) mod mock;
#[cfg(test)]
mod tests {
use super::*;
use mock::{MockTransport, Reply};
/// The fixed-format sense layout the whole crate reads its diagnostics off
/// (key at byte 2 low nibble, ASC at 12, ASCQ at 13). This file had no test
/// at all, so nothing pinned the offsets.
#[test]
fn sense_parses_at_the_fixed_format_offsets() {
let mut buf = [0u8; 32];
buf[2] = 0xF5; // sense key is the LOW nibble only
buf[12] = 0x24;
buf[13] = 0x01;
let s = ScsiSense::from_buf(&buf);
assert_eq!(s.sense_key, 0x05);
assert_eq!(s.asc, 0x24);
assert_eq!(s.ascq, 0x01);
assert!(s.is_illegal_request());
buf[2] = 0x02; // NOT READY
assert!(!ScsiSense::from_buf(&buf).is_illegal_request());
}
/// SET CD SPEED encodes the read speed big-endian at bytes 2-3 and leaves
/// the write speed at 0xFFFF.
#[test]
fn set_cd_speed_encodes_the_read_speed_big_endian() {
let cdb = build_set_cd_speed(0x1234);
assert_eq!(cdb[0], SCSI_SET_CD_SPEED);
assert_eq!([cdb[2], cdb[3]], [0x12, 0x34]);
assert_eq!([cdb[4], cdb[5]], [0xFF, 0xFF]);
assert_eq!(build_set_cd_speed(0xFFFF)[2..4], [0xFF, 0xFF]);
}
// The fixture must honour the contract it tests: a drive sense is `Ok`
// with non-zero status, a bus fault is `Err`. If this drifts, every
// transport-contract test built on it silently stops testing anything.
#[test]
fn mock_transport_expresses_the_three_contract_outcomes() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0xAB; 4]),
Reply::illegal_request(),
Reply::zero_transfer(4),
],
Reply::TransportFault,
);
let mut buf = [0u8; 4];
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("data reply is Ok");
assert_eq!(r.status, 0);
assert_eq!(r.bytes_transferred, 4);
assert_eq!(buf, [0xAB; 4]);
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("A DRIVE SENSE IS Ok, NOT Err — the load-bearing contract");
assert_eq!(r.status, SCSI_STATUS_CHECK_CONDITION);
assert!(ScsiSense::from_buf(&r.sense).is_illegal_request());
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("a zero-length transfer is still Ok");
assert_eq!(r.bytes_transferred, 0);
let e = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect_err("a transport fault is Err");
assert_eq!(e.status, SCSI_STATUS_TRANSPORT_FAILURE);
assert!(e.sense.is_none(), "a bus fault carries no drive sense");
assert_eq!(t.calls(), 4);
}
// UT1 (stop-design-v5 §5.2; §2.3 "`enter` calls `begin_critical()?`. `Drop` calls
// `end_critical`"): refused after a cancel; closed on drop AND on unwind.
#[test]
fn critical_guard_refused_after_cancel_and_ends_on_unwind() {
use mock::{Ev, StopFake};
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 4])));
{
let mut g = CriticalGuard::enter(&mut t).expect("not cancelled: opens");
g.execute(&[0x3B], DataDirection::ToDevice, &mut [0u8; 4], 0)
.expect("inside the span");
}
assert_eq!(t.log[0], Ev::Begin);
assert_eq!(t.log.last(), Some(&Ev::End), "closed on drop");
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = CriticalGuard::enter(&mut t).expect("opens");
panic!("unwind inside the span");
}));
assert!(r.is_err());
assert_eq!(t.log.last(), Some(&Ev::End), "closed on unwind");
t.cancelled = true;
let e = CriticalGuard::enter(&mut t)
.err()
.expect("refused after a cancel");
assert!(is_dead_bus(&e), "a refusal is the dead-bus shape");
assert_eq!(t.log.last(), Some(&Ev::BeginRefused), "no span, so no End");
let begins = t.log.iter().filter(|e| **e == Ev::Begin).count();
let ends = t.log.iter().filter(|e| **e == Ev::End).count();
assert_eq!(begins, ends, "every opened span closed exactly once");
}
// The defaults keep all 32 existing transports compiling and behaving as
// before: pause sleeps, spans are no-ops, cleanup is a plain execute.
#[test]
fn default_stop_methods_preserve_plain_transport_behaviour() {
let mut t = MockTransport::always(Reply::good(vec![0u8; 2]));
let t0 = std::time::Instant::now();
t.pause(std::time::Duration::from_millis(20))
.expect("plain sleep");
assert!(t0.elapsed() >= std::time::Duration::from_millis(20));
t.begin_critical().expect("no-op");
t.end_critical();
t.execute_cleanup(&[0xA4], DataDirection::FromDevice, &mut [0u8; 2], 0)
.expect("plain execute");
assert_eq!(t.cdbs, vec![vec![0xA4]], "cleanup reached the drive once");
}
// SS-7 (evidence): the guard's drop sends its REPORT KEY 3Fh CDB once, via
// execute_cleanup, even after a cancel; `defuse` sends nothing.
#[test]
fn agid_guard_releases_once_via_cleanup_and_defuse_keeps_it() {
use mock::{Ev, StopFake};
let mut rel = [0u8; 12];
rel[0] = SCSI_REPORT_KEY;
rel[10] = (2 << 6) | 0x3F;
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 8])));
t.cancelled = true;
drop(AgidGuard::new(&mut t, 2, rel));
assert_eq!(t.log, vec![Ev::Cleanup(rel.to_vec())]);
t.log.clear();
let g = AgidGuard::new(&mut t, 2, rel);
assert_eq!(g.agid(), 2);
assert_eq!(g.defuse(), 2);
assert!(t.log.is_empty(), "defuse hands the AGID over unreleased");
}
}
#[path = "scsi_tests.rs"]
mod tests;
+339
View File
@@ -0,0 +1,339 @@
use super::*;
use std::collections::VecDeque;
/// One scripted answer to one `execute()` call.
#[derive(Debug, Clone)]
pub(crate) enum Reply {
/// `Ok` + GOOD status. `payload` is copied into the caller's buffer;
/// `bytes_transferred` defaults to the number of bytes copied.
Data {
payload: Vec<u8>,
bytes_transferred: Option<usize>,
},
/// `Ok` + a NON-ZERO SCSI status (CHECK CONDITION) with a drive sense.
/// Per the contract at [`ScsiTransport::execute`] this is NOT a
/// transport fault — the caller must inspect `status`, and a caller that
/// doesn't will consume the caller's zero-filled buffer as drive data.
Sense {
status: u8,
sense_key: u8,
asc: u8,
ascq: u8,
},
/// `Err` with the transport-failure status and no sense — a genuine
/// transport-layer fault (bridge crash / disconnect). MUST abort.
TransportFault,
/// `Err` carrying a real status + parsed sense. This is what a
/// NON-conforming transport does (libfreemkv's adapter returns `Err` for
/// any non-zero SCSI status); classification must still treat it as a
/// drive rejection, not a dead bus.
ErrWithSense {
status: u8,
sense_key: u8,
asc: u8,
ascq: u8,
},
}
impl Reply {
/// `Ok`, GOOD status, full transfer of `payload`.
pub(crate) fn good(payload: Vec<u8>) -> Reply {
Reply::Data {
payload,
bytes_transferred: None,
}
}
/// `Ok`, GOOD status, but the drive only delivered `n` bytes.
pub(crate) fn short(payload: Vec<u8>, n: usize) -> Reply {
Reply::Data {
payload,
bytes_transferred: Some(n),
}
}
/// `Ok`, GOOD status, ZERO bytes delivered — the buffer the caller reads
/// is entirely its own zero fill.
pub(crate) fn zero_transfer(len: usize) -> Reply {
Reply::Data {
payload: vec![0u8; len],
bytes_transferred: Some(0),
}
}
/// `Ok` + CHECK CONDITION / ILLEGAL REQUEST (0x05, ASC 0x20 invalid
/// command) — the ordinary way a drive refuses a vendor command.
pub(crate) fn illegal_request() -> Reply {
Reply::Sense {
status: SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}
}
/// `Err` + CHECK CONDITION / ILLEGAL REQUEST — the same drive refusal as
/// seen through a non-conforming transport.
pub(crate) fn illegal_request_as_err() -> Reply {
Reply::ErrWithSense {
status: SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}
}
}
fn sense_buf(sense_key: u8, asc: u8, ascq: u8) -> [u8; 32] {
let mut b = [0u8; 32];
b[2] = sense_key & 0x0F;
b[12] = asc;
b[13] = ascq;
b
}
/// A scripted transport: answers each `execute()` from `script`, falling
/// back to `default` once the script runs out, and records every CDB.
pub(crate) struct MockTransport {
pub(crate) script: VecDeque<Reply>,
pub(crate) default: Reply,
pub(crate) cdbs: Vec<Vec<u8>>,
}
impl MockTransport {
/// Every command gets the same answer.
pub(crate) fn always(reply: Reply) -> Self {
MockTransport {
script: VecDeque::new(),
default: reply,
cdbs: Vec::new(),
}
}
/// The first N commands follow `script`; the rest get `default`.
pub(crate) fn scripted(script: Vec<Reply>, default: Reply) -> Self {
MockTransport {
script: script.into(),
default,
cdbs: Vec::new(),
}
}
/// How many CDBs were issued (lets a test assert a dead bus aborted
/// instead of retrying).
pub(crate) fn calls(&self) -> usize {
self.cdbs.len()
}
}
impl ScsiTransport for MockTransport {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
let reply = self
.script
.pop_front()
.unwrap_or_else(|| self.default.clone());
match reply {
Reply::Data {
payload,
bytes_transferred,
} => {
let n = payload.len().min(data.len());
data[..n].copy_from_slice(&payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: bytes_transferred.unwrap_or(n),
sense: [0u8; 32],
})
}
Reply::Sense {
status,
sense_key,
asc,
ascq,
} => Ok(ScsiResult {
status,
bytes_transferred: 0,
sense: sense_buf(sense_key, asc, ascq),
}),
Reply::TransportFault => Err(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
Reply::ErrWithSense {
status,
sense_key,
asc,
ascq,
} => Err(ScsiError {
status,
sense: Some(sense_buf(sense_key, asc, ascq)),
}),
}
}
}
/// One transport call as seen by [`StopFake`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Ev {
/// `execute` reached the drive.
Exec(Vec<u8>),
/// `execute` refused because the operation was cancelled.
Refused(Vec<u8>),
/// `execute_cleanup` reached the drive.
Cleanup(Vec<u8>),
Pause(std::time::Duration),
PauseRefused(std::time::Duration),
Begin,
BeginRefused,
End,
}
/// A cancellable host, as libfreemkv's adapter behaves (stop-design-v5 §2.3,
/// §2.4): after a cancel, `execute` refuses with 0xFF / no sense unless a
/// critical span opened BEFORE the cancel is still open; `begin_critical` and
/// `pause` fail; `execute_cleanup` still runs. Wraps a drive emulator `inner`.
pub(crate) struct StopFake<T> {
pub(crate) inner: T,
pub(crate) cancelled: bool,
critical: u32,
/// Cancel right after a CDB matching this predicate reaches the drive
/// (a Stop that lands while that CDB is in flight).
pub(crate) cancel_after: Option<fn(&[u8]) -> bool>,
/// Cancel when the next `pause` starts (a Stop during the wait).
pub(crate) cancel_on_pause: bool,
pub(crate) log: Vec<Ev>,
}
fn refused() -> ScsiError {
ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}
}
impl<T> StopFake<T> {
pub(crate) fn new(inner: T) -> Self {
StopFake {
inner,
cancelled: false,
critical: 0,
cancel_after: None,
cancel_on_pause: false,
log: Vec::new(),
}
}
/// CDBs that reached the drive through `execute`.
pub(crate) fn execs(&self) -> Vec<&Vec<u8>> {
self.log
.iter()
.filter_map(|e| match e {
Ev::Exec(c) => Some(c),
_ => None,
})
.collect()
}
/// CDBs that reached the drive through `execute_cleanup`.
pub(crate) fn cleanups(&self) -> Vec<&Vec<u8>> {
self.log
.iter()
.filter_map(|e| match e {
Ev::Cleanup(c) => Some(c),
_ => None,
})
.collect()
}
}
impl<T: ScsiTransport> ScsiTransport for StopFake<T> {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
if self.cancelled && self.critical == 0 {
self.log.push(Ev::Refused(cdb.to_vec()));
return Err(refused());
}
self.log.push(Ev::Exec(cdb.to_vec()));
let r = self.inner.execute(cdb, dir, data, timeout_ms);
if self.cancel_after.is_some_and(|p| p(cdb)) {
self.cancelled = true;
}
r
}
fn pause(&mut self, d: std::time::Duration) -> Result<()> {
if self.cancel_on_pause {
self.cancelled = true;
}
if self.cancelled {
self.log.push(Ev::PauseRefused(d));
return Err(refused());
}
self.log.push(Ev::Pause(d)); // no real sleep: tests run in microseconds
Ok(())
}
fn begin_critical(&mut self) -> Result<()> {
if self.cancelled {
self.log.push(Ev::BeginRefused);
return Err(refused());
}
self.critical += 1;
self.log.push(Ev::Begin);
Ok(())
}
fn end_critical(&mut self) {
self.critical -= 1;
self.log.push(Ev::End);
}
fn execute_cleanup(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
self.log.push(Ev::Cleanup(cdb.to_vec()));
self.inner.execute(cdb, dir, data, timeout_ms)
}
}
/// REPORT KEY (0xA4) key format 0x3F (AGID invalidate / release).
pub(crate) fn is_agid_release(cdb: &[u8]) -> bool {
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x3F)
}
/// REPORT KEY (0xA4) key format 0x00 (AGID allocation).
pub(crate) fn is_agid_alloc(cdb: &[u8]) -> bool {
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x00)
}
/// SS-7 (evidence): every allocated AGID is released exactly once, through
/// `execute_cleanup`, before the next allocation; `execute` never releases a
/// held AGID (it only invalidates beforehand). Returns (allocations, held at end).
pub(crate) fn agid_ledger(log: &[Ev]) -> (usize, bool) {
let (mut allocs, mut held) = (0usize, false);
for (i, e) in log.iter().enumerate() {
match e {
Ev::Exec(c) if is_agid_alloc(c) => {
assert!(!held, "event {i}: allocated while still holding an AGID");
held = true;
allocs += 1;
}
Ev::Exec(c) if is_agid_release(c) => {
assert!(!held, "event {i}: a held AGID was released via execute");
}
Ev::Cleanup(c) => {
assert!(
is_agid_release(c),
"event {i}: cleanup of a non-release CDB"
);
assert!(held, "event {i}: released an AGID that was not held");
held = false;
}
_ => {}
}
}
(allocs, held)
}
+142
View File
@@ -0,0 +1,142 @@
use super::*;
use mock::{MockTransport, Reply};
/// The fixed-format sense layout the whole crate reads its diagnostics off
/// (key at byte 2 low nibble, ASC at 12, ASCQ at 13). This file had no test
/// at all, so nothing pinned the offsets.
#[test]
fn sense_parses_at_the_fixed_format_offsets() {
let mut buf = [0u8; 32];
buf[2] = 0xF5; // sense key is the LOW nibble only
buf[12] = 0x24;
buf[13] = 0x01;
let s = ScsiSense::from_buf(&buf);
assert_eq!(s.sense_key, 0x05);
assert_eq!(s.asc, 0x24);
assert_eq!(s.ascq, 0x01);
assert!(s.is_illegal_request());
buf[2] = 0x02; // NOT READY
assert!(!ScsiSense::from_buf(&buf).is_illegal_request());
}
/// SET CD SPEED encodes the read speed big-endian at bytes 2-3 and leaves
/// the write speed at 0xFFFF.
#[test]
fn set_cd_speed_encodes_the_read_speed_big_endian() {
let cdb = build_set_cd_speed(0x1234);
assert_eq!(cdb[0], SCSI_SET_CD_SPEED);
assert_eq!([cdb[2], cdb[3]], [0x12, 0x34]);
assert_eq!([cdb[4], cdb[5]], [0xFF, 0xFF]);
assert_eq!(build_set_cd_speed(0xFFFF)[2..4], [0xFF, 0xFF]);
}
// The fixture must honour the contract it tests: a drive sense is `Ok`
// with non-zero status, a bus fault is `Err`. If this drifts, every
// transport-contract test built on it silently stops testing anything.
#[test]
fn mock_transport_expresses_the_three_contract_outcomes() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0xAB; 4]),
Reply::illegal_request(),
Reply::zero_transfer(4),
],
Reply::TransportFault,
);
let mut buf = [0u8; 4];
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("data reply is Ok");
assert_eq!(r.status, 0);
assert_eq!(r.bytes_transferred, 4);
assert_eq!(buf, [0xAB; 4]);
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("A DRIVE SENSE IS Ok, NOT Err — the load-bearing contract");
assert_eq!(r.status, SCSI_STATUS_CHECK_CONDITION);
assert!(ScsiSense::from_buf(&r.sense).is_illegal_request());
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("a zero-length transfer is still Ok");
assert_eq!(r.bytes_transferred, 0);
let e = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect_err("a transport fault is Err");
assert_eq!(e.status, SCSI_STATUS_TRANSPORT_FAILURE);
assert!(e.sense.is_none(), "a bus fault carries no drive sense");
assert_eq!(t.calls(), 4);
}
// UT1 (stop-design-v5 §5.2; §2.3 "`enter` calls `begin_critical()?`. `Drop` calls
// `end_critical`"): refused after a cancel; closed on drop AND on unwind.
#[test]
fn critical_guard_refused_after_cancel_and_ends_on_unwind() {
use mock::{Ev, StopFake};
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 4])));
{
let mut g = CriticalGuard::enter(&mut t).expect("not cancelled: opens");
g.execute(&[0x3B], DataDirection::ToDevice, &mut [0u8; 4], 0)
.expect("inside the span");
}
assert_eq!(t.log[0], Ev::Begin);
assert_eq!(t.log.last(), Some(&Ev::End), "closed on drop");
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = CriticalGuard::enter(&mut t).expect("opens");
panic!("unwind inside the span");
}));
assert!(r.is_err());
assert_eq!(t.log.last(), Some(&Ev::End), "closed on unwind");
t.cancelled = true;
let e = CriticalGuard::enter(&mut t)
.err()
.expect("refused after a cancel");
assert!(is_dead_bus(&e), "a refusal is the dead-bus shape");
assert_eq!(t.log.last(), Some(&Ev::BeginRefused), "no span, so no End");
let begins = t.log.iter().filter(|e| **e == Ev::Begin).count();
let ends = t.log.iter().filter(|e| **e == Ev::End).count();
assert_eq!(begins, ends, "every opened span closed exactly once");
}
// The defaults keep all 32 existing transports compiling and behaving as
// before: pause sleeps, spans are no-ops, cleanup is a plain execute.
#[test]
fn default_stop_methods_preserve_plain_transport_behaviour() {
let mut t = MockTransport::always(Reply::good(vec![0u8; 2]));
let t0 = std::time::Instant::now();
t.pause(std::time::Duration::from_millis(20))
.expect("plain sleep");
assert!(t0.elapsed() >= std::time::Duration::from_millis(20));
t.begin_critical().expect("no-op");
t.end_critical();
t.execute_cleanup(&[0xA4], DataDirection::FromDevice, &mut [0u8; 2], 0)
.expect("plain execute");
assert_eq!(t.cdbs, vec![vec![0xA4]], "cleanup reached the drive once");
}
// SS-7 (evidence): the guard's drop sends its REPORT KEY 3Fh CDB once, via
// execute_cleanup, even after a cancel; `defuse` sends nothing.
#[test]
fn agid_guard_releases_once_via_cleanup_and_defuse_keeps_it() {
use mock::{Ev, StopFake};
let mut rel = [0u8; 12];
rel[0] = SCSI_REPORT_KEY;
rel[10] = (2 << 6) | 0x3F;
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 8])));
t.cancelled = true;
drop(AgidGuard::new(&mut t, 2, rel));
assert_eq!(t.log, vec![Ev::Cleanup(rel.to_vec())]);
t.log.clear();
let g = AgidGuard::new(&mut t, 2, rel);
assert_eq!(g.agid(), 2);
assert_eq!(g.defuse(), 2);
assert!(t.log.is_empty(), "defuse hands the AGID over unreleased");
}
+4 -92
View File
@@ -1,9 +1,7 @@
//! 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.
//! already been opened by a compatible firmware unlock (freemkv Raw Read or
//! MT1959). Renesas uses its separate vendor-memory VID reader.
//!
//! 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
@@ -97,91 +95,5 @@ pub fn read_aacs_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);
}
/// A rejection surfaced as `Err` WITH a sense (a non-conforming transport)
/// is a drive refusal, not a dead bus → `Ok(None)` VID-miss, never
/// `Err(Transport)`.
#[test]
fn err_with_sense_is_a_vid_miss_not_transport() {
let mut t = MockTransport::always(Reply::illegal_request_as_err());
assert_eq!(
read_aacs_vid(&mut t).expect("a sense is not a dead bus"),
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);
}
}
#[path = "vid_tests.rs"]
mod tests;
+86
View File
@@ -0,0 +1,86 @@
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);
}
/// A rejection surfaced as `Err` WITH a sense (a non-conforming transport)
/// is a drive refusal, not a dead bus → `Ok(None)` VID-miss, never
/// `Err(Transport)`.
#[test]
fn err_with_sense_is_a_vid_miss_not_transport() {
let mut t = MockTransport::always(Reply::illegal_request_as_err());
assert_eq!(
read_aacs_vid(&mut t).expect("a sense is not a dead bus"),
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);
}