From 0127c274d28ff9a6134a7f01517140f0f09350e7 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:40:36 -0700 Subject: [PATCH] test: strengthen AACS resolve-reason + unlocker seam coverage (rc.6 WS3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted tests for the rc.6 surfaces, strengthening (not duplicating) the regression tests the rc.6 commits already shipped. No production code changes. aacs/keys.rs — resolve_keys_with_reason / classify_resolve_failure: The E7021/E7022 split is already proven end-to-end through the ensure_decryptable gate (disc/mod.rs). These pin the classifier directly at the keys.rs seam for the branches the gate test does not reach: - processing-keys-only + zero VID -> VidUnavailable (the gate test only exercises the device-keys arm of has_derivation_material). - VID PRESENT + material -> NoMaterial: a non-zero VID must never be reported as VidUnavailable however much material is on hand (the has_vid short-circuit; the gate test only uses the zero-VID sentinel). - VID present + no material -> NoMaterial. - version dispatch: version 1 routes the V10 resolver (stamps V10), any other value routes the V20->V21 chain; a resolved disc returns Ok, never Err(ResolveFailure). unlock.rs — Unlocker seam introspection + ordering: - matching_name reports the first matching unlocker without running it, and is None for an unsupported drive; registered_count grows after a registration (monotonic check — the registry is process-wide and shared across the unlock tests, so no exact-delta assertion). - route_unlock first-registered-match-wins: two unlockers matching the same identity, the earlier-registered one runs and the later is never consulted. --- src/aacs/keys.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++++ src/unlock.rs | 71 +++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index 810971d..e11ccec 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -3169,4 +3169,152 @@ mod tests { // And the thin wrapper must still return just the MK. assert_eq!(derive_media_key_from_dk(&mkb, &dks), Some(mk)); } + + // ── resolve_keys_with_reason / classify_resolve_failure ──────────────── + // + // The rc.6 E7021/E7022 split is also exercised end-to-end through the + // `ensure_decryptable` gate in `disc/mod.rs`. These tests pin the + // *classifier* directly at the keys.rs seam and cover the branches the + // gate test does not: VID-present (must never be VidUnavailable), the + // processing-keys-only material path, and the version dispatch / Ok path. + + /// A `SuppliedKey` provider with the given derivation material and no + /// disc-keyed entry. Mirrors the construction the gate test uses, lifted to + /// a helper so each branch reads as one line. + fn material_provider( + device_keys: Vec, + processing_keys: Vec<[u8; 16]>, + ) -> super::super::provider::SuppliedKey { + super::super::provider::SuppliedKey { + device_keys, + processing_keys, + media_keys: Vec::new(), + disc_entry: None, + } + } + + fn one_device_key() -> DeviceKey { + DeviceKey { + key: [0x11; 16], + node: 1, + uv: 1, + u_mask_shift: 0, + } + } + + /// Zero VID + PROCESSING keys (not device keys) is still "derivation + /// material present, VID missing" → VidUnavailable (E7021). The gate test + /// only proves the device-keys arm of `has_derivation_material`; this pins + /// the processing-keys arm of the same `||`. + #[test] + fn classify_processing_keys_zero_vid_is_vid_unavailable() { + let prov = material_provider(Vec::new(), vec![[0u8; 16]]); + let providers: &[&dyn super::super::KeyProvider] = &[&prov]; + let uk_ro = minimal_unit_key_ro(); + let ctx = ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &[0u8; 16], + providers, + mkb: None, + }; + assert_eq!( + resolve_keys_with_reason(&ctx, 2).err(), + Some(ResolveFailure::VidUnavailable), + "processing keys + zero VID is still material-but-no-VID" + ); + } + + /// A NON-zero VID present, but resolution still fails (the providers carry + /// material that doesn't resolve this disc). The VID is available, so the + /// failure is NOT "VID unavailable" — it must classify NoMaterial regardless + /// of how much derivation material is present, because re-acquiring the VID + /// is not the fix. This is the `has_vid == true` short-circuit, which no + /// existing test covers (the gate test only uses the zero-VID sentinel). + #[test] + fn classify_vid_present_with_material_is_no_material_not_vid() { + let prov = material_provider(vec![one_device_key()], vec![[0u8; 16]]); + let providers: &[&dyn super::super::KeyProvider] = &[&prov]; + let uk_ro = minimal_unit_key_ro(); + let ctx = ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &[0x42u8; 16], // VID IS available + providers, + mkb: None, + }; + assert_eq!( + resolve_keys_with_reason(&ctx, 2).err(), + Some(ResolveFailure::NoMaterial), + "VID present must never be reported as VidUnavailable, however much \ + derivation material is on hand" + ); + } + + /// VID present + NO material → NoMaterial (both conditions for + /// VidUnavailable absent). Distinct from the gate's zero-VID/no-material + /// branch. + #[test] + fn classify_vid_present_no_material_is_no_material() { + let prov = material_provider(Vec::new(), Vec::new()); + let providers: &[&dyn super::super::KeyProvider] = &[&prov]; + let uk_ro = minimal_unit_key_ro(); + let ctx = ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &[0x42u8; 16], + providers, + mkb: None, + }; + assert_eq!( + resolve_keys_with_reason(&ctx, 2).err(), + Some(ResolveFailure::NoMaterial) + ); + } + + /// `resolve_keys_with_reason` routes `version_u8 == 1` through the V10 + /// resolver and any other value through the V20→V21 chain. Prove the + /// dispatch by resolving the SAME path-4 (disc-hash→VUK) fixture under both + /// versions: V10 stamps V10, the non-1 arm reaches V20/V21. A success must + /// come back as `Ok`, never an `Err(ResolveFailure)`. + #[test] + fn resolve_with_reason_dispatches_on_version_and_returns_ok() { + let uk_ro = build_unit_key_ro(1, 64); + let hash_hex = disc_hash_hex(&disc_hash(&uk_ro)).to_lowercase(); + let vuk = [0x77u8; 16]; + let mut keydb = KeyDb::empty(); + keydb.disc_entries.insert( + hash_hex.clone(), + DiscEntry { + disc_hash: hash_hex, + title: "f".to_string(), + media_key: None, + disc_id: None, + vuk: Some(vuk), + unit_keys: Vec::new(), + }, + ); + let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; + let ctx = ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &[0u8; 16], + providers, + mkb: None, + }; + + // version 1 → V10 resolver. Path 4 succeeds → Ok, version stamped V10. + let v1 = resolve_keys_with_reason(&ctx, 1).expect("v1 dispatch must resolve path 4"); + assert_eq!(v1.vuk, Some(vuk)); + assert_eq!(v1.version, AacsVersion::V10); + + // version 2 → V20→V21 chain. Same fixture resolves; not the V10 stamp. + let v2 = resolve_keys_with_reason(&ctx, 2).expect("non-1 dispatch must resolve path 4"); + assert_eq!(v2.vuk, Some(vuk)); + assert_ne!( + v2.version, + AacsVersion::V10, + "the non-1 arm must run the V20/V21 resolver, not V10" + ); + } } diff --git a/src/unlock.rs b/src/unlock.rs index 7d25e12..fcb659d 100644 --- a/src/unlock.rs +++ b/src/unlock.rs @@ -368,4 +368,75 @@ mod tests { "no match → safe no-op, nothing invoked" ); } + + /// `matching_name` reports the FIRST matching unlocker's name without + /// running it (drive-info "is this drive supported?" before any unlock), + /// and returns `None` for an unknown drive. `registered_count` counts the + /// registered unlockers — pinning the two introspection helpers the routing + /// tests never touch. + /// + /// The registry is process-wide and other unlock tests register into it + /// concurrently, so the count is only asserted to be MONOTONIC across this + /// test's own registration (never an exact delta) — registering an unlocker + /// can only grow the count, never shrink it. + #[test] + fn matching_name_and_registered_count_introspection() { + let before = registered_count(); + + register_unlocker(Box::new(FakeUnlocker::new( + "NAMEVNDR", + Arc::new(AtomicBool::new(false)), + ))); + + // Registering an unlocker can only grow the count (other tests may also + // be registering concurrently, so this is a monotonic check, not a + // delta-of-exactly-one). + assert!( + registered_count() > before, + "registered_count grows after register_unlocker" + ); + + // A matching identity reports the unlocker's name — and `matches` + // is consulted WITHOUT running unlock_drive (introspection only). + assert_eq!( + matching_name(&fake_id("NAMEVNDR")).as_deref(), + Some("fake"), + "matching_name reports the supporting unlocker" + ); + + // An identity no registered unlocker matches → None (unsupported). + assert!( + matching_name(&fake_id("ZZNOMTCH")).is_none(), + "matching_name is None for an unsupported drive" + ); + } + + /// Registration order is preserved and the FIRST matching unlocker wins: + /// when two unlockers both match the same identity, `route_unlock` runs the + /// one registered earlier and never consults the later one. The routing + /// docs promise "registration order; stops at the first whose `matches` is + /// true" — this is the only test that registers two overlapping matchers to + /// prove the ordering rather than a single-match no-op. + #[test] + fn route_unlock_first_registered_match_wins() { + let mut scsi = NoopTransport; + + // Two unlockers that BOTH match vendor "DUPEVNDR"; the first registered + // must be the one that runs. + let first_ran = Arc::new(AtomicBool::new(false)); + let second_ran = Arc::new(AtomicBool::new(false)); + register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", first_ran.clone()))); + register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", second_ran.clone()))); + + let matched = route_unlock(&mut scsi, &fake_id("DUPEVNDR")).unwrap(); + assert_eq!(matched.as_deref(), Some("fake"), "a match was routed"); + assert!( + first_ran.load(Ordering::SeqCst), + "the FIRST-registered matching unlocker ran" + ); + assert!( + !second_ran.load(Ordering::SeqCst), + "the later-registered unlocker was never consulted (first-match-wins)" + ); + } }