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