Compare commits

...
4 Commits
12 changed files with 5098 additions and 5101 deletions
+7 -1
View File
@@ -1,6 +1,12 @@
# Changelog
## [1.8.0] — Unreleased
## [1.8.1] — 2026-10-07
### Changed
- Unit tests live in `*_tests.rs` side files. No behaviour change.
## [1.8.0] — 2026-10-05
### Fixed
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-keysources"
version = "1.7.7"
version = "1.8.1"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -17,7 +17,7 @@ publish = false
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
# libfreemkv is git-only now; the committed [patch.crates-io] below redirects this
# bare version req to the libfreemkv git tag (local dev overrides it to a path).
libfreemkv = "1.7"
libfreemkv = "1.8"
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "3", features = ["json"] }
serde_json = "1"
@@ -37,7 +37,7 @@ test-hooks = []
[dev-dependencies]
# `test_util` fixtures (`decrypt_unit`, …) for this crate's tests (keys-upfront
# KU-P1). CI resolves it through the sibling path patch.
libfreemkv = { version = "1.7", features = ["test-util"] }
libfreemkv = { version = "1.8", features = ["test-util"] }
[profile.release]
lto = "thin"
@@ -49,8 +49,8 @@ codegen-units = 1
# overrides it with a path patch via the gitignored .cargo/config.toml (a
# config-level [patch.crates-io] wins over this manifest one for the same crate).
[patch.crates-io]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.7.7" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.7" }
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.8.1" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.1" }
[lints.clippy]
uninlined_format_args = "allow"
+2 -1313
View File
File diff suppressed because it is too large Load Diff
+2 -1621
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1311
View File
File diff suppressed because it is too large Load Diff
+2 -177
View File
@@ -170,180 +170,5 @@ impl KeySource for MultiSource {
}
#[cfg(test)]
mod tests {
use super::*;
use libfreemkv::aacs::types::HostCert;
// A minimal KeySource that only supplies host certs — the sole MultiSource
// behaviour under test. It never resolves a unit key.
struct CertOnly {
certs: Vec<HostCert>,
}
impl KeySource for CertOnly {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> {
self.certs.clone()
}
}
// A host cert tagged with a distinct marker byte so a returned set can be
// identified exactly. Synthetic bytes only; no real key material.
fn cert(marker: u8) -> HostCert {
HostCert {
private_key: [marker; 20],
certificate: vec![marker; 92],
private_key_v2: None,
certificate_v2: None,
}
}
fn boxed(certs: Vec<HostCert>) -> Box<dyn KeySource> {
Box::new(CertOnly { certs }) as Box<dyn KeySource>
}
/// `MultiSource::host_certs` must UNION every inner source's certs. Without
/// this a composed source hides an inner source's cert from the OEM
/// cert-auth route — the gap the union closes.
#[test]
fn multi_source_host_certs_unions_every_inner_source() {
let multi = MultiSource::new(vec![
boxed(vec![cert(0x01)]),
boxed(vec![cert(0x02), cert(0x03)]),
]);
let mut markers: Vec<u8> = multi
.host_certs(Some(50))
.iter()
.map(|c| c.certificate[0])
.collect();
markers.sort_unstable();
assert_eq!(
markers,
vec![0x01, 0x02, 0x03],
"certs from every inner source must be unioned"
);
}
/// With no inner source holding a cert, the union is empty (not an error).
#[test]
fn multi_source_host_certs_empty_when_no_source_has_one() {
let multi = MultiSource::new(vec![boxed(vec![]), boxed(vec![])]);
assert!(multi.host_certs(None).is_empty());
}
// A source whose `answer_depends_on_samples` is fixed to the given value —
// the only behaviour KSK2 exercises.
struct FixedDependency(bool);
impl KeySource for FixedDependency {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn answer_depends_on_samples(&self) -> bool {
self.0
}
}
// KSK2: `MultiSource::answer_depends_on_samples` is `any(inner)`, so nesting
// a sample-dependent online source under a sample-independent keydb still
// gets the per-piece ask (`resolve` must not treat the pair as keydb-only).
#[test]
fn multi_source_answer_depends_on_samples_is_any_inner() {
let all_independent = MultiSource::new(vec![
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
]);
assert!(!all_independent.answer_depends_on_samples());
let keydb_then_online = MultiSource::new(vec![
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
Box::new(FixedDependency(true)) as Box<dyn KeySource>,
]);
assert!(keydb_then_online.answer_depends_on_samples());
}
// A source whose `uses_vid` is fixed to the given value.
struct FixedVid(bool);
impl KeySource for FixedVid {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn uses_vid(&self) -> bool {
self.0
}
}
// KU J23: like `answer_depends_on_samples`, a composed source uses the VID if any
// inner one does, so a nested online source still makes a Missing piece VID-derivable.
#[test]
fn multi_source_uses_vid_is_any_inner() {
let neither = MultiSource::new(vec![
Box::new(FixedVid(false)) as Box<dyn KeySource>,
Box::new(FixedVid(false)) as Box<dyn KeySource>,
]);
assert!(!neither.uses_vid());
let keydb_then_online = MultiSource::new(vec![
Box::new(FixedVid(false)) as Box<dyn KeySource>,
Box::new(FixedVid(true)) as Box<dyn KeySource>,
]);
assert!(keydb_then_online.uses_vid());
}
// A source whose `last_failure_was_transport` is fixed to the given value.
struct FixedTransport(bool);
impl KeySource for FixedTransport {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn last_failure_was_transport(&self) -> bool {
self.0
}
}
// For consistency with `answer_depends_on_samples` (KU3-6): a composed
// source's last-failure verdict is `any(inner)` too, so `resolve` retries
// the composition whenever ANY inner source's last failure was transport.
#[test]
fn multi_source_last_failure_was_transport_is_any_inner() {
let none_transport = MultiSource::new(vec![
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
]);
assert!(!none_transport.last_failure_was_transport());
let one_transport = MultiSource::new(vec![
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
Box::new(FixedTransport(true)) as Box<dyn KeySource>,
]);
assert!(one_transport.last_failure_was_transport());
}
/// The `mkb` generation must reach each inner source so its OWN revocation
/// filter can act; a source that drops everything at a given generation is
/// honoured by the union.
#[test]
fn multi_source_host_certs_passes_mkb_through_to_inner_sources() {
struct GenAware;
impl KeySource for GenAware {
fn get_unit_keys(
&self,
_c: &dyn ResolveCtx,
) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
match mkb {
// Modelled on a cert revoked in MKBv72.
Some(g) if g >= 72 => Vec::new(),
_ => vec![cert(0xaa)],
}
}
}
let multi = MultiSource::new(vec![Box::new(GenAware) as Box<dyn KeySource>]);
assert_eq!(multi.host_certs(Some(50)).len(), 1, "usable below gen 72");
assert!(
multi.host_certs(Some(72)).is_empty(),
"the gen must reach the inner source's filter"
);
}
}
#[path = "lib_tests.rs"]
mod tests;
+172
View File
@@ -0,0 +1,172 @@
use super::*;
use libfreemkv::aacs::types::HostCert;
// A minimal KeySource that only supplies host certs — the sole MultiSource
// behaviour under test. It never resolves a unit key.
struct CertOnly {
certs: Vec<HostCert>,
}
impl KeySource for CertOnly {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> {
self.certs.clone()
}
}
// A host cert tagged with a distinct marker byte so a returned set can be
// identified exactly. Synthetic bytes only; no real key material.
fn cert(marker: u8) -> HostCert {
HostCert {
private_key: [marker; 20],
certificate: vec![marker; 92],
private_key_v2: None,
certificate_v2: None,
}
}
fn boxed(certs: Vec<HostCert>) -> Box<dyn KeySource> {
Box::new(CertOnly { certs }) as Box<dyn KeySource>
}
/// `MultiSource::host_certs` must UNION every inner source's certs. Without
/// this a composed source hides an inner source's cert from the OEM
/// cert-auth route — the gap the union closes.
#[test]
fn multi_source_host_certs_unions_every_inner_source() {
let multi = MultiSource::new(vec![
boxed(vec![cert(0x01)]),
boxed(vec![cert(0x02), cert(0x03)]),
]);
let mut markers: Vec<u8> = multi
.host_certs(Some(50))
.iter()
.map(|c| c.certificate[0])
.collect();
markers.sort_unstable();
assert_eq!(
markers,
vec![0x01, 0x02, 0x03],
"certs from every inner source must be unioned"
);
}
/// With no inner source holding a cert, the union is empty (not an error).
#[test]
fn multi_source_host_certs_empty_when_no_source_has_one() {
let multi = MultiSource::new(vec![boxed(vec![]), boxed(vec![])]);
assert!(multi.host_certs(None).is_empty());
}
// A source whose `answer_depends_on_samples` is fixed to the given value —
// the only behaviour KSK2 exercises.
struct FixedDependency(bool);
impl KeySource for FixedDependency {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn answer_depends_on_samples(&self) -> bool {
self.0
}
}
// KSK2: `MultiSource::answer_depends_on_samples` is `any(inner)`, so nesting
// a sample-dependent online source under a sample-independent keydb still
// gets the per-piece ask (`resolve` must not treat the pair as keydb-only).
#[test]
fn multi_source_answer_depends_on_samples_is_any_inner() {
let all_independent = MultiSource::new(vec![
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
]);
assert!(!all_independent.answer_depends_on_samples());
let keydb_then_online = MultiSource::new(vec![
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
Box::new(FixedDependency(true)) as Box<dyn KeySource>,
]);
assert!(keydb_then_online.answer_depends_on_samples());
}
// A source whose `uses_vid` is fixed to the given value.
struct FixedVid(bool);
impl KeySource for FixedVid {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn uses_vid(&self) -> bool {
self.0
}
}
// KU J23: like `answer_depends_on_samples`, a composed source uses the VID if any
// inner one does, so a nested online source still makes a Missing piece VID-derivable.
#[test]
fn multi_source_uses_vid_is_any_inner() {
let neither = MultiSource::new(vec![
Box::new(FixedVid(false)) as Box<dyn KeySource>,
Box::new(FixedVid(false)) as Box<dyn KeySource>,
]);
assert!(!neither.uses_vid());
let keydb_then_online = MultiSource::new(vec![
Box::new(FixedVid(false)) as Box<dyn KeySource>,
Box::new(FixedVid(true)) as Box<dyn KeySource>,
]);
assert!(keydb_then_online.uses_vid());
}
// A source whose `last_failure_was_transport` is fixed to the given value.
struct FixedTransport(bool);
impl KeySource for FixedTransport {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn last_failure_was_transport(&self) -> bool {
self.0
}
}
// For consistency with `answer_depends_on_samples` (KU3-6): a composed
// source's last-failure verdict is `any(inner)` too, so `resolve` retries
// the composition whenever ANY inner source's last failure was transport.
#[test]
fn multi_source_last_failure_was_transport_is_any_inner() {
let none_transport = MultiSource::new(vec![
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
]);
assert!(!none_transport.last_failure_was_transport());
let one_transport = MultiSource::new(vec![
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
Box::new(FixedTransport(true)) as Box<dyn KeySource>,
]);
assert!(one_transport.last_failure_was_transport());
}
/// The `mkb` generation must reach each inner source so its OWN revocation
/// filter can act; a source that drops everything at a given generation is
/// honoured by the union.
#[test]
fn multi_source_host_certs_passes_mkb_through_to_inner_sources() {
struct GenAware;
impl KeySource for GenAware {
fn get_unit_keys(&self, _c: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
match mkb {
// Modelled on a cert revoked in MKBv72.
Some(g) if g >= 72 => Vec::new(),
_ => vec![cert(0xaa)],
}
}
}
let multi = MultiSource::new(vec![Box::new(GenAware) as Box<dyn KeySource>]);
assert_eq!(multi.host_certs(Some(50)).len(), 1, "usable below gen 72");
assert!(
multi.host_certs(Some(72)).is_empty(),
"the gen must reach the inner source's filter"
);
}
+2 -1942
View File
File diff suppressed because it is too large Load Diff
+1936
View File
File diff suppressed because it is too large Load Diff
+2 -42
View File
@@ -46,45 +46,5 @@ pub fn default_keydb_path() -> Option<PathBuf> {
}
#[cfg(test)]
mod tests {
use super::*;
/// The expected exe-local keydb path, computed the same way the code does.
/// Under `cargo test`, `current_exe()` is the test binary under `target/…`.
fn expected_local() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
}
#[test]
fn search_paths_is_exactly_exe_local_keydb() {
let paths = keydb_search_paths();
match expected_local() {
Some(expected) => {
assert_eq!(
paths,
vec![expected],
"search list must be exactly [<exe dir>/keydb.cfg]"
);
}
None => {
// No exe dir available → empty, no OS fallback (local only).
assert!(
paths.is_empty(),
"no exe dir means an empty search list, never an OS fallback"
);
}
}
}
#[test]
fn default_path_matches_search_head() {
// The write default is the single search entry, or None if unavailable.
assert_eq!(default_keydb_path(), expected_local());
assert_eq!(
default_keydb_path(),
keydb_search_paths().into_iter().next()
);
}
}
#[path = "paths_tests.rs"]
mod tests;
+40
View File
@@ -0,0 +1,40 @@
use super::*;
/// The expected exe-local keydb path, computed the same way the code does.
/// Under `cargo test`, `current_exe()` is the test binary under `target/…`.
fn expected_local() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
}
#[test]
fn search_paths_is_exactly_exe_local_keydb() {
let paths = keydb_search_paths();
match expected_local() {
Some(expected) => {
assert_eq!(
paths,
vec![expected],
"search list must be exactly [<exe dir>/keydb.cfg]"
);
}
None => {
// No exe dir available → empty, no OS fallback (local only).
assert!(
paths.is_empty(),
"no exe dir means an empty search list, never an OS fallback"
);
}
}
}
#[test]
fn default_path_matches_search_head() {
// The write default is the single search entry, or None if unavailable.
assert_eq!(default_keydb_path(), expected_local());
assert_eq!(
default_keydb_path(),
keydb_search_paths().into_iter().next()
);
}