mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
test: raise coverage across keydb, keydb_format, online, lib sources
Adds targeted unit tests for previously-uncovered branches: keydb load failure classification (invalid-UTF-8 / unreadable path), zip extraction success/no-cfg-member paths, write_atomic create/rename failure modes, keydb_format hex-digit-parsing edge cases, iter_disc_entries, to_keydb_cfg HC2 emission and empty-title/comment-field combinations, MultiSource::label, and the online key-service client's SSRF-guard malformed-authority and unresolvable-host paths, UK-as-string and VUK-derivation reply handling, and get_fmts_indexes. Local line coverage moves from ~90.3% to ~92.1%.
This commit is contained in:
+148
@@ -809,6 +809,17 @@ mod tests {
|
||||
|
||||
const HASH: &str = "0xaabb";
|
||||
|
||||
/// `MockCtx` implements the full `ResolveCtx` trait so it can stand in for
|
||||
/// a real disc, but `KeydbSource` itself never calls `title()`/`samples()`
|
||||
/// (those matter to the online source, not the local keydb). Exercise
|
||||
/// them directly so the mock's trait surface is proven correct too.
|
||||
#[test]
|
||||
fn mock_ctx_title_and_samples_are_stubbed_correctly() {
|
||||
let c = ctx(HASH, Vec::new(), None);
|
||||
assert_eq!(c.title(), None);
|
||||
assert_eq!(c.samples(4).unwrap(), Vec::<Vec<u8>>::new());
|
||||
}
|
||||
|
||||
// ── KAT (a): disc with terminal Unit Keys, no enc_title_keys ──────────────
|
||||
/// Stored terminal unit keys are returned with their CPS numbering preserved.
|
||||
/// Here `enc_title_keys` is empty, so the VUK can't derive anything — only the
|
||||
@@ -1637,6 +1648,52 @@ mod tests {
|
||||
assert!(matches!(src.save(garbage), Err(Error::KeydbInvalid)));
|
||||
}
|
||||
|
||||
/// A keydb.cfg that is over the size cap or not valid UTF-8
|
||||
/// (`std::io::ErrorKind::InvalidData`) must surface as a SOURCE FAILURE
|
||||
/// (`Error::KeydbInvalid`), never as a silent empty result — a corrupt
|
||||
/// keydb is not the same thing as "this disc has no key".
|
||||
#[test]
|
||||
fn get_unit_keys_reports_invalid_utf8_keydb_as_a_failure() {
|
||||
let dir = scratch("load-failure-invalid");
|
||||
let path = dir.join("keydb.cfg");
|
||||
std::fs::write(&path, [0xffu8, 0xfe, 0x00, 0x01]).unwrap();
|
||||
let src = KeydbSource::new(&path);
|
||||
let err = src
|
||||
.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.expect_err("non-UTF-8 keydb must be a failure, not an empty miss");
|
||||
assert!(matches!(err, Error::KeydbInvalid));
|
||||
}
|
||||
|
||||
/// Any OTHER read failure (not "missing", not "invalid data") — e.g. the
|
||||
/// configured path is a directory, not a file — is also a source failure,
|
||||
/// distinct from `KeydbInvalid`.
|
||||
#[test]
|
||||
fn get_unit_keys_reports_unreadable_keydb_path_as_a_failure() {
|
||||
let dir = scratch("load-failure-unreadable");
|
||||
// Point the source AT a directory: `File::open` fails with an io::Error
|
||||
// whose kind is neither NotFound nor InvalidData.
|
||||
let src = KeydbSource::new(&dir);
|
||||
let err = src
|
||||
.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.expect_err("an unreadable keydb path must be a failure, not an empty miss");
|
||||
assert!(matches!(err, Error::KeydbLoad { .. }));
|
||||
}
|
||||
|
||||
/// `host_certs` (the `Vec`-returning trait method) hits the exact same
|
||||
/// `load_failure` branches, just via a swallowed log instead of `Err` —
|
||||
/// prove it returns empty rather than panicking on a corrupt keydb.
|
||||
#[test]
|
||||
fn host_certs_swallows_a_corrupt_keydb_into_an_empty_list() {
|
||||
let dir = scratch("load-failure-hostcerts");
|
||||
let path = dir.join("keydb.cfg");
|
||||
std::fs::write(&path, [0xffu8, 0xfe]).unwrap();
|
||||
let src = KeydbSource::new(&path);
|
||||
assert!(
|
||||
src.host_certs().is_empty(),
|
||||
"a corrupt keydb must yield no host certs, not panic"
|
||||
);
|
||||
}
|
||||
|
||||
/// `save` recognises gzip magic (0x1f 0x8b) and routes to the gz decoder; a
|
||||
/// truncated gzip is a parse/invalid error, never a plain-text UTF-8 error.
|
||||
#[test]
|
||||
@@ -1663,6 +1720,97 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `extract_zip` on a WELL-FORMED zip that carries no `*.cfg`/`*.CFG`
|
||||
/// member must fall through the whole member loop and return
|
||||
/// `Error::KeydbInvalid` — never a parse error, and never silently pick a
|
||||
/// non-keydb member.
|
||||
#[test]
|
||||
fn save_rejects_a_valid_zip_with_no_cfg_member() {
|
||||
use std::io::Write as _;
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
|
||||
let opts: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default();
|
||||
writer.start_file("readme.txt", opts).unwrap();
|
||||
writer.write_all(b"not a keydb").unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
assert!(buf.starts_with(b"PK\x03\x04"), "must be a real zip");
|
||||
|
||||
let dir = scratch("save-zip-no-cfg");
|
||||
let src = KeydbSource::new(dir.join("k.cfg"));
|
||||
assert!(matches!(src.save(&buf), Err(Error::KeydbInvalid)));
|
||||
}
|
||||
|
||||
/// `save` on a WELL-FORMED zip that DOES carry a `*.cfg` member routes
|
||||
/// through the success arm of `extract_zip` — proven distinct from the
|
||||
/// "no member found" and "truncated/corrupt zip" cases above.
|
||||
#[test]
|
||||
fn save_extracts_the_cfg_member_from_a_valid_zip() {
|
||||
use std::io::Write as _;
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
|
||||
let opts: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default();
|
||||
writer.start_file("ignored.txt", opts).unwrap();
|
||||
writer.write_all(b"not a keydb").unwrap();
|
||||
writer.start_file("KEYDB.cfg", opts).unwrap();
|
||||
writer
|
||||
.write_all(b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD = Test\n")
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
let dir = scratch("save-zip-with-cfg");
|
||||
let src = KeydbSource::new(dir.join("k.cfg"));
|
||||
let result = src
|
||||
.save(&buf)
|
||||
.expect("a zip carrying a .cfg member must extract");
|
||||
assert_eq!(result.entries, 1);
|
||||
}
|
||||
|
||||
/// `write_atomic` when the temp file cannot be CREATED (permission
|
||||
/// denied on an existing, unwritable parent directory) must clean up and
|
||||
/// surface `Error::KeydbWrite`, distinct from the create_dir_all failure
|
||||
/// already pinned by `write_atomic_failure_preserves_prior_keydb`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_atomic_failure_when_temp_file_cannot_be_created() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = scratch("write-atomic-unwritable");
|
||||
let target = dir.join("keydb.cfg");
|
||||
// Parent dir exists (so create_dir_all is a no-op) but is read-only,
|
||||
// so `File::create(&tmp)` fails with permission denied.
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let result = write_atomic(&target, "0xAAAA = x\n");
|
||||
// Restore write permission so the scratch dir can be cleaned up by a
|
||||
// later test run regardless of outcome here.
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(matches!(result, Err(Error::KeydbWrite { .. })));
|
||||
}
|
||||
|
||||
/// `write_atomic` when the RENAME step fails (the temp file wrote fine,
|
||||
/// but the destination is an existing directory, so `rename` refuses)
|
||||
/// must surface `Error::KeydbWrite` and remove the orphaned temp file.
|
||||
#[test]
|
||||
fn write_atomic_failure_when_rename_target_is_a_directory() {
|
||||
let dir = scratch("write-atomic-rename-fail");
|
||||
// The "keydb path" is itself an existing directory — the temp file
|
||||
// writes fine (same parent), but renaming a file onto a directory
|
||||
// always fails.
|
||||
let target = dir.join("keydb.cfg");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
let result = write_atomic(&target, "0xAAAA = x\n");
|
||||
assert!(matches!(result, Err(Error::KeydbWrite { .. })));
|
||||
// No stray temp file left behind.
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.contains(".tmp."))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
|
||||
}
|
||||
|
||||
/// `read_capped_to_string` rejects input over the cap (decompression-bomb
|
||||
/// guard) and accepts exactly at the cap.
|
||||
#[test]
|
||||
|
||||
@@ -1922,6 +1922,15 @@ mod tests {
|
||||
assert_eq!(pk, [0xABu8; 16]);
|
||||
}
|
||||
|
||||
/// A `| PK |` row with fewer than 3 `|`-delimited parts (no key field at
|
||||
/// all) has nothing to parse and must return `None`, not panic on an
|
||||
/// out-of-range index.
|
||||
#[test]
|
||||
fn parse_processing_key_rejects_row_with_too_few_parts() {
|
||||
assert!(KeyDb::parse_processing_key("| PK |").is_none());
|
||||
assert!(KeyDb::parse_processing_key("just text, no pipes at all").is_none());
|
||||
}
|
||||
|
||||
// ── Disc-entry comment metadata: MKBv / VolumeSize / UHD ────────────────
|
||||
|
||||
#[test]
|
||||
@@ -2084,6 +2093,106 @@ mod tests {
|
||||
assert_eq!(uks[0].1, vec![(1, [0x03u8; 16])]);
|
||||
}
|
||||
|
||||
/// `parse_digits_after` must return `None`, not panic or misparse, when
|
||||
/// the marker is present but no digit immediately follows it (Path 3 of
|
||||
/// the MKBv/VolumeSize comment grammar: marker-with-no-value).
|
||||
#[test]
|
||||
fn parse_digits_after_marker_with_no_trailing_digits_is_none() {
|
||||
assert_eq!(parse_digits_after::<u32>("MKBv", "MKBv"), None);
|
||||
assert_eq!(parse_digits_after::<u32>("MKBv/BEE", "MKBv"), None);
|
||||
assert_eq!(parse_digits_after::<u32>("no marker here", "MKBv"), None);
|
||||
}
|
||||
|
||||
/// `iter_disc_entries` yields every parsed disc, independent of hash
|
||||
/// lookup — Path 3's full-scan use.
|
||||
#[test]
|
||||
fn iter_disc_entries_yields_every_disc() {
|
||||
let k = "01".repeat(16);
|
||||
let db = KeyDb::parse(&format!(
|
||||
"0xAAAA = A | U | 1-0x{k}\n0xBBBB = B | U | 1-0x{k}\n"
|
||||
));
|
||||
let mut hashes: Vec<&str> = db.iter_disc_entries().map(|e| &*e.disc_hash).collect();
|
||||
hashes.sort();
|
||||
assert_eq!(hashes, vec!["0xaaaa", "0xbbbb"]);
|
||||
}
|
||||
|
||||
/// `to_keydb_cfg` emits the sibling `| HC2 |` line for a host cert that
|
||||
/// carries AACS 2.0 credentials, and a `parse` of the output recovers
|
||||
/// them. Also: an empty title serializes as the literal `Unknown` (the
|
||||
/// inverse of the parser's own convention).
|
||||
#[test]
|
||||
fn to_keydb_cfg_emits_hc2_and_unknown_for_empty_title() {
|
||||
let h = |b: u8, n: usize| format!("{b:02x}").repeat(n);
|
||||
let k = h(0x09, 16);
|
||||
let src = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{priv1} | HOST_CERT 0x{cert1}\n\
|
||||
| HC2 | HOST_PRIV_KEY 0x{priv2} | HOST_CERT 0x{cert2}\n\
|
||||
0xAAAA = | U | 1-0x{k}\n",
|
||||
priv1 = h(0x01, 20),
|
||||
cert1 = h(0x02, 92),
|
||||
priv2 = h(0x03, 32),
|
||||
cert2 = h(0x04, 132),
|
||||
);
|
||||
let db = KeyDb::parse(&src);
|
||||
assert_eq!(db.host_certs.len(), 1);
|
||||
assert!(db.host_certs[0].cert.certificate_v2.is_some());
|
||||
// Empty title round-trips through "Unknown".
|
||||
let entry = db.disc_entries.values().next().unwrap();
|
||||
assert_eq!(entry.title, "");
|
||||
|
||||
let out = db.to_keydb_cfg();
|
||||
assert!(
|
||||
out.contains("| HC2 | HOST_PRIV_KEY"),
|
||||
"HC2 must be emitted: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("Unknown"),
|
||||
"empty title must serialize as Unknown: {out}"
|
||||
);
|
||||
|
||||
// And it round-trips: re-parsing sees the HC2 material again.
|
||||
let db2 = KeyDb::parse(&out);
|
||||
assert_eq!(db2.host_certs.len(), 1);
|
||||
assert!(db2.host_certs[0].cert.certificate_v2.is_some());
|
||||
assert_eq!(
|
||||
db2.host_certs[0].cert.private_key_v2,
|
||||
db.host_certs[0].cert.private_key_v2
|
||||
);
|
||||
}
|
||||
|
||||
/// `to_keydb_cfg`'s comment clause is written piecewise (MKBv, then
|
||||
/// VolumeSize, then UHD) — exercise each optional sub-field independently
|
||||
/// (not just all-three-true) so every combination of the trailing
|
||||
/// `; comment` assembly is proven, not just the "everything present" case.
|
||||
#[test]
|
||||
fn to_keydb_cfg_comment_fields_are_independent() {
|
||||
let k = "0a".repeat(16);
|
||||
|
||||
// Only VolumeSize, no MKBv, not UHD.
|
||||
let a = KeyDb::parse(&format!("0xAAAA = A | U | 1-0x{k} ; VolumeSize: 12345\n"));
|
||||
let out_a = a.to_keydb_cfg();
|
||||
assert!(out_a.contains("VolumeSize: 12345"), "{out_a}");
|
||||
assert!(!out_a.contains("MKBv"), "{out_a}");
|
||||
assert!(!out_a.contains("(UHD)"), "{out_a}");
|
||||
let a2 = KeyDb::parse(&out_a);
|
||||
let ea2 = a2.disc_entries.values().next().unwrap();
|
||||
assert_eq!(ea2.volume_size, Some(12345));
|
||||
assert_eq!(ea2.mkb_version, None);
|
||||
assert!(!ea2.is_uhd);
|
||||
|
||||
// Only the UHD flag, no MKBv, no VolumeSize.
|
||||
let b = KeyDb::parse(&format!("0xBBBB = B | U | 1-0x{k} ; (UHD)\n"));
|
||||
let out_b = b.to_keydb_cfg();
|
||||
assert!(out_b.contains("(UHD)"), "{out_b}");
|
||||
assert!(!out_b.contains("MKBv"), "{out_b}");
|
||||
assert!(!out_b.contains("VolumeSize"), "{out_b}");
|
||||
let b2 = KeyDb::parse(&out_b);
|
||||
let eb2 = b2.disc_entries.values().next().unwrap();
|
||||
assert!(eb2.is_uhd);
|
||||
assert_eq!(eb2.mkb_version, None);
|
||||
assert_eq!(eb2.volume_size, None);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// KEYDB-parser integration tests relocated from libfreemkv.
|
||||
//
|
||||
|
||||
+257
@@ -907,6 +907,52 @@ mod tests {
|
||||
assert!(resolve_and_guard("").is_err());
|
||||
}
|
||||
|
||||
/// Malformed-authority edges that `resolve_and_guard` must reject BEFORE
|
||||
/// ever touching DNS: an empty host after the scheme (`https://`), an
|
||||
/// unterminated IPv6 literal (missing `]`), and a bare `host:` with an
|
||||
/// empty host part.
|
||||
#[test]
|
||||
fn resolve_and_guard_rejects_malformed_authorities() {
|
||||
// Scheme with nothing after it at all.
|
||||
assert!(resolve_and_guard("https://").is_err());
|
||||
// Scheme immediately followed by a path — empty authority.
|
||||
assert!(resolve_and_guard("https:///keys").is_err());
|
||||
// Bracketed IPv6 host missing its closing `]`.
|
||||
assert!(resolve_and_guard("http://[::1/keys").is_err());
|
||||
// `host:port` split with an empty host before the colon.
|
||||
assert!(resolve_and_guard("https://:8080/keys").is_err());
|
||||
}
|
||||
|
||||
/// A host that genuinely does not resolve (RFC 6761 reserves `.test`
|
||||
/// to never resolve in the public DNS) must be reported as
|
||||
/// `GuardFail::Unreachable` — "the service is down", not
|
||||
/// `GuardFail::Config` ("fix the URL"). No real network traffic beyond a
|
||||
/// local DNS lookup that is guaranteed to fail.
|
||||
#[test]
|
||||
fn resolve_and_guard_reports_unreachable_for_a_host_that_never_resolves() {
|
||||
let (kind, msg) = resolve_and_guard("https://this-host-does-not-exist.test/keys")
|
||||
.expect_err(
|
||||
"a .test host must never resolve — if this passes, treat it as a fixture bug",
|
||||
);
|
||||
assert_eq!(kind, GuardFail::Unreachable);
|
||||
assert!(msg.contains("resolve"), "message should explain: {msg}");
|
||||
}
|
||||
|
||||
/// `PinnedResolver` with an EMPTY address set — the state `agent_for`
|
||||
/// would build if `resolve_and_guard` somehow returned no addresses —
|
||||
/// must fail the resolve step with `HostNotFound` rather than silently
|
||||
/// falling back to live DNS.
|
||||
#[test]
|
||||
fn pinned_resolver_with_no_addresses_fails_the_connection() {
|
||||
let result = hardened_agent(Vec::new())
|
||||
.post("http://keyserver.test/keys")
|
||||
.send("{}");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"an empty pin must fail the connection, not silently resolve some other way"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_and_guard_accepts_public_literal() {
|
||||
// Public numeric hosts resolve without DNS — must be accepted.
|
||||
@@ -1199,6 +1245,110 @@ mod tests {
|
||||
assert_eq!(keys[1].key[0], 0x0f);
|
||||
}
|
||||
|
||||
/// Backward-compatible form: `"UK"` as a bare hex STRING (not an array)
|
||||
/// is still the single base Unit Key at index 0.
|
||||
#[test]
|
||||
fn uk_as_a_bare_string_is_still_accepted() {
|
||||
let keys = interpret_reply(
|
||||
Ok(reply(200, r#"{"UK":"000102030405060708090a0b0c0d0e0f"}"#)),
|
||||
&BareCtx,
|
||||
1,
|
||||
)
|
||||
.expect("a string UK must still resolve");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[0].key[0], 0x00);
|
||||
}
|
||||
|
||||
/// A malformed `"UK"` STRING (not the array form) parses to nothing and
|
||||
/// falls through to the genuine-miss path — proven distinct from the
|
||||
/// array form's "reject the whole reply" behaviour, since a bad scalar
|
||||
/// string has no partial set to protect.
|
||||
#[test]
|
||||
fn uk_as_an_unparseable_string_falls_through_to_a_miss() {
|
||||
let keys = interpret_reply(Ok(reply(200, r#"{"UK":"not hex"}"#)), &BareCtx, 1)
|
||||
.expect("an unparseable scalar UK is a miss, not a transport failure");
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
/// A `"VUK"` reply is derived LOCALLY into terminal unit keys via the
|
||||
/// disc's encrypted title keys — the service never sees or returns them
|
||||
/// directly.
|
||||
#[test]
|
||||
fn vuk_reply_is_derived_locally_into_unit_keys() {
|
||||
struct EncCtx;
|
||||
impl ResolveCtx for EncCtx {
|
||||
fn disc_hash(&self) -> &str {
|
||||
"0x422EB"
|
||||
}
|
||||
fn title(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
|
||||
None
|
||||
}
|
||||
fn mkb(&self) -> Result<&[u8], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||
Ok(&[[0x11u8; 16], [0x22u8; 16]])
|
||||
}
|
||||
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
let vuk_hex = "0f0e0d0c0b0a09080706050403020100";
|
||||
let keys = interpret_reply(
|
||||
Ok(reply(200, &format!(r#"{{"VUK":"{vuk_hex}"}}"#))),
|
||||
&EncCtx,
|
||||
1,
|
||||
)
|
||||
.expect("a VUK reply must resolve");
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
2,
|
||||
"one derived unit key per encrypted title key"
|
||||
);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[1].idx, 1);
|
||||
}
|
||||
|
||||
/// The service answered correctly with a VUK, but the DISC's encrypted
|
||||
/// title keys could not be read — that is a disc-side condition, not a
|
||||
/// service failure, so it is `Ok(empty)`, never `Err`.
|
||||
#[test]
|
||||
fn vuk_reply_with_unreadable_enc_title_keys_is_an_empty_ok_not_an_error() {
|
||||
struct BrokenEncCtx;
|
||||
impl ResolveCtx for BrokenEncCtx {
|
||||
fn disc_hash(&self) -> &str {
|
||||
"0x422EB"
|
||||
}
|
||||
fn title(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
|
||||
None
|
||||
}
|
||||
fn mkb(&self) -> Result<&[u8], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||
Err(Error::KeydbInvalid)
|
||||
}
|
||||
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
let vuk_hex = "0f0e0d0c0b0a09080706050403020100";
|
||||
let keys = interpret_reply(
|
||||
Ok(reply(200, &format!(r#"{{"VUK":"{vuk_hex}"}}"#))),
|
||||
&BrokenEncCtx,
|
||||
1,
|
||||
)
|
||||
.expect("an unreadable disc-side input must not be a source failure");
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
/// A URL that fails the ADDRESS guard is operator configuration, not the
|
||||
/// service being down — the two must stay separable, since only one of them
|
||||
/// is worth retrying.
|
||||
@@ -1443,6 +1593,113 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `get_fmts_indexes` shares `query` with `get_unit_keys` — never
|
||||
/// separately exercised. Same guard-blocked, no-network path as the
|
||||
/// `get_unit_keys` tests above proves it is wired up.
|
||||
#[test]
|
||||
fn get_fmts_indexes_shares_the_same_query_path() {
|
||||
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
|
||||
let ctx = GuardCtx {
|
||||
mkb: Vec::new(),
|
||||
samples: MIN_SAMPLE_UNITS,
|
||||
};
|
||||
assert_eq!(
|
||||
src.get_fmts_indexes(&ctx)
|
||||
.expect_err("a blocked address means the service was never asked")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/// A host that genuinely does not resolve travels `query`'s OWN
|
||||
/// `GuardFail::Unreachable` arm (distinct from the `Config` arm covered
|
||||
/// above) — still `Err`, since "did not resolve" is what a key-service
|
||||
/// outage looks like from here, never a genuine miss.
|
||||
#[test]
|
||||
fn query_against_an_unresolvable_host_reports_unreachable_as_a_failure() {
|
||||
let src = OnlineSource::new("https://this-host-does-not-exist.test/keys", "s3cr3t");
|
||||
let ctx = GuardCtx {
|
||||
mkb: Vec::new(),
|
||||
samples: MIN_SAMPLE_UNITS,
|
||||
};
|
||||
assert_eq!(
|
||||
src.get_unit_keys(&ctx)
|
||||
.expect_err("an unresolvable host means the service was never asked")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/// The request body's `vid_b64` and `title` fields are assembled BEFORE
|
||||
/// the address guard runs (see `query`'s ordering), so a ctx that
|
||||
/// supplies a VID and a title still exercises that assembly even when
|
||||
/// the guard then rejects the address — proven through the same
|
||||
/// deterministic guard-blocked path the tests above use (no network).
|
||||
#[test]
|
||||
fn query_assembles_vid_and_title_before_the_address_guard_runs() {
|
||||
struct VidTitleCtx;
|
||||
impl ResolveCtx for VidTitleCtx {
|
||||
fn disc_hash(&self) -> &str {
|
||||
"0x422EB"
|
||||
}
|
||||
fn title(&self) -> Option<&str> {
|
||||
Some(" My Disc Title ")
|
||||
}
|
||||
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
|
||||
Some(libfreemkv::aacs::types::Vid([0x42u8; 16]))
|
||||
}
|
||||
fn mkb(&self) -> Result<&[u8], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||
Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS])
|
||||
}
|
||||
}
|
||||
// 127.0.0.1 needs no DNS and is unconditionally rejected — the guard
|
||||
// fires AFTER the body (incl. vid/title) is already built.
|
||||
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
|
||||
assert_eq!(
|
||||
src.get_unit_keys(&VidTitleCtx)
|
||||
.expect_err("a blocked address means the service was never asked")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/// A title that is present but ALL WHITESPACE must not be sent — `query`
|
||||
/// trims then checks `is_empty()` before adding the `title` field.
|
||||
#[test]
|
||||
fn query_skips_a_whitespace_only_title() {
|
||||
struct WhitespaceTitleCtx;
|
||||
impl ResolveCtx for WhitespaceTitleCtx {
|
||||
fn disc_hash(&self) -> &str {
|
||||
"0x422EB"
|
||||
}
|
||||
fn title(&self) -> Option<&str> {
|
||||
Some(" ")
|
||||
}
|
||||
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
|
||||
None
|
||||
}
|
||||
fn mkb(&self) -> Result<&[u8], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||
Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS])
|
||||
}
|
||||
}
|
||||
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
|
||||
// Reaches the same guard-blocked failure either way; this test's
|
||||
// value is in exercising the whitespace-title branch without panic.
|
||||
assert!(src.get_unit_keys(&WhitespaceTitleCtx).is_err());
|
||||
}
|
||||
|
||||
// ── One agent per address set, not one per query ────────────────────────
|
||||
|
||||
/// A round-robin keyserver returns the SAME addresses in a different order
|
||||
|
||||
@@ -270,6 +270,15 @@ fn uk(b: u8) -> UnitKey {
|
||||
UnitKey::new(0, [b; 16])
|
||||
}
|
||||
|
||||
/// `MultiSource` itself reports a fixed label, distinct from any inner
|
||||
/// source's own label — this is what a rip's source-attribution log shows
|
||||
/// when the composition, not one of its members, answered.
|
||||
#[test]
|
||||
fn multi_source_label_is_multi() {
|
||||
let multi = MultiSource::new(vec![Box::new(ScriptedSource::new("A", Vec::new()))]);
|
||||
assert_eq!(multi.label(), "multi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_first_non_empty_wins_in_caller_order() {
|
||||
// Caller supplies [A, B]; A is non-empty so A's keys win.
|
||||
|
||||
Reference in New Issue
Block a user