Compare commits

..
Author SHA1 Message Date
Matthew Jackson
3965b16740 v1.8.1: bump version (unified release) 2026-10-07 03:56:36 +00:00
Matthew Jackson
22431979a2 docs: 1.8.1 release notes 2026-10-06 20:19:11 -07:00
Matthew Jackson
bead4869a9 Read Renesas VID directly from hardware registers 2026-10-05 22:28:46 -07:00
Matthew Jackson
d64c4b3e55 Move tests to side files 2026-10-05 20:22:40 -07:00
Matthew Jackson
1970113a82 feat(renesas): discover optional VID slot and harden OEM unlock 2026-10-05 20:08:04 -07:00
Matthew Jackson
6008d2daa7 fix(renesas): read VID from vendor memory slot 2026-10-05 19:07:21 -07:00
Matthew Jackson
5b690bf34f pioneer-optical 0.10 (0.7 yanked); vendor CDB bytes unchanged and pinned by a test 2026-10-04 23:33:12 -07:00
36 changed files with 8904 additions and 8885 deletions

View file

@ -1,5 +1,15 @@
# Changelog
## [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

18
Cargo.lock generated
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"

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"

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
src/aacs/error_tests.rs Normal file
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"),
}
}

File diff suppressed because it is too large Load diff

3366
src/aacs/handshake_tests.rs Normal file

File diff suppressed because it is too large Load diff

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
src/aacs/mod_tests.rs Normal file
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()
);
}

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
src/css/error_tests.rs Normal file
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"),
}
}

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
src/css/mod_tests.rs Normal file
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"
);
}

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
src/freemkv/mod_tests.rs Normal file
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");
}

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
src/ld/cdb_tests.rs Normal file
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));
}

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
src/ld/error_tests.rs Normal file
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]);
}

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;

Some files were not shown because too many files have changed in this diff Show more