test: strengthen AACS resolve-reason + unlocker seam coverage (rc.6 WS3)

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.
This commit is contained in:
Matthew Jackson
2026-06-24 23:40:36 -07:00
parent f407c4c693
commit 0127c274d2
2 changed files with 219 additions and 0 deletions
+148
View File
@@ -3169,4 +3169,152 @@ mod tests {
// And the thin wrapper must still return just the MK. // And the thin wrapper must still return just the MK.
assert_eq!(derive_media_key_from_dk(&mkb, &dks), Some(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<DeviceKey>,
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"
);
}
} }
+71
View File
@@ -368,4 +368,75 @@ mod tests {
"no match → safe no-op, nothing invoked" "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)"
);
}
} }