Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3965b16740 | ||
|
|
22431979a2 | ||
|
|
bead4869a9 | ||
|
|
d64c4b3e55 | ||
|
|
1970113a82 | ||
|
|
6008d2daa7 | ||
|
|
5b690bf34f |
36 changed files with 8904 additions and 8885 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -1,5 +1,15 @@
|
|||
# Changelog
|
||||
|
||||
## [1.8.1] — 2026-10-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Renesas drives: more reliable VID reading from the drive, including drives with an optional VID slot, and a hardened OEM unlock.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- `pioneer-optical` 0.10 (the 0.7 line is yanked). The vendor CDB bytes are unchanged, and a test pins them.
|
||||
|
||||
## [1.8.0] — 2026-10-05
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -106,143 +106,5 @@ impl From<crate::scsi::ScsiError> for Error {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every variant has its own stable numeric code. Constructing all 17
|
||||
/// arms also exercises the `#[allow(dead_code)]` variants that the
|
||||
/// wired handshake path never builds.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
|
||||
assert_eq!(Error::AacsCertRead.code(), 7002);
|
||||
assert_eq!(Error::AacsCertRejected.code(), 7003);
|
||||
assert_eq!(Error::AacsCertShort.code(), 7004);
|
||||
assert_eq!(Error::AacsCertVerify.code(), 7005);
|
||||
assert_eq!(Error::AacsDataKey.code(), 7006);
|
||||
assert_eq!(Error::AacsKeyRead.code(), 7007);
|
||||
assert_eq!(Error::AacsKeyRejected.code(), 7008);
|
||||
assert_eq!(Error::AacsKeyVerify.code(), 7009);
|
||||
assert_eq!(Error::AacsHostSignVerify.code(), 7016);
|
||||
assert_eq!(Error::AacsNoKeys.code(), 7010);
|
||||
assert_eq!(Error::AacsVidMac.code(), 7011);
|
||||
assert_eq!(Error::AacsVidRead.code(), 7012);
|
||||
assert_eq!(Error::HandshakeRejected.code(), 7013);
|
||||
assert_eq!(Error::VidUnavailable.code(), 7014);
|
||||
assert_eq!(
|
||||
Error::ShortTransfer {
|
||||
opcode: 0xA4,
|
||||
expected: 16,
|
||||
got: 4,
|
||||
}
|
||||
.code(),
|
||||
7015
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
7099
|
||||
);
|
||||
}
|
||||
|
||||
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
|
||||
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
|
||||
/// variant.
|
||||
#[test]
|
||||
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
|
||||
let sense = ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
};
|
||||
let with_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(sense),
|
||||
};
|
||||
let sc = with_sense.scsi_sense().unwrap();
|
||||
assert_eq!(sc.sense_key, sense.sense_key);
|
||||
assert_eq!(sc.asc, sense.asc);
|
||||
assert_eq!(sc.ascq, sense.ascq);
|
||||
|
||||
let without_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0xFF,
|
||||
sense: None,
|
||||
};
|
||||
assert!(without_sense.scsi_sense().is_none());
|
||||
|
||||
assert!(Error::AacsNoKeys.scsi_sense().is_none());
|
||||
}
|
||||
|
||||
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
|
||||
/// transport-failure status AND no sense; a drive CHECK CONDITION and
|
||||
/// every non-`Scsi` variant are false.
|
||||
#[test]
|
||||
fn is_scsi_transport_failure_true_and_false_paths() {
|
||||
let transport = Error::Scsi {
|
||||
opcode: 0,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(transport.is_scsi_transport_failure());
|
||||
|
||||
let check_condition = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
};
|
||||
assert!(!check_condition.is_scsi_transport_failure());
|
||||
|
||||
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
|
||||
/// parsing the sense buffer when present and leaving it `None` otherwise.
|
||||
#[test]
|
||||
fn from_scsi_error_maps_status_and_parses_sense() {
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
match e {
|
||||
Error::Scsi {
|
||||
opcode,
|
||||
status,
|
||||
sense,
|
||||
} => {
|
||||
assert_eq!(opcode, 0);
|
||||
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
|
||||
assert!(sense.is_none());
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 32];
|
||||
buf[2] = 0x05;
|
||||
buf[12] = 0x24;
|
||||
buf[13] = 0x01;
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: 0x02,
|
||||
sense: Some(buf),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi { status, sense, .. } => {
|
||||
assert_eq!(status, 0x02);
|
||||
let s = sense.expect("sense parsed");
|
||||
assert_eq!(s.sense_key, 0x05);
|
||||
assert_eq!(s.asc, 0x24);
|
||||
assert_eq!(s.ascq, 0x01);
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "error_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
138
src/aacs/error_tests.rs
Normal file
138
src/aacs/error_tests.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
use super::*;
|
||||
|
||||
/// Every variant has its own stable numeric code. Constructing all 17
|
||||
/// arms also exercises the `#[allow(dead_code)]` variants that the
|
||||
/// wired handshake path never builds.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
|
||||
assert_eq!(Error::AacsCertRead.code(), 7002);
|
||||
assert_eq!(Error::AacsCertRejected.code(), 7003);
|
||||
assert_eq!(Error::AacsCertShort.code(), 7004);
|
||||
assert_eq!(Error::AacsCertVerify.code(), 7005);
|
||||
assert_eq!(Error::AacsDataKey.code(), 7006);
|
||||
assert_eq!(Error::AacsKeyRead.code(), 7007);
|
||||
assert_eq!(Error::AacsKeyRejected.code(), 7008);
|
||||
assert_eq!(Error::AacsKeyVerify.code(), 7009);
|
||||
assert_eq!(Error::AacsHostSignVerify.code(), 7016);
|
||||
assert_eq!(Error::AacsNoKeys.code(), 7010);
|
||||
assert_eq!(Error::AacsVidMac.code(), 7011);
|
||||
assert_eq!(Error::AacsVidRead.code(), 7012);
|
||||
assert_eq!(Error::HandshakeRejected.code(), 7013);
|
||||
assert_eq!(Error::VidUnavailable.code(), 7014);
|
||||
assert_eq!(
|
||||
Error::ShortTransfer {
|
||||
opcode: 0xA4,
|
||||
expected: 16,
|
||||
got: 4,
|
||||
}
|
||||
.code(),
|
||||
7015
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
7099
|
||||
);
|
||||
}
|
||||
|
||||
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
|
||||
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
|
||||
/// variant.
|
||||
#[test]
|
||||
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
|
||||
let sense = ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
};
|
||||
let with_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(sense),
|
||||
};
|
||||
let sc = with_sense.scsi_sense().unwrap();
|
||||
assert_eq!(sc.sense_key, sense.sense_key);
|
||||
assert_eq!(sc.asc, sense.asc);
|
||||
assert_eq!(sc.ascq, sense.ascq);
|
||||
|
||||
let without_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0xFF,
|
||||
sense: None,
|
||||
};
|
||||
assert!(without_sense.scsi_sense().is_none());
|
||||
|
||||
assert!(Error::AacsNoKeys.scsi_sense().is_none());
|
||||
}
|
||||
|
||||
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
|
||||
/// transport-failure status AND no sense; a drive CHECK CONDITION and
|
||||
/// every non-`Scsi` variant are false.
|
||||
#[test]
|
||||
fn is_scsi_transport_failure_true_and_false_paths() {
|
||||
let transport = Error::Scsi {
|
||||
opcode: 0,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(transport.is_scsi_transport_failure());
|
||||
|
||||
let check_condition = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
};
|
||||
assert!(!check_condition.is_scsi_transport_failure());
|
||||
|
||||
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
|
||||
/// parsing the sense buffer when present and leaving it `None` otherwise.
|
||||
#[test]
|
||||
fn from_scsi_error_maps_status_and_parses_sense() {
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
match e {
|
||||
Error::Scsi {
|
||||
opcode,
|
||||
status,
|
||||
sense,
|
||||
} => {
|
||||
assert_eq!(opcode, 0);
|
||||
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
|
||||
assert!(sense.is_none());
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 32];
|
||||
buf[2] = 0x05;
|
||||
buf[12] = 0x24;
|
||||
buf[13] = 0x01;
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: 0x02,
|
||||
sense: Some(buf),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi { status, sense, .. } => {
|
||||
assert_eq!(status, 0x02);
|
||||
let s = sense.expect("sense parsed");
|
||||
assert_eq!(s.sense_key, 0x05);
|
||||
assert_eq!(s.asc, 0x24);
|
||||
assert_eq!(s.ascq, 0x01);
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
3366
src/aacs/handshake_tests.rs
Normal file
3366
src/aacs/handshake_tests.rs
Normal file
File diff suppressed because it is too large
Load diff
464
src/aacs/mod.rs
464
src/aacs/mod.rs
|
|
@ -200,465 +200,5 @@ impl Unlocker for AacsUnlocker {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
|
||||
/// test vector: decrypting the published ciphertext with the published key
|
||||
/// must recover the published plaintext.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_matches_fips197_test_vector() {
|
||||
let key: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
0x0E, 0x0F,
|
||||
];
|
||||
let ciphertext: [u8; 16] = [
|
||||
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
|
||||
0xC5, 0x5A,
|
||||
];
|
||||
let expected_plaintext: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
|
||||
0xEE, 0xFF,
|
||||
];
|
||||
let out = aes_ecb_decrypt(&key, &ciphertext);
|
||||
assert_eq!(out, expected_plaintext);
|
||||
}
|
||||
|
||||
/// Decrypting a different ciphertext under the same key must not produce
|
||||
/// the same plaintext — pins that the function actually decrypts the
|
||||
/// given block rather than returning a constant.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_varies_with_input() {
|
||||
let key = [0u8; 16];
|
||||
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
|
||||
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
fn id() -> crate::DriveId {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
|
||||
/// Self-guards on the disc kind: on a non-AACS disc `unlock()` declines
|
||||
/// (`Ok(false)`) WITHOUT touching the transport, even WITH a cert present —
|
||||
/// so the reason is the kind, not a missing cert.
|
||||
#[test]
|
||||
fn declines_non_aacs_kinds() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched on a non-AACS disc");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
assert!(
|
||||
AacsUnlocker::new(vec![host_cert()])
|
||||
.unlock(&mut t, &UnlockCtx::new(&id, k))
|
||||
.expect("declines")
|
||||
.is_none(),
|
||||
"declines {k:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Full success path through `Unlocker::unlock`: a self-consistent AACS 1.0
|
||||
// emulator proves the entry point learns `vid`/`bus_key` — and that the cert
|
||||
// route reports `unlock() == true` (it DOES unlock the drive, via the cert).
|
||||
#[test]
|
||||
fn unlock_succeeds_end_to_end() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_data_keys = true;
|
||||
let (lax, lay) = (t.la_x, t.la_y);
|
||||
let id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("auth + VID + data-key reads all succeed")
|
||||
.expect("the cert route unlocks the drive");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]));
|
||||
assert!(out.bus_key.is_some());
|
||||
}
|
||||
|
||||
// The `read_data_key_dropped` path: auth + VID succeed but the drive serves
|
||||
// no bus key (non-transport). `unlock` still reports Unlocked (unlocked via
|
||||
// the cert) with `vid: Some` but `bus_key: None` — surfaced, not hidden.
|
||||
#[test]
|
||||
fn unlock_without_a_served_read_data_key_yields_vid_but_no_bus_key() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_zero_data_keys = true; // GOOD status, all-zero key block
|
||||
let (lax, lay) = (t.la_x, t.la_y);
|
||||
let id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("auth + VID succeed even when the bus key is refused")
|
||||
.expect("the cert route still unlocks the drive");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]), "the VID was learned");
|
||||
assert!(
|
||||
out.bus_key.is_none(),
|
||||
"no read_data_key served ⇒ bus_key: None (reported, not faked)"
|
||||
);
|
||||
}
|
||||
|
||||
fn host_cert() -> crate::HostCert {
|
||||
handshake::tests::dummy_cert()
|
||||
}
|
||||
|
||||
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
|
||||
|
||||
/// The default (no arm opted-in) NEVER touches the transport for arming.
|
||||
#[test]
|
||||
fn no_arm_by_default_does_not_probe_firmware() {
|
||||
struct PanicTransport;
|
||||
impl ScsiTransport for PanicTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("default AacsUnlocker must not issue a firmware knock");
|
||||
}
|
||||
}
|
||||
let u = AacsUnlocker::new(vec![host_cert()]);
|
||||
assert!(u.arm.is_none());
|
||||
u.maybe_arm(&mut PanicTransport).expect("no-op");
|
||||
}
|
||||
|
||||
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
|
||||
/// the drive untouched — no recipe SETs are sent.
|
||||
#[test]
|
||||
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
|
||||
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
|
||||
assert_eq!(t.cdbs.len(), 1);
|
||||
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
|
||||
}
|
||||
|
||||
/// A dead bus on the arm probe aborts the whole unlock.
|
||||
#[test]
|
||||
fn arm_probe_transport_fault_aborts() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// A freemkv fw front-end: answers the vendor knock (IDENTITY / SET / GET)
|
||||
/// like fw `version` does, and delegates every other CDB to `inner`.
|
||||
struct FwFront<T> {
|
||||
inner: T,
|
||||
version: &'static str,
|
||||
/// Feature flag table indexed by wire id (slot 0 unused).
|
||||
flags: [u8; 7],
|
||||
refuse_sets: bool,
|
||||
dead_after_identity: bool,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<T> FwFront<T> {
|
||||
fn new(inner: T) -> Self {
|
||||
FwFront {
|
||||
inner,
|
||||
version: "0.9.2",
|
||||
flags: [crate::firmware::STATE_PASSTHROUGH; 7],
|
||||
refuse_sets: false,
|
||||
dead_after_identity: false,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued.
|
||||
fn ran_cert_ake(&self) -> bool {
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY)
|
||||
}
|
||||
fn sent_a_set(&self) -> bool {
|
||||
use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScsiTransport> ScsiTransport for FwFront<T> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::firmware::{CDB_FEATURE, CDB_STATE, CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
if cdb[0] != READ_BUFFER_OPCODE {
|
||||
return self.inner.execute(cdb, dir, data, timeout_ms);
|
||||
}
|
||||
let status = |status: u8, n: usize| {
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
};
|
||||
let verb = cdb[CDB_VERB];
|
||||
let f = (cdb[CDB_FEATURE] as usize).min(6);
|
||||
if verb == Verb::Identity as u8 {
|
||||
let mut flags = [0u8; 6];
|
||||
flags.copy_from_slice(&self.flags[1..]);
|
||||
let mut p = crate::firmware::identity_reply(self.version, flags);
|
||||
p.resize(data.len(), 0);
|
||||
data.copy_from_slice(&p[..data.len()]);
|
||||
return status(0, data.len());
|
||||
}
|
||||
if self.dead_after_identity {
|
||||
return Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if verb == Verb::Set as u8 {
|
||||
if self.refuse_sets {
|
||||
return status(crate::scsi::SCSI_STATUS_CHECK_CONDITION, 0);
|
||||
}
|
||||
self.flags[f] = cdb[CDB_STATE];
|
||||
} else if verb == Verb::Get as u8 {
|
||||
data.fill(0);
|
||||
data[0] = self.flags[f];
|
||||
}
|
||||
status(0, data.len())
|
||||
}
|
||||
// Forward the Stop methods (stop-design-v5 §2.3): the defaults would
|
||||
// swallow them, so wrapped tests would stop exercising cancellation.
|
||||
fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> {
|
||||
self.inner.pause(d)
|
||||
}
|
||||
fn begin_critical(&mut self) -> crate::scsi::Result<()> {
|
||||
self.inner.begin_critical()
|
||||
}
|
||||
fn end_critical(&mut self) {
|
||||
self.inner.end_critical()
|
||||
}
|
||||
fn execute_cleanup(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
self.inner.execute_cleanup(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
// UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront<T>` … forwards all
|
||||
// four"): otherwise every FwFront-wrapped test silently stops exercising Stop.
|
||||
#[test]
|
||||
fn fwfront_forwards_new_methods() {
|
||||
use crate::scsi::DataDirection;
|
||||
use crate::scsi::mock::{Ev, MockTransport, Reply, StopFake};
|
||||
let inner = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 2])));
|
||||
let mut t = FwFront::new(inner);
|
||||
let d = std::time::Duration::from_millis(1);
|
||||
t.pause(d).expect("forwarded pause");
|
||||
t.begin_critical().expect("forwarded begin");
|
||||
t.end_critical();
|
||||
let rel = [0xA4, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0x3F, 0];
|
||||
t.execute_cleanup(&rel, DataDirection::FromDevice, &mut [0u8; 2], 5_000)
|
||||
.expect("forwarded cleanup");
|
||||
assert_eq!(
|
||||
t.inner.log,
|
||||
vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())],
|
||||
"all four reach the wrapped transport"
|
||||
);
|
||||
t.inner.cancelled = true;
|
||||
assert!(t.pause(d).is_err(), "a cancel reaches through the wrapper");
|
||||
assert!(t.begin_critical().is_err());
|
||||
}
|
||||
|
||||
fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut p = vec![0u8; 36];
|
||||
p[4..20].copy_from_slice(&vid);
|
||||
MockTransport::always(Reply::good(p))
|
||||
}
|
||||
|
||||
fn aacs_ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Aacs)
|
||||
}
|
||||
|
||||
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
|
||||
/// Set(Hrl, off) and Set(Encryption, off), each verified, after the
|
||||
/// IDENTITY gate and BEFORE the handshake would run.
|
||||
#[test]
|
||||
fn arm_on_freemkv_drive_applies_the_recipe() {
|
||||
use crate::firmware::{CDB_VERB, Feature, STATE_OFF, Verb, build_set_cdb};
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
assert!(u.maybe_arm(&mut t).expect("armed"));
|
||||
assert_eq!(t.flags[Feature::Hrl as usize], STATE_OFF);
|
||||
assert_eq!(t.flags[Feature::Encryption as usize], STATE_OFF);
|
||||
assert_eq!(t.cdbs.len(), 5);
|
||||
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
|
||||
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
|
||||
}
|
||||
|
||||
/// Regression: an Encryption=off recipe needs NO host cert. With an empty
|
||||
/// cert list the drive is still armed and the unlock is the bare VID read —
|
||||
/// no cert AKE, no bus key (content already comes back de-bussed).
|
||||
#[test]
|
||||
fn encryption_off_recipes_unlock_without_certs_via_the_bare_vid() {
|
||||
for recipe in [ArmRecipe::BypassBd, ArmRecipe::BypassUhd, ArmRecipe::OemUhd] {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![])
|
||||
.arm_before_unlock(recipe)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.unwrap_or_else(|| panic!("{recipe:?}: armed drive is unlocked"));
|
||||
assert_eq!(out.vid, Some([0x42; 16]), "{recipe:?}");
|
||||
assert_eq!(out.bus_key, None, "{recipe:?}: no double bus-decrypt");
|
||||
assert!(!t.ran_cert_ake(), "{recipe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Armed Encryption=off, then the bare VID read: a dead bus there aborts
|
||||
/// with Transport; a drive rejection is still an unlock, just without a VID.
|
||||
#[test]
|
||||
fn encryption_off_arm_then_bare_vid_failure() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
for (reply, want) in [
|
||||
(Reply::TransportFault, Err(UnlockError::Transport)),
|
||||
(Reply::illegal_request(), Ok(Some((None, None)))),
|
||||
(Reply::illegal_request_as_err(), Ok(Some((None, None)))),
|
||||
] {
|
||||
let mut t = FwFront::new(MockTransport::always(reply));
|
||||
let id = id();
|
||||
let got = AacsUnlocker::new(vec![host_cert()])
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.map(|o| o.map(|u| (u.vid, u.bus_key)));
|
||||
assert_eq!(got, want);
|
||||
assert!(t.sent_a_set(), "the drive was armed first");
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: with certs present, an Encryption=off arm still skips the
|
||||
/// cert AKE and never reports a bus key.
|
||||
#[test]
|
||||
fn encryption_off_recipe_with_certs_skips_the_cert_ake() {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("unlocked");
|
||||
assert_eq!(out.bus_key, None);
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
|
||||
/// A refused recipe (and OemBd, which leaves encryption on) proceeds to the
|
||||
/// plain cert AKE, which still unlocks the drive and learns the bus key.
|
||||
#[test]
|
||||
fn refused_or_cert_recipes_run_the_cert_ake() {
|
||||
for (recipe, refuse) in [(ArmRecipe::BypassBd, true), (ArmRecipe::OemBd, false)] {
|
||||
let mut emu = handshake::tests::DriveEmu::new();
|
||||
emu.serve_data_keys = true;
|
||||
let (lax, lay) = (emu.la_x, emu.la_y);
|
||||
let mut t = FwFront::new(emu);
|
||||
t.refuse_sets = refuse;
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.arm_before_unlock(recipe)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("the cert route unlocks");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]), "{recipe:?}");
|
||||
assert!(out.bus_key.is_some(), "{recipe:?}");
|
||||
assert!(t.ran_cert_ake(), "{recipe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: fw 0.8.x is below MIN_FW_VERSION, so the recipe is never
|
||||
/// sent and the plain cert route runs.
|
||||
#[test]
|
||||
fn unsupported_firmware_is_not_armed() {
|
||||
let mut emu = handshake::tests::DriveEmu::new();
|
||||
emu.serve_data_keys = true;
|
||||
let (lax, lay) = (emu.la_x, emu.la_y);
|
||||
let mut t = FwFront::new(emu);
|
||||
t.version = "0.8.3";
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("the cert route unlocks");
|
||||
assert!(out.bus_key.is_some());
|
||||
assert!(!t.sent_a_set(), "no recipe SET on unsupported firmware");
|
||||
}
|
||||
|
||||
/// A dead bus mid-arm aborts the whole unlock, with or without certs.
|
||||
#[test]
|
||||
fn dead_bus_during_arm_aborts_the_unlock() {
|
||||
for certs in [vec![host_cert()], vec![]] {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
t.dead_after_identity = true;
|
||||
let id = id();
|
||||
let n = certs.len();
|
||||
let err = AacsUnlocker::new(certs)
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport, "{n} certs");
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
fn no_host_certs_declines() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched with no host certs");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
assert!(
|
||||
AacsUnlocker::new(vec![])
|
||||
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
|
||||
.expect("no certs declines")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
460
src/aacs/mod_tests.rs
Normal file
460
src/aacs/mod_tests.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
use super::*;
|
||||
|
||||
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
|
||||
/// test vector: decrypting the published ciphertext with the published key
|
||||
/// must recover the published plaintext.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_matches_fips197_test_vector() {
|
||||
let key: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
|
||||
0x0F,
|
||||
];
|
||||
let ciphertext: [u8; 16] = [
|
||||
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4, 0xC5,
|
||||
0x5A,
|
||||
];
|
||||
let expected_plaintext: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF,
|
||||
];
|
||||
let out = aes_ecb_decrypt(&key, &ciphertext);
|
||||
assert_eq!(out, expected_plaintext);
|
||||
}
|
||||
|
||||
/// Decrypting a different ciphertext under the same key must not produce
|
||||
/// the same plaintext — pins that the function actually decrypts the
|
||||
/// given block rather than returning a constant.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_varies_with_input() {
|
||||
let key = [0u8; 16];
|
||||
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
|
||||
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
fn id() -> crate::DriveId {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
|
||||
/// Self-guards on the disc kind: on a non-AACS disc `unlock()` declines
|
||||
/// (`Ok(false)`) WITHOUT touching the transport, even WITH a cert present —
|
||||
/// so the reason is the kind, not a missing cert.
|
||||
#[test]
|
||||
fn declines_non_aacs_kinds() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched on a non-AACS disc");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
assert!(
|
||||
AacsUnlocker::new(vec![host_cert()])
|
||||
.unlock(&mut t, &UnlockCtx::new(&id, k))
|
||||
.expect("declines")
|
||||
.is_none(),
|
||||
"declines {k:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Full success path through `Unlocker::unlock`: a self-consistent AACS 1.0
|
||||
// emulator proves the entry point learns `vid`/`bus_key` — and that the cert
|
||||
// route reports `unlock() == true` (it DOES unlock the drive, via the cert).
|
||||
#[test]
|
||||
fn unlock_succeeds_end_to_end() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_data_keys = true;
|
||||
let (lax, lay) = (t.la_x, t.la_y);
|
||||
let id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("auth + VID + data-key reads all succeed")
|
||||
.expect("the cert route unlocks the drive");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]));
|
||||
assert!(out.bus_key.is_some());
|
||||
}
|
||||
|
||||
// The `read_data_key_dropped` path: auth + VID succeed but the drive serves
|
||||
// no bus key (non-transport). `unlock` still reports Unlocked (unlocked via
|
||||
// the cert) with `vid: Some` but `bus_key: None` — surfaced, not hidden.
|
||||
#[test]
|
||||
fn unlock_without_a_served_read_data_key_yields_vid_but_no_bus_key() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_zero_data_keys = true; // GOOD status, all-zero key block
|
||||
let (lax, lay) = (t.la_x, t.la_y);
|
||||
let id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("auth + VID succeed even when the bus key is refused")
|
||||
.expect("the cert route still unlocks the drive");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]), "the VID was learned");
|
||||
assert!(
|
||||
out.bus_key.is_none(),
|
||||
"no read_data_key served ⇒ bus_key: None (reported, not faked)"
|
||||
);
|
||||
}
|
||||
|
||||
fn host_cert() -> crate::HostCert {
|
||||
handshake::tests::dummy_cert()
|
||||
}
|
||||
|
||||
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
|
||||
|
||||
/// The default (no arm opted-in) NEVER touches the transport for arming.
|
||||
#[test]
|
||||
fn no_arm_by_default_does_not_probe_firmware() {
|
||||
struct PanicTransport;
|
||||
impl ScsiTransport for PanicTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("default AacsUnlocker must not issue a firmware knock");
|
||||
}
|
||||
}
|
||||
let u = AacsUnlocker::new(vec![host_cert()]);
|
||||
assert!(u.arm.is_none());
|
||||
u.maybe_arm(&mut PanicTransport).expect("no-op");
|
||||
}
|
||||
|
||||
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
|
||||
/// the drive untouched — no recipe SETs are sent.
|
||||
#[test]
|
||||
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
|
||||
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
|
||||
assert_eq!(t.cdbs.len(), 1);
|
||||
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
|
||||
}
|
||||
|
||||
/// A dead bus on the arm probe aborts the whole unlock.
|
||||
#[test]
|
||||
fn arm_probe_transport_fault_aborts() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// A freemkv fw front-end: answers the vendor knock (IDENTITY / SET / GET)
|
||||
/// like fw `version` does, and delegates every other CDB to `inner`.
|
||||
struct FwFront<T> {
|
||||
inner: T,
|
||||
version: &'static str,
|
||||
/// Feature flag table indexed by wire id (slot 0 unused).
|
||||
flags: [u8; 7],
|
||||
refuse_sets: bool,
|
||||
dead_after_identity: bool,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<T> FwFront<T> {
|
||||
fn new(inner: T) -> Self {
|
||||
FwFront {
|
||||
inner,
|
||||
version: "0.9.2",
|
||||
flags: [crate::firmware::STATE_PASSTHROUGH; 7],
|
||||
refuse_sets: false,
|
||||
dead_after_identity: false,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued.
|
||||
fn ran_cert_ake(&self) -> bool {
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY)
|
||||
}
|
||||
fn sent_a_set(&self) -> bool {
|
||||
use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScsiTransport> ScsiTransport for FwFront<T> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::firmware::{CDB_FEATURE, CDB_STATE, CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
if cdb[0] != READ_BUFFER_OPCODE {
|
||||
return self.inner.execute(cdb, dir, data, timeout_ms);
|
||||
}
|
||||
let status = |status: u8, n: usize| {
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
};
|
||||
let verb = cdb[CDB_VERB];
|
||||
let f = (cdb[CDB_FEATURE] as usize).min(6);
|
||||
if verb == Verb::Identity as u8 {
|
||||
let mut flags = [0u8; 6];
|
||||
flags.copy_from_slice(&self.flags[1..]);
|
||||
let mut p = crate::firmware::identity_reply(self.version, flags);
|
||||
p.resize(data.len(), 0);
|
||||
data.copy_from_slice(&p[..data.len()]);
|
||||
return status(0, data.len());
|
||||
}
|
||||
if self.dead_after_identity {
|
||||
return Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if verb == Verb::Set as u8 {
|
||||
if self.refuse_sets {
|
||||
return status(crate::scsi::SCSI_STATUS_CHECK_CONDITION, 0);
|
||||
}
|
||||
self.flags[f] = cdb[CDB_STATE];
|
||||
} else if verb == Verb::Get as u8 {
|
||||
data.fill(0);
|
||||
data[0] = self.flags[f];
|
||||
}
|
||||
status(0, data.len())
|
||||
}
|
||||
// Forward the Stop methods (stop-design-v5 §2.3): the defaults would
|
||||
// swallow them, so wrapped tests would stop exercising cancellation.
|
||||
fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> {
|
||||
self.inner.pause(d)
|
||||
}
|
||||
fn begin_critical(&mut self) -> crate::scsi::Result<()> {
|
||||
self.inner.begin_critical()
|
||||
}
|
||||
fn end_critical(&mut self) {
|
||||
self.inner.end_critical()
|
||||
}
|
||||
fn execute_cleanup(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
self.inner.execute_cleanup(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
// UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront<T>` … forwards all
|
||||
// four"): otherwise every FwFront-wrapped test silently stops exercising Stop.
|
||||
#[test]
|
||||
fn fwfront_forwards_new_methods() {
|
||||
use crate::scsi::DataDirection;
|
||||
use crate::scsi::mock::{Ev, MockTransport, Reply, StopFake};
|
||||
let inner = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 2])));
|
||||
let mut t = FwFront::new(inner);
|
||||
let d = std::time::Duration::from_millis(1);
|
||||
t.pause(d).expect("forwarded pause");
|
||||
t.begin_critical().expect("forwarded begin");
|
||||
t.end_critical();
|
||||
let rel = [0xA4, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0x3F, 0];
|
||||
t.execute_cleanup(&rel, DataDirection::FromDevice, &mut [0u8; 2], 5_000)
|
||||
.expect("forwarded cleanup");
|
||||
assert_eq!(
|
||||
t.inner.log,
|
||||
vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())],
|
||||
"all four reach the wrapped transport"
|
||||
);
|
||||
t.inner.cancelled = true;
|
||||
assert!(t.pause(d).is_err(), "a cancel reaches through the wrapper");
|
||||
assert!(t.begin_critical().is_err());
|
||||
}
|
||||
|
||||
fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut p = vec![0u8; 36];
|
||||
p[4..20].copy_from_slice(&vid);
|
||||
MockTransport::always(Reply::good(p))
|
||||
}
|
||||
|
||||
fn aacs_ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Aacs)
|
||||
}
|
||||
|
||||
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
|
||||
/// Set(Hrl, off) and Set(Encryption, off), each verified, after the
|
||||
/// IDENTITY gate and BEFORE the handshake would run.
|
||||
#[test]
|
||||
fn arm_on_freemkv_drive_applies_the_recipe() {
|
||||
use crate::firmware::{CDB_VERB, Feature, STATE_OFF, Verb, build_set_cdb};
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
assert!(u.maybe_arm(&mut t).expect("armed"));
|
||||
assert_eq!(t.flags[Feature::Hrl as usize], STATE_OFF);
|
||||
assert_eq!(t.flags[Feature::Encryption as usize], STATE_OFF);
|
||||
assert_eq!(t.cdbs.len(), 5);
|
||||
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
|
||||
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
|
||||
}
|
||||
|
||||
/// Regression: an Encryption=off recipe needs NO host cert. With an empty
|
||||
/// cert list the drive is still armed and the unlock is the bare VID read —
|
||||
/// no cert AKE, no bus key (content already comes back de-bussed).
|
||||
#[test]
|
||||
fn encryption_off_recipes_unlock_without_certs_via_the_bare_vid() {
|
||||
for recipe in [ArmRecipe::BypassBd, ArmRecipe::BypassUhd, ArmRecipe::OemUhd] {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![])
|
||||
.arm_before_unlock(recipe)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.unwrap_or_else(|| panic!("{recipe:?}: armed drive is unlocked"));
|
||||
assert_eq!(out.vid, Some([0x42; 16]), "{recipe:?}");
|
||||
assert_eq!(out.bus_key, None, "{recipe:?}: no double bus-decrypt");
|
||||
assert!(!t.ran_cert_ake(), "{recipe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Armed Encryption=off, then the bare VID read: a dead bus there aborts
|
||||
/// with Transport; a drive rejection is still an unlock, just without a VID.
|
||||
#[test]
|
||||
fn encryption_off_arm_then_bare_vid_failure() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
for (reply, want) in [
|
||||
(Reply::TransportFault, Err(UnlockError::Transport)),
|
||||
(Reply::illegal_request(), Ok(Some((None, None)))),
|
||||
(Reply::illegal_request_as_err(), Ok(Some((None, None)))),
|
||||
] {
|
||||
let mut t = FwFront::new(MockTransport::always(reply));
|
||||
let id = id();
|
||||
let got = AacsUnlocker::new(vec![host_cert()])
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.map(|o| o.map(|u| (u.vid, u.bus_key)));
|
||||
assert_eq!(got, want);
|
||||
assert!(t.sent_a_set(), "the drive was armed first");
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: with certs present, an Encryption=off arm still skips the
|
||||
/// cert AKE and never reports a bus key.
|
||||
#[test]
|
||||
fn encryption_off_recipe_with_certs_skips_the_cert_ake() {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("unlocked");
|
||||
assert_eq!(out.bus_key, None);
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
|
||||
/// A refused recipe (and OemBd, which leaves encryption on) proceeds to the
|
||||
/// plain cert AKE, which still unlocks the drive and learns the bus key.
|
||||
#[test]
|
||||
fn refused_or_cert_recipes_run_the_cert_ake() {
|
||||
for (recipe, refuse) in [(ArmRecipe::BypassBd, true), (ArmRecipe::OemBd, false)] {
|
||||
let mut emu = handshake::tests::DriveEmu::new();
|
||||
emu.serve_data_keys = true;
|
||||
let (lax, lay) = (emu.la_x, emu.la_y);
|
||||
let mut t = FwFront::new(emu);
|
||||
t.refuse_sets = refuse;
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.arm_before_unlock(recipe)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("the cert route unlocks");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]), "{recipe:?}");
|
||||
assert!(out.bus_key.is_some(), "{recipe:?}");
|
||||
assert!(t.ran_cert_ake(), "{recipe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: fw 0.8.x is below MIN_FW_VERSION, so the recipe is never
|
||||
/// sent and the plain cert route runs.
|
||||
#[test]
|
||||
fn unsupported_firmware_is_not_armed() {
|
||||
let mut emu = handshake::tests::DriveEmu::new();
|
||||
emu.serve_data_keys = true;
|
||||
let (lax, lay) = (emu.la_x, emu.la_y);
|
||||
let mut t = FwFront::new(emu);
|
||||
t.version = "0.8.3";
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("the cert route unlocks");
|
||||
assert!(out.bus_key.is_some());
|
||||
assert!(!t.sent_a_set(), "no recipe SET on unsupported firmware");
|
||||
}
|
||||
|
||||
/// A dead bus mid-arm aborts the whole unlock, with or without certs.
|
||||
#[test]
|
||||
fn dead_bus_during_arm_aborts_the_unlock() {
|
||||
for certs in [vec![host_cert()], vec![]] {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
t.dead_after_identity = true;
|
||||
let id = id();
|
||||
let n = certs.len();
|
||||
let err = AacsUnlocker::new(certs)
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport, "{n} certs");
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
fn no_host_certs_declines() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched with no host certs");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
assert!(
|
||||
AacsUnlocker::new(vec![])
|
||||
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
|
||||
.expect("no certs declines")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
|
@ -61,84 +61,5 @@ impl From<Error> for crate::UnlockError {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
|
||||
|
||||
/// Catches restoring the `.map_err(|_| CssAuthFailed)` that made the
|
||||
/// `Transport` arm unreachable: a dead bus must classify as `Transport`
|
||||
/// (consumer aborts), a drive rejection as `NotApplicable` (fall through).
|
||||
#[test]
|
||||
fn transport_fault_survives_the_step_mapping() {
|
||||
let dead = step_err(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(dead.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(dead), UnlockError::Transport);
|
||||
|
||||
let refused = step_err(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(matches!(refused, Error::CssAuthFailed));
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Each variant has its own stable numeric code.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::CssAuthFailed.code(), 7201);
|
||||
assert_eq!(
|
||||
Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
})
|
||||
.code(),
|
||||
7299
|
||||
);
|
||||
}
|
||||
|
||||
// False for `CssAuthFailed` and for a `Scsi` error with a sense (a
|
||||
// drive rejection, not a dead bus) even at transport-failure status;
|
||||
// true only for the genuine transport fault (status + no sense).
|
||||
#[test]
|
||||
fn is_transport_failure_false_paths() {
|
||||
assert!(!Error::CssAuthFailed.is_transport_failure());
|
||||
|
||||
let sensed = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(!sensed.is_transport_failure());
|
||||
|
||||
let refusal = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
});
|
||||
assert!(!refusal.is_transport_failure());
|
||||
|
||||
let dead = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(dead.is_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` wraps the transport error unchanged into `Error::Scsi`.
|
||||
#[test]
|
||||
fn from_scsi_error_wraps_into_scsi_variant() {
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([1u8; 32]),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi(inner) => {
|
||||
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
|
||||
assert_eq!(inner.sense, Some([1u8; 32]));
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "error_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
79
src/css/error_tests.rs
Normal file
79
src/css/error_tests.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use super::*;
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
|
||||
|
||||
/// Catches restoring the `.map_err(|_| CssAuthFailed)` that made the
|
||||
/// `Transport` arm unreachable: a dead bus must classify as `Transport`
|
||||
/// (consumer aborts), a drive rejection as `NotApplicable` (fall through).
|
||||
#[test]
|
||||
fn transport_fault_survives_the_step_mapping() {
|
||||
let dead = step_err(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(dead.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(dead), UnlockError::Transport);
|
||||
|
||||
let refused = step_err(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(matches!(refused, Error::CssAuthFailed));
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Each variant has its own stable numeric code.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::CssAuthFailed.code(), 7201);
|
||||
assert_eq!(
|
||||
Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
})
|
||||
.code(),
|
||||
7299
|
||||
);
|
||||
}
|
||||
|
||||
// False for `CssAuthFailed` and for a `Scsi` error with a sense (a
|
||||
// drive rejection, not a dead bus) even at transport-failure status;
|
||||
// true only for the genuine transport fault (status + no sense).
|
||||
#[test]
|
||||
fn is_transport_failure_false_paths() {
|
||||
assert!(!Error::CssAuthFailed.is_transport_failure());
|
||||
|
||||
let sensed = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(!sensed.is_transport_failure());
|
||||
|
||||
let refusal = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
});
|
||||
assert!(!refusal.is_transport_failure());
|
||||
|
||||
let dead = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(dead.is_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` wraps the transport error unchanged into `Error::Scsi`.
|
||||
#[test]
|
||||
fn from_scsi_error_wraps_into_scsi_variant() {
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([1u8; 32]),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi(inner) => {
|
||||
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
|
||||
assert_eq!(inner.sense, Some([1u8; 32]));
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
911
src/css/mod.rs
911
src/css/mod.rs
|
|
@ -624,912 +624,5 @@ fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
|
|||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// SECURITY REGRESSION GUARD: scans source files for a `tracing` field
|
||||
// binding a forbidden key name to a value expression (only a string
|
||||
// literal or `_fp` field is allowed).
|
||||
#[test]
|
||||
fn no_key_bytes_in_instrumentation() {
|
||||
use std::path::Path;
|
||||
|
||||
// Forbidden field names whose VALUES must never be logged.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"title_key",
|
||||
"disc_key",
|
||||
"unit_key",
|
||||
"vuk",
|
||||
"player_key",
|
||||
"bus_key",
|
||||
];
|
||||
|
||||
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, forbidden, violations);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let src = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for (lineno, line) in src.lines().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
// Only inspect tracing instrumentation lines.
|
||||
if !(trimmed.contains("tracing::")
|
||||
|| trimmed.starts_with("debug!")
|
||||
|| trimmed.starts_with("info!")
|
||||
|| trimmed.starts_with("warn!")
|
||||
|| trimmed.starts_with("trace!")
|
||||
|| trimmed.starts_with("error!"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// This guard test itself contains the forbidden names.
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
|
||||
&& line.contains("FORBIDDEN")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for &name in forbidden {
|
||||
// A fingerprint field (`<name>_fp = ...`) is allowed;
|
||||
// match `<name>` then `=` with a value that is not a
|
||||
// string-literal redaction marker.
|
||||
if let Some(idx) = line.find(name) {
|
||||
let after = &line[idx + name.len()..];
|
||||
let after = after.trim_start();
|
||||
// `<name>_fp` / `<name>_id` etc. are safe.
|
||||
if after.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
// Must be a field binding `name = ...`.
|
||||
let Some(rest) = after.strip_prefix('=') else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
// Redaction string literal is the only allowed value.
|
||||
if rest.starts_with('"') {
|
||||
continue;
|
||||
}
|
||||
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
|
||||
violations.push(format!(
|
||||
"{}:{}: forbidden key field `{}` logged with a value: {}",
|
||||
path.display(),
|
||||
lineno + 1,
|
||||
name,
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan this crate's `src` plus sibling workspace crates so the
|
||||
// guard covers everything that can reach CSS/AACS internals.
|
||||
// Missing sibling dirs (standalone builds) are simply skipped.
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest.parent().unwrap_or(manifest);
|
||||
let mut violations = Vec::new();
|
||||
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
|
||||
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
|
||||
let dir = workspace.join(sibling).join("src");
|
||||
if dir.is_dir() {
|
||||
scan_dir(&dir, FORBIDDEN, &mut violations);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"key material logged in instrumentation:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_is_deterministic() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
let r1 = crypt_key(0, v, &challenge);
|
||||
let r2 = crypt_key(0, v, &challenge);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_nonzero() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSS constant-table integrity ───────────────────────────────────────
|
||||
|
||||
// Each PERM_CHALLENGE row must be a permutation of indices 0..10; a
|
||||
// non-permutation would drop/duplicate bytes.
|
||||
#[test]
|
||||
fn perm_challenge_rows_are_permutations() {
|
||||
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
|
||||
let mut seen = [false; 10];
|
||||
for &idx in perm.iter() {
|
||||
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
|
||||
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
|
||||
seen[idx] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_CHALLENGE[{row}] misses an index"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Each PERM_VARIANT row must map the 32 variants to 32 distinct 5-bit
|
||||
// values; a collision would make two variants indistinguishable.
|
||||
#[test]
|
||||
fn perm_variant_rows_are_permutations_of_0_31() {
|
||||
for (row, perm) in PERM_VARIANT.iter().enumerate() {
|
||||
let mut seen = [false; 32];
|
||||
for &v in perm.iter() {
|
||||
let v = v as usize;
|
||||
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
|
||||
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
|
||||
seen[v] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_VARIANT[{row}] misses a value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── crypt_key behaviour ────────────────────────────────────────────────
|
||||
|
||||
// crypt_key's result must depend on every challenge byte: flipping any
|
||||
// single byte must change the output.
|
||||
#[test]
|
||||
fn crypt_key_depends_on_every_challenge_byte() {
|
||||
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let base_out = crypt_key(0, 5, &base);
|
||||
for i in 0..10 {
|
||||
let mut c = base;
|
||||
c[i] ^= 0x55;
|
||||
assert_ne!(
|
||||
crypt_key(0, 5, &c),
|
||||
base_out,
|
||||
"flipping challenge byte {i} did not change the bus-key derivation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// crypt_key(0, v, ..) must be distinct for each of the 32 variants:
|
||||
// bus-auth brute-forces the variant by matching against key1, so a
|
||||
// collision could select the wrong one.
|
||||
#[test]
|
||||
fn crypt_key_type0_distinct_per_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let mut outs = Vec::new();
|
||||
for v in 0..32u8 {
|
||||
let k = crypt_key(0, v, &challenge);
|
||||
assert!(
|
||||
!outs.contains(&k),
|
||||
"variant {v} collides with an earlier variant"
|
||||
);
|
||||
outs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
// crypt_key's `key_type < 3` assert! (not debug_assert!) must fire in
|
||||
// every profile; `expected` rejects the index-OOB panic that would follow.
|
||||
#[test]
|
||||
#[should_panic(expected = "crypt_key: key_type out of range")]
|
||||
fn crypt_key_rejects_out_of_range_key_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(3, 0, &challenge);
|
||||
}
|
||||
|
||||
// crypt_key's `variant < 32` assert! must fire in every profile, not
|
||||
// the VARIANTS/PERM_VARIANT index-OOB panic that would follow it.
|
||||
#[test]
|
||||
#[should_panic(expected = "crypt_key: variant out of range")]
|
||||
fn crypt_key_rejects_out_of_range_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(0, 32, &challenge);
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
|
||||
|
||||
// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
|
||||
// byte 0=0xA4, bytes 8-9=big-endian len, byte 10=(AGID<<6)|(format&0x3F).
|
||||
#[test]
|
||||
fn report_key_cdb_matches_mmc_layout() {
|
||||
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
|
||||
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
|
||||
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
|
||||
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b10 << 6) | 0x04,
|
||||
"AGID in bits 6-7, format in bits 0-5"
|
||||
);
|
||||
// Every other byte must be zero.
|
||||
for (i, &b) in cdb.iter().enumerate() {
|
||||
if ![0, 8, 9, 10].contains(&i) {
|
||||
assert_eq!(b, 0, "CDB byte {i} must be zero");
|
||||
}
|
||||
}
|
||||
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
|
||||
}
|
||||
|
||||
// The key-format field is masked to 6 bits so a format with high bits
|
||||
// set (e.g. 0xFF) cannot corrupt the AGID bits of byte 10.
|
||||
#[test]
|
||||
fn report_key_cdb_masks_format_to_6_bits() {
|
||||
let cdb = report_key_cdb(0, 0xFF, 8);
|
||||
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
|
||||
}
|
||||
|
||||
// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
|
||||
// parameter-list length at bytes 8-9 (big-endian) and AGID/format at
|
||||
// byte 10.
|
||||
#[test]
|
||||
fn send_key_cdb_matches_mmc_layout() {
|
||||
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
|
||||
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
|
||||
assert_eq!(cdb[8], 0x00, "param_len high byte");
|
||||
assert_eq!(cdb[9], 0x0C, "param_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b11 << 6) | 0x03,
|
||||
"AGID bits 6-7, format bits 0-5"
|
||||
);
|
||||
assert_eq!(cdb.len(), 12);
|
||||
}
|
||||
|
||||
// Allocation length > 255 must split across bytes 8 (high) and 9 (low)
|
||||
// as a 16-bit big-endian field, e.g. 0x0804 (the disc-key block size).
|
||||
#[test]
|
||||
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
|
||||
let cdb = report_key_cdb(0, 0x00, 0x0804);
|
||||
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
|
||||
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
|
||||
}
|
||||
|
||||
// The unlocker's user-facing name is "DVD" (the medium), not "CSS" (the
|
||||
// scheme); apps render the unlocker report from this name, so it is a
|
||||
// stable contract.
|
||||
#[test]
|
||||
fn dvd_unlocker_is_named_dvd() {
|
||||
use crate::Unlocker;
|
||||
assert_eq!(DvdUnlocker::new().name(), "DVD");
|
||||
}
|
||||
|
||||
/// `Default` must delegate to `new()` — there is only one way to build a
|
||||
/// `DvdUnlocker` (it is a unit struct), so this pins the two never drift.
|
||||
#[test]
|
||||
#[allow(clippy::default_constructed_unit_structs)]
|
||||
fn default_matches_new() {
|
||||
let _ = DvdUnlocker::default();
|
||||
let _ = DvdUnlocker::new();
|
||||
}
|
||||
|
||||
/// DvdUnlocker provides bus removal only — it never provides drive features.
|
||||
// Defense in depth: even when the caller declares `DiscKind::Css`,
|
||||
// DvdUnlocker self-verifies against GET CONFIGURATION; a BD profile
|
||||
// must yield NotApplicable with no CSS CDB issued.
|
||||
#[test]
|
||||
fn dvd_unlocker_self_guards_against_non_dvd() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, Unlocker};
|
||||
|
||||
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
|
||||
/// other CDB (i.e. CSS bus-auth activity).
|
||||
struct BdTransport {
|
||||
non_config_cdbs: usize,
|
||||
}
|
||||
impl ScsiTransport for BdTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
|
||||
if data.len() >= 8 {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x40; // BD-ROM current profile
|
||||
}
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 8,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.non_config_cdbs += 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let id = DriveId {
|
||||
vendor_id: "FAKEVNDR".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut t = BdTransport { non_config_cdbs: 0 };
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(
|
||||
r.expect("BD profile declines, not a hard error").is_none(),
|
||||
"a BD-profile drive must be refused"
|
||||
);
|
||||
assert_eq!(
|
||||
t.non_config_cdbs, 0,
|
||||
"no CSS CDB may be issued at a non-DVD drive"
|
||||
);
|
||||
}
|
||||
// ── Transport-contract tests ────────────────────────────────────────────
|
||||
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
|
||||
|
||||
// Defect-7 regression: a transport fault on the first probe command
|
||||
// must abort as Transport, not fall through to NotApplicable (which
|
||||
// let the consumer keep probing a dead bus).
|
||||
#[test]
|
||||
fn transport_fault_probing_for_a_dvd_aborts() {
|
||||
let id = DriveId::default();
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::Transport);
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
1,
|
||||
"must abort on the first command, not probe on"
|
||||
);
|
||||
}
|
||||
|
||||
/// A drive that REFUSES GET CONFIGURATION (CHECK CONDITION, delivered as
|
||||
/// `Ok` per the contract) is inconclusive, not a dead bus → decline. Guards
|
||||
/// against over-correcting defect 7 into "any probe failure aborts the rip".
|
||||
#[test]
|
||||
fn check_condition_probing_for_a_dvd_declines() {
|
||||
let id = DriveId::default();
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(r.expect("inconclusive probe declines").is_none());
|
||||
}
|
||||
|
||||
// Defect-2 regression: a DVD is mounted, then the bus dies mid
|
||||
// bus-auth — must abort as Transport, not collapse to CssAuthFailed /
|
||||
// NotApplicable.
|
||||
#[test]
|
||||
fn transport_fault_during_bus_auth_aborts() {
|
||||
let id = DriveId::default();
|
||||
// GET CONFIGURATION reports a DVD-ROM profile (0x0010), then the bus dies.
|
||||
let mut config = vec![0u8; 8];
|
||||
config[6] = 0x00;
|
||||
config[7] = 0x10;
|
||||
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::TransportFault);
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// The same shape with the drive REFUSING the bus-auth commands: a CSS
|
||||
/// auth failure is a fall-through, not an abort.
|
||||
#[test]
|
||||
fn drive_refusing_bus_auth_is_not_applicable() {
|
||||
let id = DriveId::default();
|
||||
let mut config = vec![0u8; 8];
|
||||
config[7] = 0x10;
|
||||
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::illegal_request());
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(r.expect("a refused bus-auth declines").is_none());
|
||||
}
|
||||
|
||||
// A CHECK CONDITION on AGID allocation must not let the handshake carry
|
||||
// on off the caller's own zero-filled buffer; catches a dropped
|
||||
// `status` check in `css_scsi`.
|
||||
#[test]
|
||||
fn agid_allocation_check_condition_fails_the_auth() {
|
||||
// 4 AGID invalidations are best-effort; the 5th command is the alloc.
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = establish_authenticated_session(&mut t).expect_err("refused alloc");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// Defect 18: an AGID lost to a failed challenge must be RELEASED
|
||||
// (REPORT KEY format 0x3F), not abandoned, even though a drive's four
|
||||
// AGIDs self-heal on the next session's invalidation pass.
|
||||
#[test]
|
||||
fn a_failed_handshake_releases_the_agid_it_allocated() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]), // 4 × AGID invalidate
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]), // AGID allocated
|
||||
],
|
||||
Reply::illegal_request(), // every challenge step is refused
|
||||
);
|
||||
establish_authenticated_session(&mut t).expect_err("refused challenge");
|
||||
let last = t.cdbs.last().expect("commands were issued");
|
||||
assert_eq!(last[0], crate::scsi::SCSI_REPORT_KEY);
|
||||
assert_eq!(last[10] & 0x3F, 0x3F, "AGID released on the failure path");
|
||||
}
|
||||
|
||||
/// A short AGID-allocation response is equally unusable — the AGID would be
|
||||
/// read out of bytes the drive never sent.
|
||||
#[test]
|
||||
fn short_agid_allocation_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
],
|
||||
Reply::short(vec![0u8; 8], 3),
|
||||
);
|
||||
let e = establish_authenticated_session(&mut t).expect_err("short alloc");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// ── authenticate_with_agid step failures ────────────────────────────────
|
||||
|
||||
/// The drive refuses the host-challenge SEND KEY (step 1) — the very first
|
||||
/// command `authenticate_with_agid` issues.
|
||||
#[test]
|
||||
fn host_challenge_send_failure_fails_the_auth() {
|
||||
let mut t =
|
||||
MockTransport::scripted(vec![Reply::illegal_request()], Reply::illegal_request());
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused host challenge");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(t.calls(), 1, "must not proceed past the first refused step");
|
||||
}
|
||||
|
||||
/// The host challenge SEND KEY succeeds but the Key1 REPORT KEY (step 2) is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn key1_report_failure_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(vec![]), Reply::illegal_request()],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key1 report");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(t.calls(), 2);
|
||||
}
|
||||
|
||||
/// Both SCSI steps succeed but the drive's Key1 does not match any of the
|
||||
/// 32 CryptKey variants for the (randomly generated) host challenge — the
|
||||
/// brute-force loop exhausts and `variant.ok_or(CssAuthFailed)` fires.
|
||||
#[test]
|
||||
fn key1_matching_no_variant_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(vec![]), Reply::good(vec![0xABu8; 12])],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("no variant matches");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
2,
|
||||
"the brute-force loop issues no further SCSI commands"
|
||||
);
|
||||
}
|
||||
|
||||
// Host challenge + Key1 succeed but the drive-challenge REPORT KEY
|
||||
// (step 3) is refused; distinct from the step-2 and step-4 failure
|
||||
// tests, each pinning a different `css_scsi` call site's `?`.
|
||||
#[test]
|
||||
fn drive_challenge_report_failure_fails_the_auth() {
|
||||
struct FailAtDriveChallenge(FakeDvdDrive);
|
||||
impl ScsiTransport for FailAtDriveChallenge {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_REPORT_KEY && cdb[10] & 0x3F == 0x01 {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
let mut t = FailAtDriveChallenge(FakeDvdDrive {
|
||||
variant: 4,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused drive challenge");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
/// Host challenge, Key1, and the drive challenge all succeed, but the
|
||||
/// Key2 SEND KEY (step 4, the last command in the handshake) is refused.
|
||||
#[test]
|
||||
fn key2_send_failure_fails_the_auth() {
|
||||
struct FailAtKey2Send(FakeDvdDrive);
|
||||
impl ScsiTransport for FailAtKey2Send {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_SEND_KEY && cdb[10] & 0x3F == 0x03 {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
let mut t = FailAtKey2Send(FakeDvdDrive {
|
||||
variant: 4,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key2 send");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// ── read_disc_key ────────────────────────────────────────────────────────
|
||||
|
||||
/// The best-effort disc-key REPORT KEY is refused — `read_disc_key` must
|
||||
/// surface the failure to its caller (who treats it as non-fatal), not
|
||||
/// silently return `Ok`.
|
||||
#[test]
|
||||
fn read_disc_key_refused_is_an_error() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let e = read_disc_key(&mut t, 0).expect_err("drive refused disc-key read");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
/// A transport fault reading the disc key must classify as a transport
|
||||
/// failure, not a generic auth failure.
|
||||
#[test]
|
||||
fn read_disc_key_transport_fault_is_transport_failure() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let e = read_disc_key(&mut t, 0).expect_err("dead bus");
|
||||
assert!(e.is_transport_failure());
|
||||
}
|
||||
|
||||
// ── Full happy-path bus-auth ────────────────────────────────────────────
|
||||
|
||||
// A fake drive that plays its half of the CSS handshake for real:
|
||||
// answers Key1 honestly, and DVD/disc-key probes as success.
|
||||
struct FakeDvdDrive {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
}
|
||||
|
||||
impl ScsiTransport for FakeDvdDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::scsi::ScsiResult;
|
||||
let ok = |bytes_transferred: usize| ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
};
|
||||
match cdb[0] {
|
||||
crate::scsi::SCSI_GET_CONFIGURATION => {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x10; // DVD-ROM current profile
|
||||
Ok(ok(8))
|
||||
}
|
||||
crate::scsi::SCSI_SEND_KEY => {
|
||||
let format = cdb[10] & 0x3F;
|
||||
if format == 0x01 {
|
||||
// Host challenge: capture it for the Key1 answer.
|
||||
for i in 0..10 {
|
||||
self.host_challenge[i] = data[4 + (9 - i)];
|
||||
}
|
||||
}
|
||||
// format 0x03 (Key2) is accepted unconditionally: the
|
||||
// real handshake never verifies it drive-side in this
|
||||
// primitive (ASF=1 is set on the drive's own say-so).
|
||||
Ok(ok(0))
|
||||
}
|
||||
crate::scsi::SCSI_REPORT_KEY => {
|
||||
match cdb[10] & 0x3F {
|
||||
0x00 => {
|
||||
// Allocate AGID 0.
|
||||
data[7] = 0x00;
|
||||
Ok(ok(8))
|
||||
}
|
||||
0x02 => {
|
||||
// Key1, honestly derived from the captured challenge.
|
||||
let key1 = crypt_key(0, self.variant, &self.host_challenge);
|
||||
for i in 0..5 {
|
||||
data[4 + (4 - i)] = key1[i];
|
||||
}
|
||||
Ok(ok(12))
|
||||
}
|
||||
0x01 => {
|
||||
// Drive challenge (arbitrary, fixed).
|
||||
let drive_challenge: [u8; 10] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
|
||||
for i in 0..10 {
|
||||
data[4 + (9 - i)] = drive_challenge[i];
|
||||
}
|
||||
Ok(ok(16))
|
||||
}
|
||||
_ => Ok(ok(0)), // 0x3F release, best-effort
|
||||
}
|
||||
}
|
||||
crate::scsi::SCSI_READ_DISC_STRUCTURE => Ok(ok(2048 + 4)),
|
||||
_ => Ok(ok(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end happy path for the bus-auth handshake (AGID, challenge,
|
||||
// Key1/Key2, disc-key REPORT KEY), exercising the `Ok` returns that
|
||||
// the failure-path tests above never reach.
|
||||
#[test]
|
||||
fn full_bus_auth_and_disc_key_succeed() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let agid =
|
||||
establish_authenticated_session(&mut t).expect("full bus-auth handshake succeeds");
|
||||
assert_eq!(agid, 0);
|
||||
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
|
||||
}
|
||||
|
||||
// The same happy path through the public `unlock` entry point, proving it
|
||||
// returns `Some(Unlocked::default())` on success — the path every
|
||||
// failure-injection test above deliberately avoids.
|
||||
#[test]
|
||||
fn dvd_unlocker_succeeds_end_to_end() {
|
||||
let id = DriveId::default();
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 3,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
let unlocked = r
|
||||
.expect("full unlock succeeds")
|
||||
.expect("DVD bus-auth unlocks the drive");
|
||||
assert!(unlocked.vid.is_none(), "CSS yields no Volume ID");
|
||||
assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key");
|
||||
}
|
||||
|
||||
/// `unlock_css_reads` (the crate-level public entry point, distinct from
|
||||
/// the `DvdUnlocker` wrapper) also succeeds end to end.
|
||||
#[test]
|
||||
fn unlock_css_reads_succeeds() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 11,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
// unlock_css_reads doesn't probe GET CONFIGURATION itself (that's
|
||||
// DvdUnlocker's job); it goes straight to bus-auth.
|
||||
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
|
||||
}
|
||||
|
||||
// A drive whose bus-auth succeeds but refuses the best-effort disc-key
|
||||
// REPORT KEY; `unlock_css_reads_inner` must swallow that failure since
|
||||
// the read barrier is already open from bus-auth.
|
||||
struct DiscKeyRefusingDrive(FakeDvdDrive);
|
||||
|
||||
impl ScsiTransport for DiscKeyRefusingDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_key_refusal_is_non_fatal_to_the_overall_unlock() {
|
||||
let mut t = DiscKeyRefusingDrive(FakeDvdDrive {
|
||||
variant: 5,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
unlock_css_reads(&mut t, 0)
|
||||
.expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it");
|
||||
}
|
||||
|
||||
// `crypt_key`'s key_type==2 arm (`PERM_VARIANT[1]`) has no production
|
||||
// caller (only 0 and 1 are invoked from `authenticate_with_agid`) but
|
||||
// is a pure, directly-testable function.
|
||||
#[test]
|
||||
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let a = crypt_key(2, 9, &challenge);
|
||||
let b = crypt_key(2, 9, &challenge);
|
||||
assert_eq!(a, b, "crypt_key(2, ..) must be deterministic");
|
||||
assert_ne!(
|
||||
crypt_key(1, 9, &challenge),
|
||||
crypt_key(2, 9, &challenge),
|
||||
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stop (stop-design-v5 §2.3, ST-U1) ───────────────────────────────────
|
||||
|
||||
/// A drive that completes the CSS bus-auth for real: it answers Key1 for the
|
||||
/// host challenge under `variant`, serves a challenge, and allocates AGID 1.
|
||||
struct CssEmu {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
/// Answer Key1 with zeros, which matches no variant → CssAuthFailed.
|
||||
bad_key1: bool,
|
||||
}
|
||||
|
||||
impl ScsiTransport for CssEmu {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
let mut r = vec![0u8; data.len()];
|
||||
match (cdb[0], cdb[10] & 0x3F) {
|
||||
(crate::scsi::SCSI_REPORT_KEY, 0x00) => r[7] = 1 << 6, // AGID 1
|
||||
(crate::scsi::SCSI_REPORT_KEY, 0x02) if !self.bad_key1 => {
|
||||
let k = crypt_key(0, self.variant, &self.host_challenge);
|
||||
(0..5).for_each(|j| r[4 + j] = k[4 - j]);
|
||||
}
|
||||
(crate::scsi::SCSI_SEND_KEY, 0x01) => {
|
||||
(0..10).for_each(|i| self.host_challenge[i] = data[4 + 9 - i]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if cdb[0] != crate::scsi::SCSI_SEND_KEY {
|
||||
data.copy_from_slice(&r);
|
||||
}
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn css_emu() -> CssEmu {
|
||||
CssEmu {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
bad_key1: false,
|
||||
}
|
||||
}
|
||||
|
||||
// UT4 / G2 (stop-design-v5 §5.2, D9 "Keep holding it"): on success the AGID is
|
||||
// `defuse`d, so it is still held for `read_disc_key` and never released.
|
||||
// Per design D9; do not change without a design citation proving otherwise.
|
||||
#[test]
|
||||
fn css_success_keeps_agid() {
|
||||
use crate::scsi::mock::{StopFake, agid_ledger, is_agid_release};
|
||||
let mut t = StopFake::new(css_emu());
|
||||
unlock_css_reads(&mut t, 0).expect("the bus-auth completes");
|
||||
let (allocs, held) = agid_ledger(&t.log);
|
||||
assert_eq!(
|
||||
(allocs, held),
|
||||
(1, true),
|
||||
"one AGID, still held after success"
|
||||
);
|
||||
assert!(t.cleanups().is_empty(), "no release on success");
|
||||
let execs = t.execs();
|
||||
let alloc = execs.iter().position(|c| c[0] == 0xA4 && c[10] & 0x3F == 0);
|
||||
let after = &execs[alloc.expect("allocated") + 1..];
|
||||
assert!(
|
||||
!after.iter().any(|c| is_agid_release(c)),
|
||||
"no 0x3F after success"
|
||||
);
|
||||
let disc_key = after.last().expect("the disc-key read follows");
|
||||
assert_eq!(
|
||||
(disc_key[0], disc_key[7]),
|
||||
(0xAD, 0x02),
|
||||
"READ DVD STRUCTURE fmt 2"
|
||||
);
|
||||
assert_eq!(disc_key[10], 1 << 6, "issued under the still-held AGID 1");
|
||||
}
|
||||
|
||||
// SS-7 (evidence): a CSS challenge that fails after allocation releases the
|
||||
// AGID exactly once, through `execute_cleanup`, even when a Stop caused it.
|
||||
#[test]
|
||||
fn css_failed_challenge_releases_agid_once_via_cleanup() {
|
||||
use crate::scsi::mock::{StopFake, agid_ledger};
|
||||
let stop_at_challenge: fn(&[u8]) -> bool =
|
||||
|c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01;
|
||||
for cancel in [false, true] {
|
||||
let mut emu = css_emu();
|
||||
if !cancel {
|
||||
emu.bad_key1 = true;
|
||||
}
|
||||
let mut t = StopFake::new(emu);
|
||||
if cancel {
|
||||
t.cancel_after = Some(stop_at_challenge);
|
||||
}
|
||||
let e = establish_authenticated_session(&mut t).expect_err("auth fails");
|
||||
assert_eq!(e.is_transport_failure(), cancel, "a Stop is a refusal");
|
||||
assert_eq!(agid_ledger(&t.log), (1, false), "cancel={cancel}");
|
||||
assert_eq!(
|
||||
t.cleanups().len(),
|
||||
1,
|
||||
"exactly one release, cancel={cancel}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// UT5 (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" — the
|
||||
// pre-allocation invalidate loop uses `execute`, so a Stop refuses it.
|
||||
#[test]
|
||||
fn invalidate_loops_refused_after_cancel() {
|
||||
use crate::scsi::mock::StopFake;
|
||||
let mut t = StopFake::new(css_emu());
|
||||
t.cancelled = true;
|
||||
let e = establish_authenticated_session(&mut t).expect_err("cancelled");
|
||||
assert!(e.is_transport_failure());
|
||||
assert!(
|
||||
t.execs().is_empty(),
|
||||
"no CDB reached the drive: {:?}",
|
||||
t.log
|
||||
);
|
||||
assert!(
|
||||
t.cleanups().is_empty(),
|
||||
"nothing allocated, nothing released"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
905
src/css/mod_tests.rs
Normal file
905
src/css/mod_tests.rs
Normal file
|
|
@ -0,0 +1,905 @@
|
|||
use super::*;
|
||||
|
||||
// SECURITY REGRESSION GUARD: scans source files for a `tracing` field
|
||||
// binding a forbidden key name to a value expression (only a string
|
||||
// literal or `_fp` field is allowed).
|
||||
#[test]
|
||||
fn no_key_bytes_in_instrumentation() {
|
||||
use std::path::Path;
|
||||
|
||||
// Forbidden field names whose VALUES must never be logged.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"title_key",
|
||||
"disc_key",
|
||||
"unit_key",
|
||||
"vuk",
|
||||
"player_key",
|
||||
"bus_key",
|
||||
];
|
||||
|
||||
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, forbidden, violations);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let src = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for (lineno, line) in src.lines().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
// Only inspect tracing instrumentation lines.
|
||||
if !(trimmed.contains("tracing::")
|
||||
|| trimmed.starts_with("debug!")
|
||||
|| trimmed.starts_with("info!")
|
||||
|| trimmed.starts_with("warn!")
|
||||
|| trimmed.starts_with("trace!")
|
||||
|| trimmed.starts_with("error!"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// This guard test itself contains the forbidden names.
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
|
||||
&& line.contains("FORBIDDEN")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for &name in forbidden {
|
||||
// A fingerprint field (`<name>_fp = ...`) is allowed;
|
||||
// match `<name>` then `=` with a value that is not a
|
||||
// string-literal redaction marker.
|
||||
if let Some(idx) = line.find(name) {
|
||||
let after = &line[idx + name.len()..];
|
||||
let after = after.trim_start();
|
||||
// `<name>_fp` / `<name>_id` etc. are safe.
|
||||
if after.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
// Must be a field binding `name = ...`.
|
||||
let Some(rest) = after.strip_prefix('=') else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
// Redaction string literal is the only allowed value.
|
||||
if rest.starts_with('"') {
|
||||
continue;
|
||||
}
|
||||
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
|
||||
violations.push(format!(
|
||||
"{}:{}: forbidden key field `{}` logged with a value: {}",
|
||||
path.display(),
|
||||
lineno + 1,
|
||||
name,
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan this crate's `src` plus sibling workspace crates so the
|
||||
// guard covers everything that can reach CSS/AACS internals.
|
||||
// Missing sibling dirs (standalone builds) are simply skipped.
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest.parent().unwrap_or(manifest);
|
||||
let mut violations = Vec::new();
|
||||
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
|
||||
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
|
||||
let dir = workspace.join(sibling).join("src");
|
||||
if dir.is_dir() {
|
||||
scan_dir(&dir, FORBIDDEN, &mut violations);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"key material logged in instrumentation:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_is_deterministic() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
let r1 = crypt_key(0, v, &challenge);
|
||||
let r2 = crypt_key(0, v, &challenge);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_nonzero() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSS constant-table integrity ───────────────────────────────────────
|
||||
|
||||
// Each PERM_CHALLENGE row must be a permutation of indices 0..10; a
|
||||
// non-permutation would drop/duplicate bytes.
|
||||
#[test]
|
||||
fn perm_challenge_rows_are_permutations() {
|
||||
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
|
||||
let mut seen = [false; 10];
|
||||
for &idx in perm.iter() {
|
||||
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
|
||||
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
|
||||
seen[idx] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_CHALLENGE[{row}] misses an index"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Each PERM_VARIANT row must map the 32 variants to 32 distinct 5-bit
|
||||
// values; a collision would make two variants indistinguishable.
|
||||
#[test]
|
||||
fn perm_variant_rows_are_permutations_of_0_31() {
|
||||
for (row, perm) in PERM_VARIANT.iter().enumerate() {
|
||||
let mut seen = [false; 32];
|
||||
for &v in perm.iter() {
|
||||
let v = v as usize;
|
||||
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
|
||||
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
|
||||
seen[v] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_VARIANT[{row}] misses a value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── crypt_key behaviour ────────────────────────────────────────────────
|
||||
|
||||
// crypt_key's result must depend on every challenge byte: flipping any
|
||||
// single byte must change the output.
|
||||
#[test]
|
||||
fn crypt_key_depends_on_every_challenge_byte() {
|
||||
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let base_out = crypt_key(0, 5, &base);
|
||||
for i in 0..10 {
|
||||
let mut c = base;
|
||||
c[i] ^= 0x55;
|
||||
assert_ne!(
|
||||
crypt_key(0, 5, &c),
|
||||
base_out,
|
||||
"flipping challenge byte {i} did not change the bus-key derivation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// crypt_key(0, v, ..) must be distinct for each of the 32 variants:
|
||||
// bus-auth brute-forces the variant by matching against key1, so a
|
||||
// collision could select the wrong one.
|
||||
#[test]
|
||||
fn crypt_key_type0_distinct_per_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let mut outs = Vec::new();
|
||||
for v in 0..32u8 {
|
||||
let k = crypt_key(0, v, &challenge);
|
||||
assert!(
|
||||
!outs.contains(&k),
|
||||
"variant {v} collides with an earlier variant"
|
||||
);
|
||||
outs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
// crypt_key's `key_type < 3` assert! (not debug_assert!) must fire in
|
||||
// every profile; `expected` rejects the index-OOB panic that would follow.
|
||||
#[test]
|
||||
#[should_panic(expected = "crypt_key: key_type out of range")]
|
||||
fn crypt_key_rejects_out_of_range_key_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(3, 0, &challenge);
|
||||
}
|
||||
|
||||
// crypt_key's `variant < 32` assert! must fire in every profile, not
|
||||
// the VARIANTS/PERM_VARIANT index-OOB panic that would follow it.
|
||||
#[test]
|
||||
#[should_panic(expected = "crypt_key: variant out of range")]
|
||||
fn crypt_key_rejects_out_of_range_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(0, 32, &challenge);
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
|
||||
|
||||
// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
|
||||
// byte 0=0xA4, bytes 8-9=big-endian len, byte 10=(AGID<<6)|(format&0x3F).
|
||||
#[test]
|
||||
fn report_key_cdb_matches_mmc_layout() {
|
||||
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
|
||||
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
|
||||
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
|
||||
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b10 << 6) | 0x04,
|
||||
"AGID in bits 6-7, format in bits 0-5"
|
||||
);
|
||||
// Every other byte must be zero.
|
||||
for (i, &b) in cdb.iter().enumerate() {
|
||||
if ![0, 8, 9, 10].contains(&i) {
|
||||
assert_eq!(b, 0, "CDB byte {i} must be zero");
|
||||
}
|
||||
}
|
||||
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
|
||||
}
|
||||
|
||||
// The key-format field is masked to 6 bits so a format with high bits
|
||||
// set (e.g. 0xFF) cannot corrupt the AGID bits of byte 10.
|
||||
#[test]
|
||||
fn report_key_cdb_masks_format_to_6_bits() {
|
||||
let cdb = report_key_cdb(0, 0xFF, 8);
|
||||
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
|
||||
}
|
||||
|
||||
// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
|
||||
// parameter-list length at bytes 8-9 (big-endian) and AGID/format at
|
||||
// byte 10.
|
||||
#[test]
|
||||
fn send_key_cdb_matches_mmc_layout() {
|
||||
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
|
||||
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
|
||||
assert_eq!(cdb[8], 0x00, "param_len high byte");
|
||||
assert_eq!(cdb[9], 0x0C, "param_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b11 << 6) | 0x03,
|
||||
"AGID bits 6-7, format bits 0-5"
|
||||
);
|
||||
assert_eq!(cdb.len(), 12);
|
||||
}
|
||||
|
||||
// Allocation length > 255 must split across bytes 8 (high) and 9 (low)
|
||||
// as a 16-bit big-endian field, e.g. 0x0804 (the disc-key block size).
|
||||
#[test]
|
||||
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
|
||||
let cdb = report_key_cdb(0, 0x00, 0x0804);
|
||||
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
|
||||
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
|
||||
}
|
||||
|
||||
// The unlocker's user-facing name is "DVD" (the medium), not "CSS" (the
|
||||
// scheme); apps render the unlocker report from this name, so it is a
|
||||
// stable contract.
|
||||
#[test]
|
||||
fn dvd_unlocker_is_named_dvd() {
|
||||
use crate::Unlocker;
|
||||
assert_eq!(DvdUnlocker::new().name(), "DVD");
|
||||
}
|
||||
|
||||
/// `Default` must delegate to `new()` — there is only one way to build a
|
||||
/// `DvdUnlocker` (it is a unit struct), so this pins the two never drift.
|
||||
#[test]
|
||||
#[allow(clippy::default_constructed_unit_structs)]
|
||||
fn default_matches_new() {
|
||||
let _ = DvdUnlocker::default();
|
||||
let _ = DvdUnlocker::new();
|
||||
}
|
||||
|
||||
/// DvdUnlocker provides bus removal only — it never provides drive features.
|
||||
// Defense in depth: even when the caller declares `DiscKind::Css`,
|
||||
// DvdUnlocker self-verifies against GET CONFIGURATION; a BD profile
|
||||
// must yield NotApplicable with no CSS CDB issued.
|
||||
#[test]
|
||||
fn dvd_unlocker_self_guards_against_non_dvd() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, Unlocker};
|
||||
|
||||
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
|
||||
/// other CDB (i.e. CSS bus-auth activity).
|
||||
struct BdTransport {
|
||||
non_config_cdbs: usize,
|
||||
}
|
||||
impl ScsiTransport for BdTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
|
||||
if data.len() >= 8 {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x40; // BD-ROM current profile
|
||||
}
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 8,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.non_config_cdbs += 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let id = DriveId {
|
||||
vendor_id: "FAKEVNDR".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut t = BdTransport { non_config_cdbs: 0 };
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(
|
||||
r.expect("BD profile declines, not a hard error").is_none(),
|
||||
"a BD-profile drive must be refused"
|
||||
);
|
||||
assert_eq!(
|
||||
t.non_config_cdbs, 0,
|
||||
"no CSS CDB may be issued at a non-DVD drive"
|
||||
);
|
||||
}
|
||||
// ── Transport-contract tests ────────────────────────────────────────────
|
||||
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
|
||||
|
||||
// Defect-7 regression: a transport fault on the first probe command
|
||||
// must abort as Transport, not fall through to NotApplicable (which
|
||||
// let the consumer keep probing a dead bus).
|
||||
#[test]
|
||||
fn transport_fault_probing_for_a_dvd_aborts() {
|
||||
let id = DriveId::default();
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::Transport);
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
1,
|
||||
"must abort on the first command, not probe on"
|
||||
);
|
||||
}
|
||||
|
||||
/// A drive that REFUSES GET CONFIGURATION (CHECK CONDITION, delivered as
|
||||
/// `Ok` per the contract) is inconclusive, not a dead bus → decline. Guards
|
||||
/// against over-correcting defect 7 into "any probe failure aborts the rip".
|
||||
#[test]
|
||||
fn check_condition_probing_for_a_dvd_declines() {
|
||||
let id = DriveId::default();
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(r.expect("inconclusive probe declines").is_none());
|
||||
}
|
||||
|
||||
// Defect-2 regression: a DVD is mounted, then the bus dies mid
|
||||
// bus-auth — must abort as Transport, not collapse to CssAuthFailed /
|
||||
// NotApplicable.
|
||||
#[test]
|
||||
fn transport_fault_during_bus_auth_aborts() {
|
||||
let id = DriveId::default();
|
||||
// GET CONFIGURATION reports a DVD-ROM profile (0x0010), then the bus dies.
|
||||
let mut config = vec![0u8; 8];
|
||||
config[6] = 0x00;
|
||||
config[7] = 0x10;
|
||||
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::TransportFault);
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// The same shape with the drive REFUSING the bus-auth commands: a CSS
|
||||
/// auth failure is a fall-through, not an abort.
|
||||
#[test]
|
||||
fn drive_refusing_bus_auth_is_not_applicable() {
|
||||
let id = DriveId::default();
|
||||
let mut config = vec![0u8; 8];
|
||||
config[7] = 0x10;
|
||||
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::illegal_request());
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(r.expect("a refused bus-auth declines").is_none());
|
||||
}
|
||||
|
||||
// A CHECK CONDITION on AGID allocation must not let the handshake carry
|
||||
// on off the caller's own zero-filled buffer; catches a dropped
|
||||
// `status` check in `css_scsi`.
|
||||
#[test]
|
||||
fn agid_allocation_check_condition_fails_the_auth() {
|
||||
// 4 AGID invalidations are best-effort; the 5th command is the alloc.
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = establish_authenticated_session(&mut t).expect_err("refused alloc");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// Defect 18: an AGID lost to a failed challenge must be RELEASED
|
||||
// (REPORT KEY format 0x3F), not abandoned, even though a drive's four
|
||||
// AGIDs self-heal on the next session's invalidation pass.
|
||||
#[test]
|
||||
fn a_failed_handshake_releases_the_agid_it_allocated() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]), // 4 × AGID invalidate
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]), // AGID allocated
|
||||
],
|
||||
Reply::illegal_request(), // every challenge step is refused
|
||||
);
|
||||
establish_authenticated_session(&mut t).expect_err("refused challenge");
|
||||
let last = t.cdbs.last().expect("commands were issued");
|
||||
assert_eq!(last[0], crate::scsi::SCSI_REPORT_KEY);
|
||||
assert_eq!(last[10] & 0x3F, 0x3F, "AGID released on the failure path");
|
||||
}
|
||||
|
||||
/// A short AGID-allocation response is equally unusable — the AGID would be
|
||||
/// read out of bytes the drive never sent.
|
||||
#[test]
|
||||
fn short_agid_allocation_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
],
|
||||
Reply::short(vec![0u8; 8], 3),
|
||||
);
|
||||
let e = establish_authenticated_session(&mut t).expect_err("short alloc");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// ── authenticate_with_agid step failures ────────────────────────────────
|
||||
|
||||
/// The drive refuses the host-challenge SEND KEY (step 1) — the very first
|
||||
/// command `authenticate_with_agid` issues.
|
||||
#[test]
|
||||
fn host_challenge_send_failure_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(vec![Reply::illegal_request()], Reply::illegal_request());
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused host challenge");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(t.calls(), 1, "must not proceed past the first refused step");
|
||||
}
|
||||
|
||||
/// The host challenge SEND KEY succeeds but the Key1 REPORT KEY (step 2) is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn key1_report_failure_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(vec![]), Reply::illegal_request()],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key1 report");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(t.calls(), 2);
|
||||
}
|
||||
|
||||
/// Both SCSI steps succeed but the drive's Key1 does not match any of the
|
||||
/// 32 CryptKey variants for the (randomly generated) host challenge — the
|
||||
/// brute-force loop exhausts and `variant.ok_or(CssAuthFailed)` fires.
|
||||
#[test]
|
||||
fn key1_matching_no_variant_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(vec![]), Reply::good(vec![0xABu8; 12])],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("no variant matches");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
2,
|
||||
"the brute-force loop issues no further SCSI commands"
|
||||
);
|
||||
}
|
||||
|
||||
// Host challenge + Key1 succeed but the drive-challenge REPORT KEY
|
||||
// (step 3) is refused; distinct from the step-2 and step-4 failure
|
||||
// tests, each pinning a different `css_scsi` call site's `?`.
|
||||
#[test]
|
||||
fn drive_challenge_report_failure_fails_the_auth() {
|
||||
struct FailAtDriveChallenge(FakeDvdDrive);
|
||||
impl ScsiTransport for FailAtDriveChallenge {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_REPORT_KEY && cdb[10] & 0x3F == 0x01 {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
let mut t = FailAtDriveChallenge(FakeDvdDrive {
|
||||
variant: 4,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused drive challenge");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
/// Host challenge, Key1, and the drive challenge all succeed, but the
|
||||
/// Key2 SEND KEY (step 4, the last command in the handshake) is refused.
|
||||
#[test]
|
||||
fn key2_send_failure_fails_the_auth() {
|
||||
struct FailAtKey2Send(FakeDvdDrive);
|
||||
impl ScsiTransport for FailAtKey2Send {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_SEND_KEY && cdb[10] & 0x3F == 0x03 {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
let mut t = FailAtKey2Send(FakeDvdDrive {
|
||||
variant: 4,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key2 send");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// ── read_disc_key ────────────────────────────────────────────────────────
|
||||
|
||||
/// The best-effort disc-key REPORT KEY is refused — `read_disc_key` must
|
||||
/// surface the failure to its caller (who treats it as non-fatal), not
|
||||
/// silently return `Ok`.
|
||||
#[test]
|
||||
fn read_disc_key_refused_is_an_error() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let e = read_disc_key(&mut t, 0).expect_err("drive refused disc-key read");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
/// A transport fault reading the disc key must classify as a transport
|
||||
/// failure, not a generic auth failure.
|
||||
#[test]
|
||||
fn read_disc_key_transport_fault_is_transport_failure() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let e = read_disc_key(&mut t, 0).expect_err("dead bus");
|
||||
assert!(e.is_transport_failure());
|
||||
}
|
||||
|
||||
// ── Full happy-path bus-auth ────────────────────────────────────────────
|
||||
|
||||
// A fake drive that plays its half of the CSS handshake for real:
|
||||
// answers Key1 honestly, and DVD/disc-key probes as success.
|
||||
struct FakeDvdDrive {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
}
|
||||
|
||||
impl ScsiTransport for FakeDvdDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::scsi::ScsiResult;
|
||||
let ok = |bytes_transferred: usize| ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
};
|
||||
match cdb[0] {
|
||||
crate::scsi::SCSI_GET_CONFIGURATION => {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x10; // DVD-ROM current profile
|
||||
Ok(ok(8))
|
||||
}
|
||||
crate::scsi::SCSI_SEND_KEY => {
|
||||
let format = cdb[10] & 0x3F;
|
||||
if format == 0x01 {
|
||||
// Host challenge: capture it for the Key1 answer.
|
||||
for i in 0..10 {
|
||||
self.host_challenge[i] = data[4 + (9 - i)];
|
||||
}
|
||||
}
|
||||
// format 0x03 (Key2) is accepted unconditionally: the
|
||||
// real handshake never verifies it drive-side in this
|
||||
// primitive (ASF=1 is set on the drive's own say-so).
|
||||
Ok(ok(0))
|
||||
}
|
||||
crate::scsi::SCSI_REPORT_KEY => {
|
||||
match cdb[10] & 0x3F {
|
||||
0x00 => {
|
||||
// Allocate AGID 0.
|
||||
data[7] = 0x00;
|
||||
Ok(ok(8))
|
||||
}
|
||||
0x02 => {
|
||||
// Key1, honestly derived from the captured challenge.
|
||||
let key1 = crypt_key(0, self.variant, &self.host_challenge);
|
||||
for i in 0..5 {
|
||||
data[4 + (4 - i)] = key1[i];
|
||||
}
|
||||
Ok(ok(12))
|
||||
}
|
||||
0x01 => {
|
||||
// Drive challenge (arbitrary, fixed).
|
||||
let drive_challenge: [u8; 10] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
|
||||
for i in 0..10 {
|
||||
data[4 + (9 - i)] = drive_challenge[i];
|
||||
}
|
||||
Ok(ok(16))
|
||||
}
|
||||
_ => Ok(ok(0)), // 0x3F release, best-effort
|
||||
}
|
||||
}
|
||||
crate::scsi::SCSI_READ_DISC_STRUCTURE => Ok(ok(2048 + 4)),
|
||||
_ => Ok(ok(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end happy path for the bus-auth handshake (AGID, challenge,
|
||||
// Key1/Key2, disc-key REPORT KEY), exercising the `Ok` returns that
|
||||
// the failure-path tests above never reach.
|
||||
#[test]
|
||||
fn full_bus_auth_and_disc_key_succeed() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let agid = establish_authenticated_session(&mut t).expect("full bus-auth handshake succeeds");
|
||||
assert_eq!(agid, 0);
|
||||
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
|
||||
}
|
||||
|
||||
// The same happy path through the public `unlock` entry point, proving it
|
||||
// returns `Some(Unlocked::default())` on success — the path every
|
||||
// failure-injection test above deliberately avoids.
|
||||
#[test]
|
||||
fn dvd_unlocker_succeeds_end_to_end() {
|
||||
let id = DriveId::default();
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 3,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
let unlocked = r
|
||||
.expect("full unlock succeeds")
|
||||
.expect("DVD bus-auth unlocks the drive");
|
||||
assert!(unlocked.vid.is_none(), "CSS yields no Volume ID");
|
||||
assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key");
|
||||
}
|
||||
|
||||
/// `unlock_css_reads` (the crate-level public entry point, distinct from
|
||||
/// the `DvdUnlocker` wrapper) also succeeds end to end.
|
||||
#[test]
|
||||
fn unlock_css_reads_succeeds() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 11,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
// unlock_css_reads doesn't probe GET CONFIGURATION itself (that's
|
||||
// DvdUnlocker's job); it goes straight to bus-auth.
|
||||
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
|
||||
}
|
||||
|
||||
// A drive whose bus-auth succeeds but refuses the best-effort disc-key
|
||||
// REPORT KEY; `unlock_css_reads_inner` must swallow that failure since
|
||||
// the read barrier is already open from bus-auth.
|
||||
struct DiscKeyRefusingDrive(FakeDvdDrive);
|
||||
|
||||
impl ScsiTransport for DiscKeyRefusingDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_key_refusal_is_non_fatal_to_the_overall_unlock() {
|
||||
let mut t = DiscKeyRefusingDrive(FakeDvdDrive {
|
||||
variant: 5,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
unlock_css_reads(&mut t, 0)
|
||||
.expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it");
|
||||
}
|
||||
|
||||
// `crypt_key`'s key_type==2 arm (`PERM_VARIANT[1]`) has no production
|
||||
// caller (only 0 and 1 are invoked from `authenticate_with_agid`) but
|
||||
// is a pure, directly-testable function.
|
||||
#[test]
|
||||
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let a = crypt_key(2, 9, &challenge);
|
||||
let b = crypt_key(2, 9, &challenge);
|
||||
assert_eq!(a, b, "crypt_key(2, ..) must be deterministic");
|
||||
assert_ne!(
|
||||
crypt_key(1, 9, &challenge),
|
||||
crypt_key(2, 9, &challenge),
|
||||
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stop (stop-design-v5 §2.3, ST-U1) ───────────────────────────────────
|
||||
|
||||
/// A drive that completes the CSS bus-auth for real: it answers Key1 for the
|
||||
/// host challenge under `variant`, serves a challenge, and allocates AGID 1.
|
||||
struct CssEmu {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
/// Answer Key1 with zeros, which matches no variant → CssAuthFailed.
|
||||
bad_key1: bool,
|
||||
}
|
||||
|
||||
impl ScsiTransport for CssEmu {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
let mut r = vec![0u8; data.len()];
|
||||
match (cdb[0], cdb[10] & 0x3F) {
|
||||
(crate::scsi::SCSI_REPORT_KEY, 0x00) => r[7] = 1 << 6, // AGID 1
|
||||
(crate::scsi::SCSI_REPORT_KEY, 0x02) if !self.bad_key1 => {
|
||||
let k = crypt_key(0, self.variant, &self.host_challenge);
|
||||
(0..5).for_each(|j| r[4 + j] = k[4 - j]);
|
||||
}
|
||||
(crate::scsi::SCSI_SEND_KEY, 0x01) => {
|
||||
(0..10).for_each(|i| self.host_challenge[i] = data[4 + 9 - i]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if cdb[0] != crate::scsi::SCSI_SEND_KEY {
|
||||
data.copy_from_slice(&r);
|
||||
}
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn css_emu() -> CssEmu {
|
||||
CssEmu {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
bad_key1: false,
|
||||
}
|
||||
}
|
||||
|
||||
// UT4 / G2 (stop-design-v5 §5.2, D9 "Keep holding it"): on success the AGID is
|
||||
// `defuse`d, so it is still held for `read_disc_key` and never released.
|
||||
// Per design D9; do not change without a design citation proving otherwise.
|
||||
#[test]
|
||||
fn css_success_keeps_agid() {
|
||||
use crate::scsi::mock::{StopFake, agid_ledger, is_agid_release};
|
||||
let mut t = StopFake::new(css_emu());
|
||||
unlock_css_reads(&mut t, 0).expect("the bus-auth completes");
|
||||
let (allocs, held) = agid_ledger(&t.log);
|
||||
assert_eq!(
|
||||
(allocs, held),
|
||||
(1, true),
|
||||
"one AGID, still held after success"
|
||||
);
|
||||
assert!(t.cleanups().is_empty(), "no release on success");
|
||||
let execs = t.execs();
|
||||
let alloc = execs.iter().position(|c| c[0] == 0xA4 && c[10] & 0x3F == 0);
|
||||
let after = &execs[alloc.expect("allocated") + 1..];
|
||||
assert!(
|
||||
!after.iter().any(|c| is_agid_release(c)),
|
||||
"no 0x3F after success"
|
||||
);
|
||||
let disc_key = after.last().expect("the disc-key read follows");
|
||||
assert_eq!(
|
||||
(disc_key[0], disc_key[7]),
|
||||
(0xAD, 0x02),
|
||||
"READ DVD STRUCTURE fmt 2"
|
||||
);
|
||||
assert_eq!(disc_key[10], 1 << 6, "issued under the still-held AGID 1");
|
||||
}
|
||||
|
||||
// SS-7 (evidence): a CSS challenge that fails after allocation releases the
|
||||
// AGID exactly once, through `execute_cleanup`, even when a Stop caused it.
|
||||
#[test]
|
||||
fn css_failed_challenge_releases_agid_once_via_cleanup() {
|
||||
use crate::scsi::mock::{StopFake, agid_ledger};
|
||||
let stop_at_challenge: fn(&[u8]) -> bool =
|
||||
|c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01;
|
||||
for cancel in [false, true] {
|
||||
let mut emu = css_emu();
|
||||
if !cancel {
|
||||
emu.bad_key1 = true;
|
||||
}
|
||||
let mut t = StopFake::new(emu);
|
||||
if cancel {
|
||||
t.cancel_after = Some(stop_at_challenge);
|
||||
}
|
||||
let e = establish_authenticated_session(&mut t).expect_err("auth fails");
|
||||
assert_eq!(e.is_transport_failure(), cancel, "a Stop is a refusal");
|
||||
assert_eq!(agid_ledger(&t.log), (1, false), "cancel={cancel}");
|
||||
assert_eq!(
|
||||
t.cleanups().len(),
|
||||
1,
|
||||
"exactly one release, cancel={cancel}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// UT5 (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" — the
|
||||
// pre-allocation invalidate loop uses `execute`, so a Stop refuses it.
|
||||
#[test]
|
||||
fn invalidate_loops_refused_after_cancel() {
|
||||
use crate::scsi::mock::StopFake;
|
||||
let mut t = StopFake::new(css_emu());
|
||||
t.cancelled = true;
|
||||
let e = establish_authenticated_session(&mut t).expect_err("cancelled");
|
||||
assert!(e.is_transport_failure());
|
||||
assert!(
|
||||
t.execs().is_empty(),
|
||||
"no CDB reached the drive: {:?}",
|
||||
t.log
|
||||
);
|
||||
assert!(
|
||||
t.cleanups().is_empty(),
|
||||
"nothing allocated, nothing released"
|
||||
);
|
||||
}
|
||||
|
|
@ -233,349 +233,5 @@ impl Unlocker for FreemkvUnlocker {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::firmware::{REGION_FREE, STATE_OFF, build_identity_cdb, build_memread_cdb};
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
|
||||
|
||||
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown)
|
||||
}
|
||||
|
||||
/// The fw IDENTITY wire shape: `freemkv <ver>` (no NUL), the raw flag
|
||||
/// table, then zero padding to the 64-byte window.
|
||||
fn identity_payload(version: &str, flags: &[u8]) -> Vec<u8> {
|
||||
let mut p = b"freemkv ".to_vec();
|
||||
p.extend_from_slice(version.as_bytes());
|
||||
p.extend_from_slice(flags);
|
||||
p.resize(RESP_LEN, 0);
|
||||
p
|
||||
}
|
||||
|
||||
/// A fresh-boot fw 0.9.2 drive (DEFAULT_FLAGS: Unrestricted + Bd on).
|
||||
fn freemkv_identity_payload() -> Vec<u8> {
|
||||
identity_payload("0.9.2", &[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF])
|
||||
}
|
||||
|
||||
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
|
||||
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked). 36 =
|
||||
/// the fixed `0xAD` fmt-0x80 response length (see `crate::vid`).
|
||||
fn vid_ds_response(vid: [u8; 16]) -> Vec<u8> {
|
||||
let mut p = vec![0u8; 36];
|
||||
p[4..20].copy_from_slice(&vid);
|
||||
p
|
||||
}
|
||||
|
||||
fn unlock(t: &mut MockTransport) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
let id = crate::DriveId::default();
|
||||
FreemkvUnlocker::new().unlock(t, &ctx(&id))
|
||||
}
|
||||
|
||||
/// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its
|
||||
/// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command),
|
||||
/// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like
|
||||
/// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix
|
||||
/// the whole freemkv unlock failed at the first SET and fell back to AACS.
|
||||
#[test]
|
||||
fn set_issues_the_required_data_in_phase() {
|
||||
struct DataInContract;
|
||||
impl ScsiTransport for DataInContract {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
assert_eq!(cdb[4], 0x02, "test exercises only the SET verb");
|
||||
// Model the drive: a SET with no from-device phase is aborted.
|
||||
if !matches!(dir, DataDirection::FromDevice) || data.is_empty() {
|
||||
return Ok(ScsiResult {
|
||||
status: 2,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
FreemkvUnlocker::new()
|
||||
.set(&mut DataInContract, Feature::Region, REGION_FREE)
|
||||
.expect("SET must succeed by issuing the data-in phase the firmware requires");
|
||||
}
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identify_true_on_supported_firmware_and_issues_identity_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
|
||||
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_non_matching_payload() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![0u8; RESP_LEN]));
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_short_response() {
|
||||
let mut t = MockTransport::always(Reply::short(freemkv_identity_payload(), 3));
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
/// The status guard decides: a CHECK CONDITION that nonetheless reports a
|
||||
/// full magic-bearing transfer (residual/echoed buffer) is not freemkv.
|
||||
#[test]
|
||||
fn identify_false_on_check_condition_with_a_magic_buffer() {
|
||||
struct CheckConditionWithData;
|
||||
impl ScsiTransport for CheckConditionWithData {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let p = freemkv_identity_payload();
|
||||
data.copy_from_slice(&p[..data.len()]);
|
||||
Ok(ScsiResult {
|
||||
status: 2,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!FreemkvUnlocker::new()
|
||||
.identify(&mut CheckConditionWithData)
|
||||
.expect("no fault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_when_command_rejected() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_when_command_rejected_as_err() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request_as_err());
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_transport_fault_aborts() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(
|
||||
FreemkvUnlocker::new().identify(&mut t).unwrap_err(),
|
||||
UnlockError::Transport
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: released fw 0.7.x (`freemkv 0.7.1` reply, old sub-function
|
||||
/// grammar where 0x02 = Speed) answers GOOD to every SET. The unlocker must
|
||||
/// decline it, not claim a drive it never unlocked.
|
||||
#[test]
|
||||
fn declines_legacy_07_firmware_without_sending_a_set() {
|
||||
let mut t = MockTransport::always(Reply::good(identity_payload("0.7.1", &[])));
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
|
||||
}
|
||||
|
||||
/// Regression: fw 0.8.x (0x06 = `Ake`, separate `Bus`): de-bus via 0x06
|
||||
/// alone is not validated (hardware-dependent), so decline.
|
||||
#[test]
|
||||
fn declines_08_firmware_without_sending_a_set() {
|
||||
let flags = [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF, 0xFF];
|
||||
let mut t = MockTransport::always(Reply::good(identity_payload("0.8.3", &flags)));
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
|
||||
}
|
||||
|
||||
// ── Feature sets ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_drive_rejection_is_not_applicable() {
|
||||
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::always(reply.clone());
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Encryption, STATE_OFF)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable, "{reply:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Speed, SPEED_MAX)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── full_unlock / unlock ─────────────────────────────────────────────
|
||||
|
||||
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
|
||||
/// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire
|
||||
/// bytes for every vendor SET.
|
||||
#[test]
|
||||
fn full_unlock_issues_identity_region_speed_unrestricted_encryption_then_bare_vid() {
|
||||
let vid = [0x7Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // IDENTITY
|
||||
Reply::good(vec![]), // Set Region
|
||||
Reply::good(vec![]), // Set Speed
|
||||
Reply::good(vec![]), // Set Unrestricted
|
||||
Reply::good(vec![]), // Set Encryption (load-bearing)
|
||||
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t)
|
||||
.expect("no fault")
|
||||
.expect("encryption off ⇒ unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
assert_eq!(out.bus_key, None);
|
||||
assert_eq!(t.cdbs.len(), 6);
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
let set = |f: u8, s: u8| vec![0x3C, 0x0E, 0xC0, 0xDE, 0x02, f, s, 0x00, 0x40, 0x00];
|
||||
assert_eq!(t.cdbs[1], set(0x02, 0x0F), "Region = free");
|
||||
assert_eq!(t.cdbs[2], set(0x01, 0x00), "Speed = max");
|
||||
assert_eq!(t.cdbs[3], set(0x03, 0x01), "Unrestricted = on");
|
||||
assert_eq!(t.cdbs[4], set(0x06, 0x00), "Encryption = off");
|
||||
assert_eq!(t.cdbs[5][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
}
|
||||
|
||||
/// Missing region/speed/unrestricted features do not fail the unlock
|
||||
/// (best-effort), whichever rejection shape the transport uses.
|
||||
#[test]
|
||||
fn full_unlock_tolerates_missing_best_effort_features() {
|
||||
let vid = [0x3Cu8; 16];
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
reject.clone(), // region
|
||||
reject.clone(), // speed
|
||||
reject.clone(), // unrestricted
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vid_ds_response(vid)),
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t).expect("no fault").expect("unlocked");
|
||||
assert_eq!(out.vid, Some(vid), "{reject:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it (either
|
||||
/// shape), `unlock()` declines (`None`) and falls through.
|
||||
#[test]
|
||||
fn declines_when_disable_encryption_rejected() {
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
reject.clone(), // Set(Encryption,off) REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
assert!(unlock(&mut t).expect("no fault").is_none(), "{reject:?}");
|
||||
assert_eq!(t.calls(), 5, "no VID read after the refusal");
|
||||
}
|
||||
}
|
||||
|
||||
/// A dead bus at ANY step after IDENTITY aborts with `Transport` (never a
|
||||
/// fall-through): each best-effort SET, the load-bearing SET, the VID read.
|
||||
#[test]
|
||||
fn dead_bus_after_identity_aborts_at_every_step() {
|
||||
for good_before_fault in 0..5 {
|
||||
let mut script = vec![Reply::good(freemkv_identity_payload())];
|
||||
script.extend(std::iter::repeat_n(Reply::good(vec![]), good_before_fault));
|
||||
let mut t = MockTransport::scripted(script, Reply::TransportFault);
|
||||
assert_eq!(
|
||||
unlock(&mut t).unwrap_err(),
|
||||
UnlockError::Transport,
|
||||
"fault after {good_before_fault} SETs"
|
||||
);
|
||||
assert_eq!(t.calls(), good_before_fault + 2, "aborted at the fault");
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the
|
||||
/// bare VID read was rejected — `unlock()` still returns `Some`, with
|
||||
/// `vid: None`.
|
||||
#[test]
|
||||
fn unlocks_without_vid_when_bare_read_fails() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]), // Set(Encryption,off)
|
||||
Reply::illegal_request(), // bare VID: no medium
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t)
|
||||
.expect("no fault")
|
||||
.expect("unlocked despite no VID");
|
||||
assert_eq!(out.vid, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declines_non_freemkv_drive() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── DumpAll ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
|
||||
let mut payload = vec![0xABu8; MEMREAD_LEN];
|
||||
payload[0] = 0xEE;
|
||||
let mut t = MockTransport::always(Reply::good(payload));
|
||||
let got = FreemkvUnlocker::new()
|
||||
.dump_ram(&mut t, 0x01F8_1234)
|
||||
.expect("dump ok");
|
||||
assert_eq!(got[0], 0xEE);
|
||||
assert_eq!(t.cdbs[0], build_memread_cdb(0x01F8_1234).to_vec());
|
||||
}
|
||||
|
||||
/// A GOOD-status short DUMPALL is not a zero-padded RAM window.
|
||||
#[test]
|
||||
fn dump_ram_rejects_a_short_transfer() {
|
||||
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
|
||||
assert_eq!(
|
||||
FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(),
|
||||
UnlockError::NotApplicable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_is_freemkv() {
|
||||
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
344
src/freemkv/mod_tests.rs
Normal file
344
src/freemkv/mod_tests.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::firmware::{REGION_FREE, STATE_OFF, build_identity_cdb, build_memread_cdb};
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
|
||||
|
||||
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown)
|
||||
}
|
||||
|
||||
/// The fw IDENTITY wire shape: `freemkv <ver>` (no NUL), the raw flag
|
||||
/// table, then zero padding to the 64-byte window.
|
||||
fn identity_payload(version: &str, flags: &[u8]) -> Vec<u8> {
|
||||
let mut p = b"freemkv ".to_vec();
|
||||
p.extend_from_slice(version.as_bytes());
|
||||
p.extend_from_slice(flags);
|
||||
p.resize(RESP_LEN, 0);
|
||||
p
|
||||
}
|
||||
|
||||
/// A fresh-boot fw 0.9.2 drive (DEFAULT_FLAGS: Unrestricted + Bd on).
|
||||
fn freemkv_identity_payload() -> Vec<u8> {
|
||||
identity_payload("0.9.2", &[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF])
|
||||
}
|
||||
|
||||
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
|
||||
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked). 36 =
|
||||
/// the fixed `0xAD` fmt-0x80 response length (see `crate::vid`).
|
||||
fn vid_ds_response(vid: [u8; 16]) -> Vec<u8> {
|
||||
let mut p = vec![0u8; 36];
|
||||
p[4..20].copy_from_slice(&vid);
|
||||
p
|
||||
}
|
||||
|
||||
fn unlock(t: &mut MockTransport) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
let id = crate::DriveId::default();
|
||||
FreemkvUnlocker::new().unlock(t, &ctx(&id))
|
||||
}
|
||||
|
||||
/// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its
|
||||
/// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command),
|
||||
/// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like
|
||||
/// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix
|
||||
/// the whole freemkv unlock failed at the first SET and fell back to AACS.
|
||||
#[test]
|
||||
fn set_issues_the_required_data_in_phase() {
|
||||
struct DataInContract;
|
||||
impl ScsiTransport for DataInContract {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
assert_eq!(cdb[4], 0x02, "test exercises only the SET verb");
|
||||
// Model the drive: a SET with no from-device phase is aborted.
|
||||
if !matches!(dir, DataDirection::FromDevice) || data.is_empty() {
|
||||
return Ok(ScsiResult {
|
||||
status: 2,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
FreemkvUnlocker::new()
|
||||
.set(&mut DataInContract, Feature::Region, REGION_FREE)
|
||||
.expect("SET must succeed by issuing the data-in phase the firmware requires");
|
||||
}
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identify_true_on_supported_firmware_and_issues_identity_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
|
||||
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_non_matching_payload() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![0u8; RESP_LEN]));
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_short_response() {
|
||||
let mut t = MockTransport::always(Reply::short(freemkv_identity_payload(), 3));
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
/// The status guard decides: a CHECK CONDITION that nonetheless reports a
|
||||
/// full magic-bearing transfer (residual/echoed buffer) is not freemkv.
|
||||
#[test]
|
||||
fn identify_false_on_check_condition_with_a_magic_buffer() {
|
||||
struct CheckConditionWithData;
|
||||
impl ScsiTransport for CheckConditionWithData {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let p = freemkv_identity_payload();
|
||||
data.copy_from_slice(&p[..data.len()]);
|
||||
Ok(ScsiResult {
|
||||
status: 2,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!FreemkvUnlocker::new()
|
||||
.identify(&mut CheckConditionWithData)
|
||||
.expect("no fault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_when_command_rejected() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_when_command_rejected_as_err() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request_as_err());
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_transport_fault_aborts() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(
|
||||
FreemkvUnlocker::new().identify(&mut t).unwrap_err(),
|
||||
UnlockError::Transport
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: released fw 0.7.x (`freemkv 0.7.1` reply, old sub-function
|
||||
/// grammar where 0x02 = Speed) answers GOOD to every SET. The unlocker must
|
||||
/// decline it, not claim a drive it never unlocked.
|
||||
#[test]
|
||||
fn declines_legacy_07_firmware_without_sending_a_set() {
|
||||
let mut t = MockTransport::always(Reply::good(identity_payload("0.7.1", &[])));
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
|
||||
}
|
||||
|
||||
/// Regression: fw 0.8.x (0x06 = `Ake`, separate `Bus`): de-bus via 0x06
|
||||
/// alone is not validated (hardware-dependent), so decline.
|
||||
#[test]
|
||||
fn declines_08_firmware_without_sending_a_set() {
|
||||
let flags = [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF, 0xFF];
|
||||
let mut t = MockTransport::always(Reply::good(identity_payload("0.8.3", &flags)));
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
|
||||
}
|
||||
|
||||
// ── Feature sets ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_drive_rejection_is_not_applicable() {
|
||||
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::always(reply.clone());
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Encryption, STATE_OFF)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable, "{reply:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Speed, SPEED_MAX)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── full_unlock / unlock ─────────────────────────────────────────────
|
||||
|
||||
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
|
||||
/// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire
|
||||
/// bytes for every vendor SET.
|
||||
#[test]
|
||||
fn full_unlock_issues_identity_region_speed_unrestricted_encryption_then_bare_vid() {
|
||||
let vid = [0x7Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // IDENTITY
|
||||
Reply::good(vec![]), // Set Region
|
||||
Reply::good(vec![]), // Set Speed
|
||||
Reply::good(vec![]), // Set Unrestricted
|
||||
Reply::good(vec![]), // Set Encryption (load-bearing)
|
||||
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t)
|
||||
.expect("no fault")
|
||||
.expect("encryption off ⇒ unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
assert_eq!(out.bus_key, None);
|
||||
assert_eq!(t.cdbs.len(), 6);
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
let set = |f: u8, s: u8| vec![0x3C, 0x0E, 0xC0, 0xDE, 0x02, f, s, 0x00, 0x40, 0x00];
|
||||
assert_eq!(t.cdbs[1], set(0x02, 0x0F), "Region = free");
|
||||
assert_eq!(t.cdbs[2], set(0x01, 0x00), "Speed = max");
|
||||
assert_eq!(t.cdbs[3], set(0x03, 0x01), "Unrestricted = on");
|
||||
assert_eq!(t.cdbs[4], set(0x06, 0x00), "Encryption = off");
|
||||
assert_eq!(t.cdbs[5][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
}
|
||||
|
||||
/// Missing region/speed/unrestricted features do not fail the unlock
|
||||
/// (best-effort), whichever rejection shape the transport uses.
|
||||
#[test]
|
||||
fn full_unlock_tolerates_missing_best_effort_features() {
|
||||
let vid = [0x3Cu8; 16];
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
reject.clone(), // region
|
||||
reject.clone(), // speed
|
||||
reject.clone(), // unrestricted
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vid_ds_response(vid)),
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t).expect("no fault").expect("unlocked");
|
||||
assert_eq!(out.vid, Some(vid), "{reject:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it (either
|
||||
/// shape), `unlock()` declines (`None`) and falls through.
|
||||
#[test]
|
||||
fn declines_when_disable_encryption_rejected() {
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
reject.clone(), // Set(Encryption,off) REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
assert!(unlock(&mut t).expect("no fault").is_none(), "{reject:?}");
|
||||
assert_eq!(t.calls(), 5, "no VID read after the refusal");
|
||||
}
|
||||
}
|
||||
|
||||
/// A dead bus at ANY step after IDENTITY aborts with `Transport` (never a
|
||||
/// fall-through): each best-effort SET, the load-bearing SET, the VID read.
|
||||
#[test]
|
||||
fn dead_bus_after_identity_aborts_at_every_step() {
|
||||
for good_before_fault in 0..5 {
|
||||
let mut script = vec![Reply::good(freemkv_identity_payload())];
|
||||
script.extend(std::iter::repeat_n(Reply::good(vec![]), good_before_fault));
|
||||
let mut t = MockTransport::scripted(script, Reply::TransportFault);
|
||||
assert_eq!(
|
||||
unlock(&mut t).unwrap_err(),
|
||||
UnlockError::Transport,
|
||||
"fault after {good_before_fault} SETs"
|
||||
);
|
||||
assert_eq!(t.calls(), good_before_fault + 2, "aborted at the fault");
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the
|
||||
/// bare VID read was rejected — `unlock()` still returns `Some`, with
|
||||
/// `vid: None`.
|
||||
#[test]
|
||||
fn unlocks_without_vid_when_bare_read_fails() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]), // Set(Encryption,off)
|
||||
Reply::illegal_request(), // bare VID: no medium
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t)
|
||||
.expect("no fault")
|
||||
.expect("unlocked despite no VID");
|
||||
assert_eq!(out.vid, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declines_non_freemkv_drive() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── DumpAll ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
|
||||
let mut payload = vec![0xABu8; MEMREAD_LEN];
|
||||
payload[0] = 0xEE;
|
||||
let mut t = MockTransport::always(Reply::good(payload));
|
||||
let got = FreemkvUnlocker::new()
|
||||
.dump_ram(&mut t, 0x01F8_1234)
|
||||
.expect("dump ok");
|
||||
assert_eq!(got[0], 0xEE);
|
||||
assert_eq!(t.cdbs[0], build_memread_cdb(0x01F8_1234).to_vec());
|
||||
}
|
||||
|
||||
/// A GOOD-status short DUMPALL is not a zero-padded RAM window.
|
||||
#[test]
|
||||
fn dump_ram_rejects_a_short_transfer() {
|
||||
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
|
||||
assert_eq!(
|
||||
FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(),
|
||||
UnlockError::NotApplicable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_is_freemkv() {
|
||||
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
|
||||
}
|
||||
|
|
@ -25,33 +25,5 @@ pub fn is_unlock_read_buffer(mode: u8, buf_id: u8) -> bool {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marker_is_four_bytes() {
|
||||
assert_eq!(UNLOCK_MARKER, b"MMkv");
|
||||
assert_eq!(UNLOCK_MARKER.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_variants_match() {
|
||||
// Variant A and variant B.
|
||||
assert!(is_unlock_read_buffer(1, 0x44));
|
||||
assert!(is_unlock_read_buffer(2, 0x77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_unlock_cdbs_do_not_match() {
|
||||
// Right mode, wrong buffer id.
|
||||
assert!(!is_unlock_read_buffer(1, 0x77));
|
||||
assert!(!is_unlock_read_buffer(2, 0x44));
|
||||
// Wrong mode, right buffer id.
|
||||
assert!(!is_unlock_read_buffer(0, 0x77));
|
||||
assert!(!is_unlock_read_buffer(0, 0x44));
|
||||
assert!(!is_unlock_read_buffer(3, 0x77));
|
||||
// Ordinary data reads.
|
||||
assert!(!is_unlock_read_buffer(2, 0x00));
|
||||
assert!(!is_unlock_read_buffer(0, 0x00));
|
||||
}
|
||||
}
|
||||
#[path = "cdb_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
28
src/ld/cdb_tests.rs
Normal file
28
src/ld/cdb_tests.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marker_is_four_bytes() {
|
||||
assert_eq!(UNLOCK_MARKER, b"MMkv");
|
||||
assert_eq!(UNLOCK_MARKER.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_variants_match() {
|
||||
// Variant A and variant B.
|
||||
assert!(is_unlock_read_buffer(1, 0x44));
|
||||
assert!(is_unlock_read_buffer(2, 0x77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_unlock_cdbs_do_not_match() {
|
||||
// Right mode, wrong buffer id.
|
||||
assert!(!is_unlock_read_buffer(1, 0x77));
|
||||
assert!(!is_unlock_read_buffer(2, 0x44));
|
||||
// Wrong mode, right buffer id.
|
||||
assert!(!is_unlock_read_buffer(0, 0x77));
|
||||
assert!(!is_unlock_read_buffer(0, 0x44));
|
||||
assert!(!is_unlock_read_buffer(3, 0x77));
|
||||
// Ordinary data reads.
|
||||
assert!(!is_unlock_read_buffer(2, 0x00));
|
||||
assert!(!is_unlock_read_buffer(0, 0x00));
|
||||
}
|
||||
117
src/ld/error.rs
117
src/ld/error.rs
|
|
@ -87,118 +87,5 @@ impl From<Error> for crate::UnlockError {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
|
||||
|
||||
/// The abort/fall-through boundary, which had no test at all. Catches
|
||||
/// widening `is_transport_failure` (every failure would abort every rip) or
|
||||
/// narrowing it (a dead bus would be probed forever).
|
||||
#[test]
|
||||
fn only_a_senseless_transport_status_is_a_transport_failure() {
|
||||
let dead_bus = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(dead_bus.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(dead_bus), UnlockError::Transport);
|
||||
|
||||
// A CHECK CONDITION is the DRIVE refusing, not the bus dying.
|
||||
let refused = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
};
|
||||
assert!(!refused.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
|
||||
// The transport-failure status WITH a sense is a drive that reported
|
||||
// 0xFF, not a bus fault — it must not abort the consumer.
|
||||
let odd = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: Some([0u8; 32]),
|
||||
};
|
||||
assert!(!odd.is_transport_failure());
|
||||
|
||||
for e in [
|
||||
Error::ProfileParse,
|
||||
Error::UnlockFailed,
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
},
|
||||
] {
|
||||
assert!(!e.is_transport_failure(), "{e:?} is not a bus fault");
|
||||
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Display` is what shows up in logs — every variant must render without
|
||||
/// panicking and the SCSI variant must carry the opcode/status through.
|
||||
#[test]
|
||||
fn display_renders_every_variant() {
|
||||
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
|
||||
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
|
||||
assert_eq!(
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
}
|
||||
.to_string(),
|
||||
"signature mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.to_string(),
|
||||
"SCSI error (opcode 0x3c, status 0x02)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` must carry the status AND sense across the seam —
|
||||
/// collapsing either one destroys the transport-vs-rejection distinction the
|
||||
/// whole abort/fall-through split rests on.
|
||||
#[test]
|
||||
fn scsi_error_conversion_preserves_status_and_sense() {
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(e.is_transport_failure());
|
||||
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(!e.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Error codes are stable and distinct — they are the numeric contract the
|
||||
/// logs carry instead of English.
|
||||
#[test]
|
||||
fn error_codes_are_distinct_and_stable() {
|
||||
let codes = [
|
||||
Error::ProfileParse.code(),
|
||||
Error::UnlockFailed.code(),
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [0; 4],
|
||||
}
|
||||
.code(),
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: 0,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
];
|
||||
assert_eq!(codes, [7101, 7102, 7103, 7199]);
|
||||
}
|
||||
}
|
||||
#[path = "error_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
113
src/ld/error_tests.rs
Normal file
113
src/ld/error_tests.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use super::*;
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
|
||||
|
||||
/// The abort/fall-through boundary, which had no test at all. Catches
|
||||
/// widening `is_transport_failure` (every failure would abort every rip) or
|
||||
/// narrowing it (a dead bus would be probed forever).
|
||||
#[test]
|
||||
fn only_a_senseless_transport_status_is_a_transport_failure() {
|
||||
let dead_bus = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(dead_bus.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(dead_bus), UnlockError::Transport);
|
||||
|
||||
// A CHECK CONDITION is the DRIVE refusing, not the bus dying.
|
||||
let refused = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
};
|
||||
assert!(!refused.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
|
||||
// The transport-failure status WITH a sense is a drive that reported
|
||||
// 0xFF, not a bus fault — it must not abort the consumer.
|
||||
let odd = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: Some([0u8; 32]),
|
||||
};
|
||||
assert!(!odd.is_transport_failure());
|
||||
|
||||
for e in [
|
||||
Error::ProfileParse,
|
||||
Error::UnlockFailed,
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
},
|
||||
] {
|
||||
assert!(!e.is_transport_failure(), "{e:?} is not a bus fault");
|
||||
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Display` is what shows up in logs — every variant must render without
|
||||
/// panicking and the SCSI variant must carry the opcode/status through.
|
||||
#[test]
|
||||
fn display_renders_every_variant() {
|
||||
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
|
||||
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
|
||||
assert_eq!(
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
}
|
||||
.to_string(),
|
||||
"signature mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.to_string(),
|
||||
"SCSI error (opcode 0x3c, status 0x02)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` must carry the status AND sense across the seam —
|
||||
/// collapsing either one destroys the transport-vs-rejection distinction the
|
||||
/// whole abort/fall-through split rests on.
|
||||
#[test]
|
||||
fn scsi_error_conversion_preserves_status_and_sense() {
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(e.is_transport_failure());
|
||||
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(!e.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Error codes are stable and distinct — they are the numeric contract the
|
||||
/// logs carry instead of English.
|
||||
#[test]
|
||||
fn error_codes_are_distinct_and_stable() {
|
||||
let codes = [
|
||||
Error::ProfileParse.code(),
|
||||
Error::UnlockFailed.code(),
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [0; 4],
|
||||
}
|
||||
.code(),
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: 0,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
];
|
||||
assert_eq!(codes, [7101, 7102, 7103, 7199]);
|
||||
}
|
||||
433
src/ld/mod.rs
433
src/ld/mod.rs
|
|
@ -200,434 +200,5 @@ impl Unlocker for LdUnlocker {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Unlock context for a fake drive id (kind/host-certs irrelevant to the
|
||||
/// firmware unlocker — it keys off the drive identity).
|
||||
fn ctx(id: &DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown)
|
||||
}
|
||||
|
||||
/// A fake transport that fills the response buffer from a fixed payload and
|
||||
/// reports a configurable transferred-byte count.
|
||||
struct FakeTransport {
|
||||
payload: Vec<u8>,
|
||||
bytes_transferred: usize,
|
||||
}
|
||||
impl ScsiTransport for FakeTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A DriveId for the bundled HL-DT-ST profile that carries a real
|
||||
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
|
||||
fn known_vid_drive_id() -> DriveId {
|
||||
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
|
||||
}
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled profile of the fixture drive (the one carrying a real
|
||||
/// `read_vid_cdb`).
|
||||
fn known_vid_profile() -> profile::DriveProfile {
|
||||
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||
assert!(
|
||||
m.profile.read_vid_cdb.is_some(),
|
||||
"test fixture drive must carry an OEM VID CDB"
|
||||
);
|
||||
m.profile
|
||||
}
|
||||
|
||||
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
|
||||
/// to `Some(vid)`.
|
||||
#[test]
|
||||
fn read_oem_vid_parses_well_formed_response() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
|
||||
let vid = [0x3Cu8; 16];
|
||||
payload[4..20].copy_from_slice(&vid);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("parse ok");
|
||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||
}
|
||||
|
||||
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
|
||||
#[test]
|
||||
fn read_oem_vid_short_response_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("short response is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_bad_header_is_none() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("bad header is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// Catches dropping the `result.status` check: a CHECK CONDITION arrives as
|
||||
/// `Ok` per the transport contract, so without it the caller's zero-filled
|
||||
/// buffer is parsed as a real 36-byte response.
|
||||
#[test]
|
||||
fn read_oem_vid_check_condition_is_none_not_a_vid() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("a drive sense is not a transport fault");
|
||||
assert_eq!(got, None, "a CHECK CONDITION must never yield a VID");
|
||||
}
|
||||
|
||||
/// Catches swallowing a transport fault in the OEM-VID read: a dead bus must
|
||||
/// propagate (→ `UnlockError::Transport`), never become `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_transport_fault_propagates() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect_err("a dead bus must not be Ok(None)");
|
||||
assert!(err.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(err), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// A profile with no OEM-VID CDB → `Ok(None)` without touching the drive.
|
||||
#[test]
|
||||
fn read_oem_vid_no_cdb_is_none() {
|
||||
let mut p = known_vid_profile();
|
||||
p.read_vid_cdb = None;
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &p)
|
||||
.expect("no CDB is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// Public catalog accessors: `profiles()` returns the bundled catalog and
|
||||
/// `profile()` is `profiles().and_then(get)` for a known drive.
|
||||
#[test]
|
||||
fn public_catalog_accessors_find_the_bundled_fixture_drive() {
|
||||
assert!(super::profiles().is_some(), "bundled catalog must parse");
|
||||
let m = super::profile(&known_vid_drive_id()).expect("known fixture drive matches");
|
||||
assert!(m.profile.read_vid_cdb.is_some());
|
||||
}
|
||||
|
||||
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
|
||||
/// (fall through), short-circuiting before any firmware handshake.
|
||||
#[test]
|
||||
fn unlock_no_profile_is_not_applicable() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let unlocked = LdUnlocker::new()
|
||||
.unlock(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
.expect("no profile → declines, not a hard error");
|
||||
assert!(unlocked.is_none());
|
||||
}
|
||||
|
||||
// THE defect-1 test: response carries the signature + primary marker but not
|
||||
// the secondary one, so init() succeeds but the drive isn't in extended-access
|
||||
// state. Catches removing the `is_unlocked()` gate in `firmware_unlock`.
|
||||
#[test]
|
||||
fn half_unlocked_drive_falls_through_instead_of_claiming_unlocked() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
// 64-byte unlock response: signature + primary marker "MMkv" at [12..16],
|
||||
// secondary marker at [16..20] left zeroed.
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
|
||||
let mut t = MockTransport::always(Reply::good(resp));
|
||||
let unlocked = LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("a half-unlock declines, not a hard error");
|
||||
assert!(
|
||||
unlocked.is_none(),
|
||||
"a half-unlocked drive must fall through to cert-auth"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole-unlock happy path still reports unlocked when BOTH firmware
|
||||
/// markers are present — the `is_unlocked()` gate must not have made a
|
||||
/// genuinely unlocked drive fall through.
|
||||
#[test]
|
||||
fn fully_unlocked_drive_reports_unlocked() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
|
||||
|
||||
let mut t = MockTransport::always(Reply::good(resp));
|
||||
assert!(
|
||||
LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.is_some(),
|
||||
"both markers → unlocked"
|
||||
);
|
||||
}
|
||||
|
||||
// THE probe-disc dead-bus test: bus dies during speed calibration after a
|
||||
// full unlock; must abort with Transport, not report a successful unlock.
|
||||
#[test]
|
||||
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
// A drive that unlocks fully but whose bus dies on the speed probe.
|
||||
struct ProbeFaultsDrive {
|
||||
resp: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for ProbeFaultsDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
|
||||
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
|
||||
return Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let n = self.resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.resp[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
|
||||
let mut t = ProbeFaultsDrive { resp };
|
||||
|
||||
let err = LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect_err("a dead bus during probe must abort, not report success");
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
// A drive-sense (not a dead bus) rejecting the speed probe is a genuine
|
||||
// calibration miss: warn-and-continue on the default speed table, still
|
||||
// reporting a full unlock. Non-transport sibling of the probe-fault test.
|
||||
#[test]
|
||||
fn drive_sense_during_probe_disc_still_reports_a_successful_unlock() {
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
|
||||
|
||||
struct ProbeSenseDrive {
|
||||
resp: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for ProbeSenseDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
|
||||
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
let n = self.resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.resp[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = ProbeSenseDrive { resp };
|
||||
assert!(
|
||||
LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("a calibration miss must not fail the whole unlock")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Catches classifying a dead bus as "not this unlocker's drive": the very
|
||||
/// first unlock command faulting at the transport layer must abort the
|
||||
/// consumer (`Transport`), not fall through to the next unlocker.
|
||||
// Review of ST-D10: after a confirmed firmware upload, a drive whose media never
|
||||
// becomes ready (the same 02/04/01 for 60 s, T6) must still report the unlock,
|
||||
// and probe_disc + the VID read must still run (not `NotApplicable`).
|
||||
#[test]
|
||||
fn readiness_stall_after_upload_keeps_the_unlock() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use platform::mt1959::tests::{FakeClock, TEST_READY_CLOCK};
|
||||
struct UploadThenNotReady {
|
||||
sig: [u8; 4],
|
||||
unlock_calls: usize,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
impl ScsiTransport for UploadThenNotReady {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
let unlock = cdb[0] == 0x3C && matches!(cdb[2], 0x44 | 0x77) && cdb[3] == 0;
|
||||
let (status, n) = if cdb[0] == 0x00 {
|
||||
let mut sense = [0u8; 32];
|
||||
(sense[2], sense[12], sense[13]) = (0x02, 0x04, 0x01);
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense,
|
||||
});
|
||||
} else if unlock && cdb[8] == 64 {
|
||||
self.unlock_calls += 1;
|
||||
data.fill(0);
|
||||
data[0..4].copy_from_slice(&self.sig);
|
||||
data[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
data[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
|
||||
// The first unlock fails short, forcing the firmware upload.
|
||||
(0, if self.unlock_calls == 1 { 10 } else { 64 })
|
||||
} else {
|
||||
data.fill(0);
|
||||
(0, data.len())
|
||||
};
|
||||
Ok(ScsiResult {
|
||||
status,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
let mut t = UploadThenNotReady {
|
||||
sig,
|
||||
unlock_calls: 0,
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
TEST_READY_CLOCK.set(Some(|| Box::new(FakeClock::new())));
|
||||
let r = LdUnlocker::new().unlock(&mut t, &ctx(&id));
|
||||
TEST_READY_CLOCK.set(None);
|
||||
let unlocked = r.expect("no fault");
|
||||
assert!(
|
||||
unlocked.is_some(),
|
||||
"the confirmed unlock stands despite the stall"
|
||||
);
|
||||
let uploaded = t.cdbs.iter().any(|c| matches!(c[0], 0x3B | 0x55));
|
||||
assert!(uploaded, "the firmware upload ran");
|
||||
let last_tur = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.rposition(|c| c[0] == 0x00)
|
||||
.expect("polled TUR");
|
||||
let turs = t.cdbs.iter().filter(|c| c[0] == 0x00).count();
|
||||
assert_eq!(turs, 121, "polled until the 60 s stall (virtual clock)");
|
||||
let capacity = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.rposition(|c| c[0] == 0x25)
|
||||
.expect("probe_disc ran");
|
||||
assert!(capacity > last_tur, "probe_disc runs after the poll");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_during_unlock_is_transport_not_not_applicable() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&known_vid_drive_id()))
|
||||
.expect_err("dead bus");
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue