From e83b7a34890fa1a289469990ba2b22858bb6183e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:11:01 -0700 Subject: [PATCH] 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%. --- src/keydb.rs | 148 +++++++++++++++++++++++++ src/keydb_format.rs | 109 ++++++++++++++++++ src/online.rs | 257 +++++++++++++++++++++++++++++++++++++++++++ tests/key_sources.rs | 9 ++ 4 files changed, 523 insertions(+) diff --git a/src/keydb.rs b/src/keydb.rs index 0f55ae3..1f56bda 100644 --- a/src/keydb.rs +++ b/src/keydb.rs @@ -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::>::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] diff --git a/src/keydb_format.rs b/src/keydb_format.rs index 359ed03..f35af11 100644 --- a/src/keydb_format.rs +++ b/src/keydb_format.rs @@ -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::("MKBv", "MKBv"), None); + assert_eq!(parse_digits_after::("MKBv/BEE", "MKBv"), None); + assert_eq!(parse_digits_after::("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. // diff --git a/src/online.rs b/src/online.rs index 15287c5..b2f8335 100644 --- a/src/online.rs +++ b/src/online.rs @@ -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 { + 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>, 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 { + 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>, 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 { + 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>, 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 { + None + } + fn mkb(&self) -> Result<&[u8], Error> { + Ok(&[]) + } + fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { + Ok(&[]) + } + fn samples(&self, _n: usize) -> Result>, 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 diff --git a/tests/key_sources.rs b/tests/key_sources.rs index 439494c..5f89f52 100644 --- a/tests/key_sources.rs +++ b/tests/key_sources.rs @@ -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.