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:
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user