test: round-2 coverage on css bus-auth, mt1959 retry/probe, aacs handshake

Extends the fake ScsiTransport in css::mod to run the full CSS bus-auth
happy path (variant brute-force, drive-challenge/Key2 exchange, best-effort
disc-key REPORT KEY, DvdUnlocker::unlock_bus success) instead of only its
failure branches.

Covers mt1959's run_init retry loop (signature-mismatch abort, generic
do_unlock failure with firmware-reload fallback, firmware-reload transport
fault), run_probe's disc-type detection (capacity sense fallback, UHD vs BD
init address, pass-2 early-end on a rejected probe), and the PlatformDriver
guard clauses.

Covers aacs/handshake's EC math edge cases (mod_inv/ec_add/ec_double/ec_mul
defensive branches, off-curve pubkey rejection), the Debug redaction impls,
the AACS 1.0 type-0x01 cert-verify gate, the P-256 thin wrapper + short-cert
guard + step-6 bad-signature rejection, and drives run_cert_handshake to its
full Ok(CertHandshake) success path via an extended drive emulator.
This commit is contained in:
Matthew Jackson
2026-08-25 21:48:47 -07:00
parent 781b498e4e
commit b3d1b99460
3 changed files with 697 additions and 6 deletions
+272 -6
View File
@@ -1901,6 +1901,10 @@ mod tests {
drive_y: [u8; 20],
vid: [u8; 16],
bus_key: Option<[u8; 16]>,
/// When set, format-0x84 (read-data-keys) is served a genuine non-zero
/// response instead of the default dead-bus behaviour, letting
/// `run_cert_handshake` run all the way to its `Ok` return.
serve_data_keys: bool,
}
impl DriveEmu {
@@ -1912,6 +1916,7 @@ mod tests {
drive_y,
vid: [0x5Au8; 16],
bus_key: None,
serve_data_keys: false,
}
}
}
@@ -1974,11 +1979,21 @@ mod tests {
r[20..36].copy_from_slice(&mac);
ok(r, data)
}
// format 0x84 read-data-keys: the bus dies here.
_ => Err(crate::scsi::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
// format 0x84 read-data-keys: the bus dies here, unless
// the test opted into a full success path.
_ => {
if self.serve_data_keys {
let mut r = vec![0u8; 36];
r[4..20].copy_from_slice(&[0x7Bu8; 16]); // enc read data key
r[20..36].copy_from_slice(&[0x7Cu8; 16]); // enc write data key
ok(r, data)
} else {
Err(crate::scsi::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
})
}
}
},
_ => ok(vec![0u8; 2], data),
}
@@ -2849,6 +2864,10 @@ mod tests {
/// When set, step 6 serves an OFF-CURVE key point (identity-ish) so the
/// host's `compute_bus_key_p256` invalid-curve guard must reject it.
off_curve_point: bool,
/// When set, step 6's key-point signature is corrupted AFTER signing
/// (so the signed message is genuine but the wire bytes are not) — the
/// host's `ecdsa_verify_p256` at step 6 must reject it.
bad_step6_sig: bool,
host_nonce: [u8; 20],
bus_key: Option<[u8; 16]>,
}
@@ -2866,6 +2885,7 @@ mod tests {
eph_y,
cert,
off_curve_point: false,
bad_step6_sig: false,
host_nonce: [0u8; 20],
bus_key: None,
}
@@ -2911,7 +2931,10 @@ mod tests {
signed.extend_from_slice(&self.host_nonce);
signed.extend_from_slice(&px);
signed.extend_from_slice(&py);
let (sr, ss) = ecdsa_sign_p256(&self.lt_priv, &signed);
let (sr, mut ss) = ecdsa_sign_p256(&self.lt_priv, &signed);
if self.bad_step6_sig {
ss[31] ^= 0xFF; // corrupt the wire signature, not the signed message
}
let mut r = vec![0u8; 132];
r[4..36].copy_from_slice(&px);
r[36..68].copy_from_slice(&py);
@@ -3152,6 +3175,249 @@ mod tests {
);
}
// ── Round-2 coverage: EC math edge cases ────────────────────────────────
/// `mod_inv` returns `None` when `gcd(a, m) != 1` — the caller-facing
/// contract every call site (`ec_add`, `ec_double`, `ecdsa_verify*`)
/// pattern-matches on. Called directly with a non-coprime pair (both
/// even), since every production caller passes a genuinely prime curve
/// modulus for which this never fires.
#[test]
fn mod_inv_returns_none_for_non_coprime_inputs() {
let a = BigUint::from(4u32);
let m = BigUint::from(8u32); // gcd(4, 8) = 4, no inverse exists
assert!(mod_inv(&a, &m).is_none());
}
/// `ec_add`'s `dx_inv` guard: when the x-difference is not invertible mod
/// the modulus, addition must return the point at infinity rather than
/// panic or produce a bogus point. Only reachable with a non-prime
/// modulus (every real curve here uses a prime `p`, under which two
/// distinct field elements always have an invertible difference) — a
/// direct, pure-function exercise of the defensive branch.
#[test]
fn ec_add_returns_infinity_when_dx_not_invertible() {
let a = BigUint::from(1u32);
let p = BigUint::from(8u32); // composite modulus
let p1 = EcPoint::new(BigUint::from(0u32), BigUint::from(1u32));
let p2 = EcPoint::new(BigUint::from(4u32), BigUint::from(3u32)); // dx = 4, gcd(4,8)=4
let r = ec_add(&p1, &p2, &a, &p);
assert!(
r.infinity,
"a non-invertible dx must yield infinity, not a bogus point"
);
}
/// `ec_double`'s `denom_inv` guard: same shape as above, for the
/// doubling denominator `2y`.
#[test]
fn ec_double_returns_infinity_when_denominator_not_invertible() {
let a = BigUint::from(1u32);
let p = BigUint::from(8u32); // composite modulus
let pt = EcPoint::new(BigUint::from(1u32), BigUint::from(4u32)); // 2y = 8, gcd(8,8)=8
let r = ec_double(&pt, &a, &p);
assert!(
r.infinity,
"a non-invertible denominator must yield infinity"
);
}
/// `ec_mul` with a zero scalar is the point at infinity by definition —
/// the double-and-add loop must short-circuit rather than run.
#[test]
fn ec_mul_zero_scalar_returns_infinity() {
let a = BigUint::from_bytes_be(&EC_A);
let p = BigUint::from_bytes_be(&EC_P);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let r = ec_mul(&BigUint::zero(), &g, &a, &p);
assert!(r.infinity);
}
/// `ecdsa_verify` (AACS 1.0) rejects an off-curve public key up front —
/// the same invalid-curve defence `ecdsa_verify_p256` has its own direct
/// test for (`ecdsa_verify_p256_rejects_identity_key_forgery`), but the
/// 1.0 primitive had none.
#[test]
fn ecdsa_verify_rejects_off_curve_public_key() {
let (priv_key, px, py) = generate_host_key_pair();
let (r, s) = ecdsa_sign(&priv_key, b"payload");
// A genuinely off-curve point: flip a low byte of py.
let mut bad_py = py;
bad_py[19] ^= 0x01;
assert!(
!ecdsa_verify(&px, &bad_py, &r, &s, b"payload"),
"an off-curve public key must be rejected before any range/pairing check"
);
}
// ── Round-2 coverage: Debug redaction ───────────────────────────────────
/// `AacsAuth`'s hand-written `Debug` must redact `bus_key` / `volume_id` /
/// `read_data_key` (key material) while still showing whether each is
/// present, and must NOT panic on a populated instance.
#[test]
fn aacs_auth_debug_redacts_key_material() {
let auth = AacsAuth {
bus_key: [0x11u8; 16],
agid: 2,
volume_id: Some([0x22u8; 16]),
read_data_key: Some([0x33u8; 16]),
drive_cert: [0u8; 92],
};
let s = format!("{auth:?}");
assert!(
s.contains("[redacted]"),
"key material must be redacted: {s}"
);
assert!(!s.contains("17"), "no raw key byte (0x11=17) may leak: {s}");
assert!(
s.contains("agid"),
"non-secret fields must still print: {s}"
);
}
/// `CertHandshake`'s hand-written `Debug` likewise redacts `read_data_key`
/// and `volume_id`, and does not panic on `None`.
#[test]
fn cert_handshake_debug_redacts_key_material() {
let ch = CertHandshake {
volume_id: [0x44u8; 16],
read_data_key: Some([0x55u8; 16]),
read_data_key_err: None,
};
let s = format!("{ch:?}");
assert!(
s.contains("[redacted]"),
"key material must be redacted: {s}"
);
let ch_none = CertHandshake {
volume_id: [0u8; 16],
read_data_key: None,
read_data_key_err: Some(7006),
};
let s2 = format!("{ch_none:?}");
assert!(s2.contains("[redacted]"));
}
// ── Round-2 coverage: AACS 1.0 cert-verify gate ─────────────────────────
/// `aacs_authenticate` rejects a host cert shorter than 92 bytes before
/// issuing a single SCSI command.
#[test]
fn aacs_authenticate_v1_rejects_short_host_cert() {
let mut t = MockTransport::always(Reply::TransportFault);
let priv_key = [0u8; 20];
let err = aacs_authenticate(&mut t, &priv_key, &[0u8; 91])
.expect_err("a 91-byte cert is too short");
assert!(matches!(err, Error::AacsCertShort));
assert_eq!(t.calls(), 0, "must reject before touching the transport");
}
/// A type-0x01 (AACS 1.0) drive certificate whose LA signature does not
/// verify must be rejected at the cert-verify gate (`Error::AacsCertVerify`,
/// via `verify_cert`) — the type-0x01 sibling of the type-0x11
/// (`unknown_drive_cert_type_is_rejected_before_trusting_any_key`) and
/// type-0x02 gates, neither of which exercises this specific `if
/// drive_cert[0] == 0x01 { if !verify_cert(..) }` branch.
#[test]
fn aacs_authenticate_v1_type01_bad_signature_is_rejected() {
let mut cert_resp = vec![0u8; 116];
cert_resp[24] = 0x01; // type 0x01, but the rest is all zeros: sig r=s=0
let script = vec![
Reply::good(vec![0u8; 2]),
Reply::good(vec![0u8; 2]),
Reply::good(vec![0u8; 2]),
Reply::good(vec![0u8; 2]),
Reply::good(vec![0u8; 8]), // AGID alloc
Reply::good(vec![]), // SEND KEY host cert
Reply::good(cert_resp), // REPORT KEY drive cert (type 0x01, unsigned)
];
let mut t = MockTransport::scripted(script, Reply::good(vec![0u8; 2]));
let hc = dummy_cert();
let err = aacs_authenticate(&mut t, &hc.private_key, &hc.certificate)
.expect_err("an unsigned type-0x01 cert must fail LA verification");
assert!(matches!(err, Error::AacsCertVerify));
}
// ── Round-2 coverage: AACS 2.0 (P-256) wiring ───────────────────────────
/// `aacs2_authenticate_p256` — the thin wrapper threading the REAL
/// production LA anchor (`AACS2_LA_PUB_X/Y`), as opposed to the
/// test-injectable `_with_anchor` every other P-256 AKE test calls — had
/// no direct test. A dead bus on the very first step proves the wrapper
/// forwards correctly and its `if r.is_err() { release_agid }` cleanup
/// runs (the AGID-allocation call itself fails, so no AGID to release —
/// asserting the call count instead pins that no extra CDB is issued).
#[test]
fn aacs2_authenticate_p256_wrapper_propagates_transport_fault() {
let mut t = MockTransport::always(Reply::TransportFault);
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
let err =
aacs2_authenticate_p256(&mut t, &host_priv, &[0x11u8; 132]).expect_err("dead bus");
assert!(err.is_scsi_transport_failure());
}
/// `aacs2_authenticate_p256_with_anchor` rejects a host cert shorter than
/// 132 bytes before issuing a single SCSI command.
#[test]
fn aacs2_authenticate_p256_with_anchor_rejects_short_host_cert() {
let mut t = MockTransport::always(Reply::TransportFault);
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
let (_la_priv, la_x, la_y) = generate_host_key_pair_p256();
let err =
aacs2_authenticate_p256_with_anchor(&mut t, &host_priv, &[0u8; 131], &la_x, &la_y)
.expect_err("a 131-byte cert is too short");
assert!(matches!(err, Error::AacsCertShort));
assert_eq!(t.calls(), 0, "must reject before touching the transport");
}
/// THE step-6 signature proof, P-256 AKE: a validly LA-signed type-0x11
/// cert passes the cert-verify gate, but the drive's key-point signature
/// at step 6 is corrupted. Must abort at `ecdsa_verify_p256` (returning
/// `Error::AacsKeyVerify`) — distinct from the cert-verify gate
/// (`p256_ake_bad_cert_signature_is_fatal`) and the bus-key invalid-curve
/// guard (`p256_ake_off_curve_drive_point_is_rejected`), neither of which
/// exercises this specific check.
#[test]
fn p256_ake_bad_step6_signature_is_rejected() {
let (la_priv, la_x, la_y) = generate_host_key_pair_p256();
let (drive_lt_priv, drive_lt_x, drive_lt_y) = generate_host_key_pair_p256();
let cert = p256_synth_cert(0x11, &drive_lt_x, &drive_lt_y, &la_priv);
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
let mut emu = DriveEmuP256::new(drive_lt_priv, cert);
emu.bad_step6_sig = true;
let err =
aacs2_authenticate_p256_with_anchor(&mut emu, &host_priv, &[0x11u8; 132], &la_x, &la_y)
.expect_err("a corrupted step-6 signature must abort the AKE");
assert!(
matches!(err, Error::AacsKeyVerify),
"rejection must fire at the step-6 signature check; got {err:?}"
);
}
// ── Round-2 coverage: run_cert_handshake full success ───────────────────
/// THE full happy path: auth, Volume ID read, AND the read-data-keys
/// (bus key) read all succeed against a self-consistent drive emulator.
/// Every other `run_cert_handshake` test above deliberately stops short
/// (dead bus / bad MAC / all-zero keys) — this is the only one that
/// reaches the function's final `Ok(CertHandshake { .. })` return.
#[test]
fn run_cert_handshake_succeeds_end_to_end() {
let mut t = DriveEmu::new();
t.serve_data_keys = true;
let ch = run_cert_handshake(&mut t, &[dummy_cert()])
.expect("auth + VID + data-key reads all succeed");
assert_eq!(ch.volume_id, [0x5Au8; 16]);
assert!(
ch.read_data_key.is_some(),
"a served data-key block must decrypt"
);
assert!(ch.read_data_key_err.is_none());
}
/// SLOT for a REAL AACS 2.0 host certificate (a side agent is sourcing one).
/// When it lands, drop the genuine 132-byte drive cert + the real
/// `AACS2_LA_PUB_X/Y` anchor here and remove `#[ignore]`: it pins the exact
+148
View File
@@ -1290,4 +1290,152 @@ mod tests {
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 bus-auth handshake for
/// real: it captures the host's genuinely-random per-session challenge
/// and answers Key1 with the actual `crypt_key(0, variant, ..)` for a
/// fixed `variant`, so the host's brute-force loop matches it honestly
/// (not via a canned constant the loop happens to hit). Also answers
/// GET CONFIGURATION as a DVD and READ DVD STRUCTURE (disc key) as a
/// success, so the full `unlock_bus` path can be driven end to end.
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 allocation,
/// host challenge, a Key1 that genuinely matches the brute-forced
/// variant, the drive-challenge/Key2 exchange, and the disc-key REPORT
/// KEY all succeed. Exercises `establish_authenticated_session`'s `Ok`
/// return and `read_disc_key`'s success path, neither of which the
/// failure-path tests above 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 driven through the public `unlock_bus` entry
/// point (DVD self-guard, full bus-auth, best-effort disc key), proving
/// `DvdUnlocker::unlock_bus` returns `Ok(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_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
let unlocked = r.expect("full unlock succeeds");
assert!(unlocked.vid.is_none(), "CSS yields no Volume ID");
assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key");
assert!(
!unlocked.drive_unlocked,
"CSS is a bus-auth unlock, not a firmware drive-unlock"
);
}
/// `unlock_css_reads` (the crate-level public entry point, distinct from
/// 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");
}
/// `crypt_key`'s `key_type == 2` arm (`PERM_VARIANT[1]`) has no
/// production caller (only key_type 0, the variant brute-force, and 1,
/// the drive-challenge Key2, are ever invoked from `authenticate_with_agid`)
/// but is a pure, directly-testable function of its documented contract.
#[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)"
);
}
}
+277
View File
@@ -1039,6 +1039,283 @@ mod tests {
assert!(!mt.is_unlocked());
}
// ── run_init (the retry loop itself, not the leaf steps) ───────────────
/// A signature mismatch on the unlock response is another drive's token —
/// retrying accomplishes nothing, so `run_init` must abort on the FIRST
/// attempt with `UnlockFailed`, not retry three times.
#[test]
fn run_init_signature_mismatch_aborts_without_retry() {
let response = build_response(
[0xAA, 0xBB, 0xCC, 0xDD],
FIRMWARE_ACTIVE_SIG,
FIRMWARE_MODE_SIG,
);
let mut t =
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::good(response));
let mut mt = Mt1959::new(fixture_profile([0x99, 0x9E, 0xC3, 0x75]), false);
let e = mt.init(&mut t).expect_err("signature mismatch");
assert!(matches!(e, Error::UnlockFailed));
assert_eq!(t.calls(), 1, "must not retry a signature mismatch");
}
/// `run_init`'s retry loop: `do_unlock` fails generically (too short to
/// validate — not a signature mismatch, not a transport fault), the
/// firmware reload it falls back to also fails generically (an empty
/// firmware blob) rather than fatally, so the loop must `continue` and
/// try again — three times — before exhausting to the last error.
#[test]
fn run_init_exhausts_after_three_generic_failures() {
let mut t = crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::short(
vec![0u8; 64],
10,
));
// Empty firmware -> variant_a::load_firmware fails immediately with
// no CDB, so each attempt costs exactly one do_unlock call.
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
let e = mt.init(&mut t).expect_err("never validates, never uploads");
assert!(matches!(e, Error::UnlockFailed));
assert!(!e.is_transport_failure());
assert_eq!(
t.calls(),
3,
"one do_unlock call per attempt, three attempts, no firmware CDBs"
);
}
/// Same generic `do_unlock` failure, but this time the firmware-reload
/// fallback itself hits a dead bus. That must abort `run_init`
/// immediately (transport classification preserved), not be folded into
/// `last_err` and retried.
#[test]
fn run_init_firmware_reload_transport_fault_aborts() {
let mut profile = fixture_profile([0; 4]);
profile.firmware = vec![0u8; 64]; // non-empty: load_firmware issues a CDB
let mut t = crate::scsi::mock::MockTransport::scripted(
vec![crate::scsi::mock::Reply::short(vec![0u8; 64], 10)], // do_unlock: generic fail
crate::scsi::mock::Reply::TransportFault, // WRITE_BUFFER: dead bus
);
let mut mt = Mt1959::new(profile, false);
let e = mt
.init(&mut t)
.expect_err("dead bus during firmware reload");
assert!(e.is_transport_failure());
assert_eq!(crate::UnlockError::from(e), crate::UnlockError::Transport);
assert_eq!(
t.calls(),
2,
"one do_unlock call, then the WRITE_BUFFER that dies"
);
}
// ── run_probe entry / disc-type detection ───────────────────────────────
/// `run_probe` called before `init_complete` runs `do_unlock` itself
/// first (rather than assuming the caller already did).
#[test]
fn run_probe_runs_do_unlock_when_not_yet_initialized() {
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
let mut t =
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::good(response));
let mut mt = Mt1959::new(fixture_profile(sig), false);
assert!(!mt.init_complete);
let r = mt.run_probe(&mut t);
assert!(r.is_ok(), "probe completes: {r:?}");
assert!(mt.init_complete, "run_probe must do_unlock itself first");
}
/// A READ CAPACITY drive sense (status != 0) means "capacity unknown" ->
/// `disc_sectors` falls back to 0, which is still below the UHD
/// threshold, so probing continues with the BD init address rather than
/// aborting.
#[test]
fn run_probe_capacity_check_condition_falls_back_to_zero_sectors() {
struct CapacitySenseTransport;
impl ScsiTransport for CapacitySenseTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
if cdb.first() == Some(&SCSI_READ_CAPACITY) {
return Ok(ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
for b in data.iter_mut() {
*b = 0;
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
let mut t = CapacitySenseTransport;
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
mt.init_complete = true;
let r = mt.run_probe(&mut t);
assert!(
r.is_ok(),
"a capacity sense must not abort the probe: {r:?}"
);
}
/// A disc reporting more than `UHD_SECTOR_THRESHOLD` sectors selects the
/// UHD init address (0x0200) rather than the BD one (0x0100).
#[test]
fn run_probe_selects_uhd_init_addr_for_a_large_disc() {
struct BigDiscTransport {
init_addr_seen: std::cell::RefCell<Option<u16>>,
}
impl ScsiTransport for BigDiscTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
if cdb.first() == Some(&SCSI_READ_CAPACITY) {
// last_lba = UHD_SECTOR_THRESHOLD (well above threshold
// once +1'd), well past the 25M-sector cutoff.
let last_lba: u32 = UHD_SECTOR_THRESHOLD + 1_000_000;
data[..4].copy_from_slice(&last_lba.to_be_bytes());
return Ok(ScsiResult {
status: 0,
bytes_transferred: 4,
sense: [0u8; 32],
});
}
if cdb.first() == Some(&SCSI_READ_BUFFER) && cdb.get(3) == Some(&SUB_CMD_INIT) {
let addr = ((cdb[4] as u16) << 8) | cdb[5] as u16;
*self.init_addr_seen.borrow_mut() = Some(addr);
}
for b in data.iter_mut() {
*b = 0;
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
let mut t = BigDiscTransport {
init_addr_seen: std::cell::RefCell::new(None),
};
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
mt.init_complete = true;
let _ = mt.run_probe(&mut t);
assert_eq!(
*t.init_addr_seen.borrow(),
Some(INIT_ADDR_UHD),
"a >25M-sector disc must probe-init at the UHD address"
);
}
/// The fine (pass-2) probe sweep ends early on a merely-rejected probe
/// reply (not a transport fault) — it must `break` out of the sweep and
/// let `run_probe` still return `Ok`, rather than propagating the drive's
/// refusal as a hard failure.
#[test]
fn run_probe_pass2_ends_early_on_a_rejected_probe_without_failing() {
struct Pass2RejectsTransport {
fault_at: u32,
}
impl ScsiTransport for Pass2RejectsTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
if cdb.first() == Some(&SCSI_READ_BUFFER) && cdb.get(3) == Some(&SUB_CMD_PROBE) {
let addr = ((cdb[4] as u32) << 8) | cdb[5] as u32;
if addr >= self.fault_at {
return Ok(ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
}
for b in data.iter_mut() {
*b = 0;
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
let mut t = Pass2RejectsTransport {
fault_at: PROBE_COARSE_END as u32,
};
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
mt.init_complete = true;
let r = mt.run_probe(&mut t);
assert!(
r.is_ok(),
"a rejected fine probe must end the sweep, not fail run_probe: {r:?}"
);
assert!(mt.probed);
}
// ── PlatformDriver trait guard clauses ──────────────────────────────────
/// `init()` is a no-op once `init_complete` is already set — it must not
/// re-run the handshake.
#[test]
fn init_is_a_noop_once_already_complete() {
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
mt.init_complete = true;
let mut t =
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
assert!(mt.init(&mut t).is_ok());
assert_eq!(t.calls(), 0, "already-complete init must issue no CDBs");
}
/// `probe_disc()` before `init()` succeeded is a deliberate no-op (not a
/// retry of init): it must return `Ok(())` without touching the bus.
#[test]
fn probe_disc_is_a_noop_before_init_completes() {
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
let mut t =
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
assert!(mt.probe_disc(&mut t).is_ok());
assert_eq!(t.calls(), 0);
}
/// `probe_disc()` is a no-op once already probed.
#[test]
fn probe_disc_is_a_noop_once_already_probed() {
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
mt.init_complete = true;
mt.probed = true;
let mut t =
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
assert!(mt.probe_disc(&mut t).is_ok());
assert_eq!(t.calls(), 0);
}
/// `is_ready()` mirrors `init_complete`.
#[test]
fn is_ready_mirrors_init_complete() {
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
assert!(!mt.is_ready());
mt.init_complete = true;
assert!(mt.is_ready());
}
#[test]
fn do_unlock_rejects_inactive_mode_marker() {
// Signature matches but the primary marker at [12..16] is