From 8e166903cc088a96ec640fd7af2e70de2876d56d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:33:00 -0700 Subject: [PATCH] freemkv-keysources: fixture-based integration tests for key sources Add tests/key_sources.rs exercising the published KeySource impls end-to-end over real fixture files and the libfreemkv parsers: - KeydbSource: disc-hash lookup from a real keydb.cfg (VUK hit ranks ahead of the universal DK pool), hash miss yields only the pool, missing file is silent/not errored, label + needs_samples, and host-cert serving from a | HC | row (inherent and trait paths). - paths: exe-local search list, default == search head, existing path reflects on-disk state (local-only, no OS fallback). - MapfileSource: persisted # freemkv-uk: keys read back as a terminal Key::Unit, one-shot exhaustion, missing/keyless mapfile offers nothing. - OnlineSource: unconfigured no-op (no network), one-shot latch, metadata, and validate_keyserver_url scheme/SSRF gating. - MultiSource: caller-supplied order/precedence (and its reverse), empty-source skip, needs_samples/errored OR-aggregation, nesting, and a real keydb-then-mapfile precedence chain over fixtures. --- tests/key_sources.rs | 534 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 tests/key_sources.rs diff --git a/tests/key_sources.rs b/tests/key_sources.rs new file mode 100644 index 0000000..3dea153 --- /dev/null +++ b/tests/key_sources.rs @@ -0,0 +1,534 @@ +//! Fixture-based integration tests for the published key sources. +//! +//! These exercise the *public* surface of `freemkv-keysources` end-to-end — +//! real files on disk, the real `KeyDb`/`Mapfile` parsers from libfreemkv, and +//! the `KeySource` trait the applications drive — rather than the pure +//! `candidates_from` unit tests that already live next to the source code. +//! +//! Covered: +//! - `KeydbSource`: lookup by disc hash through a real `keydb.cfg` file; the +//! exe-local `keydb_search_paths` / default / existing path helpers; host-cert +//! serving from a `| HC |` row. +//! - `MapfileSource`: terminal `Key::Unit` read back from a rip mapfile's +//! `# freemkv-uk:` header; one-shot exhaustion; missing-file silence. +//! - `OnlineSource`: SSRF/scheme validation and the unconfigured no-op (no +//! network is touched in CI). +//! - `MultiSource`: caller-supplied ordering / precedence, exhaustion advance, +//! `needs_samples`/`errored` aggregation, and nesting. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use freemkv_keysources::{ + DiscInputs, Key, KeySource, KeydbSource, MapfileSource, MultiSource, OnlineSource, + default_keydb_path, existing_keydb_path, keydb_search_paths, validate_keyserver_url, +}; + +// ── fixture helpers ───────────────────────────────────────────────────────── + +/// A unique scratch dir for one test, removed on `Drop` so fixtures never leak. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "fmk_ks_{}_{}_{:?}", + tag, + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + + fn write(&self, name: &str, body: &str) -> PathBuf { + let p = self.0.join(name); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + p + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Bare `DiscInputs` keyed only by disc hash — the lookup key a keydb uses. +fn inputs(hash: &str) -> DiscInputs { + DiscInputs { + disc_hash: hash.into(), + volume_id: [0u8; 16], + mkb: Vec::new(), + unit_key_ro: Vec::new(), + samples: Vec::new(), + volume_label: None, + } +} + +/// Drain a source completely into the ordered list of candidates it yields. +/// `Key` does not implement `PartialEq`, so callers compare via [`tags`]. +fn drain(src: &mut dyn KeySource, inp: &DiscInputs) -> Vec { + let mut out = Vec::new(); + while let Some(k) = src.next_key(inp) { + out.push(k); + } + out +} + +/// A comparable fingerprint for a `Key` (which has no `PartialEq`): the variant +/// plus its first identifying byte, enough to assert ordering deterministically. +fn tag(k: &Key) -> (u8, u8) { + match k { + Key::Device(_) => (0, 0), + Key::Processing(p) => (1, p.first().map(|b| b[0]).unwrap_or(0)), + Key::Media(m) => (2, m.first().map(|b| b[0]).unwrap_or(0)), + Key::Volume(v) => (3, v[0]), + Key::Unit(u) => (4, u.first().map(|(_, b)| b[0]).unwrap_or(0)), + // `Key` is #[non_exhaustive]; any future variant gets a distinct tag. + _ => (255, 0), + } +} + +fn tags(ks: &[Key]) -> Vec<(u8, u8)> { + ks.iter().map(tag).collect() +} + +// ── KeydbSource: real-file lookup by disc hash ────────────────────────────── + +const DISC_HASH: &str = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd"; + +/// A `keydb.cfg` with one per-disc entry (VUK only) plus a universal DK pool. +/// `0xHASH = TITLE | V | 0xVUK` is the disc-entry shape libfreemkv parses. +fn keydb_with_disc_entry() -> String { + format!( + "; fixture keydb\n\ + | DK | DEVICE_KEY 0x{dk} | DEVICE_NODE 0x0001 | KEY_UV 0x00000002 | KEY_U_MASK_SHIFT 0x00\n\ + {hash} = FIXTURE_DISC | V | 0x{vuk}\n", + dk = "22".repeat(16), + hash = DISC_HASH, + vuk = "11".repeat(16), + ) +} + +#[test] +fn keydb_source_looks_up_disc_by_hash_from_file() { + let s = Scratch::new("keydb_hit"); + let path = s.write("keydb.cfg", &keydb_with_disc_entry()); + + let mut src = KeydbSource::new(&path); + let cands = drain(&mut src, &inputs(DISC_HASH)); + + // The disc's own VUK (hash hit) must be the FIRST candidate, ahead of the + // universal device-key pool fallback. + assert!( + matches!(cands.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]), + "per-disc VUK from the hash hit must rank first, got {cands:?}" + ); + assert!( + cands.iter().any(|k| matches!(k, Key::Device(_))), + "the universal device-key pool is still offered as a fallback" + ); +} + +#[test] +fn keydb_source_hash_miss_yields_only_universal_pool() { + let s = Scratch::new("keydb_miss"); + let path = s.write("keydb.cfg", &keydb_with_disc_entry()); + + // A different disc: no per-disc entry, so no Volume/Unit candidate — only + // the universal DK pool the library walks against the disc's own MKB. + let mut src = KeydbSource::new(&path); + let cands = drain( + &mut src, + &inputs("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), + ); + + assert_eq!(cands.len(), 1, "hash miss offers only the universal pool"); + assert!(matches!(cands[0], Key::Device(_))); +} + +#[test] +fn keydb_source_missing_file_is_silent_not_errored() { + // A missing keydb is not an error — it simply offers no candidates, so a + // later source in the chain can still supply the key. + let mut src = KeydbSource::new("/nonexistent/path/keydb.cfg"); + assert!(src.next_key(&inputs(DISC_HASH)).is_none()); + assert!(!src.errored(), "a missing keydb must not flag errored()"); +} + +#[test] +fn keydb_source_label_and_needs_samples() { + let src = KeydbSource::new("/nonexistent/keydb.cfg"); + assert_eq!(src.label(), "keydb"); + // A keydb can hand out a terminal Key::Unit applied as-is, so it must demand + // ciphertext samples for validation. + assert!(src.needs_samples()); +} + +// ── KeydbSource: host-cert serving from a file ────────────────────────────── + +#[test] +fn keydb_source_serves_host_cert_from_hc_row() { + let s = Scratch::new("keydb_hc"); + // `| HC |` row with all-zero placeholder material (never a real key) — same + // convention libfreemkv's own parse_host_cert test uses. + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(20), + "00".repeat(92) + ); + let path = s.write("keydb.cfg", &line); + + let src = KeydbSource::new(&path); + // Both the inherent method and the trait method must surface the cert — the + // OEM/AACS cert-auth route collects through the trait. + let inherent = src.host_certs(); + let via_trait = KeySource::host_certs(&src); + assert_eq!(inherent.len(), 1, "inherent host_certs sees the HC row"); + assert_eq!(via_trait.len(), 1, "trait host_certs sees the HC row"); + assert_eq!(via_trait[0].certificate.len(), 92); +} + +#[test] +fn keydb_source_host_certs_empty_when_file_missing() { + let src = KeydbSource::new("/nonexistent/keydb.cfg"); + assert!(src.host_certs().is_empty()); + assert!(KeySource::host_certs(&src).is_empty()); +} + +// ── path policy: exe-local, local-only ────────────────────────────────────── + +/// The exe-local keydb path, computed the way the module does — under +/// `cargo test` `current_exe()` is the integration-test binary under `target/`. +fn expected_local() -> Option { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg"))) +} + +#[test] +fn search_paths_is_exactly_exe_local() { + let paths = keydb_search_paths(); + match expected_local() { + Some(expected) => assert_eq!( + paths, + vec![expected], + "search list must be exactly [/keydb.cfg], no OS fallback" + ), + None => assert!(paths.is_empty(), "no exe dir → empty, never an OS fallback"), + } +} + +#[test] +fn default_path_matches_search_head() { + assert_eq!(default_keydb_path(), expected_local()); + assert_eq!( + default_keydb_path(), + keydb_search_paths().into_iter().next() + ); +} + +#[test] +fn existing_keydb_path_reflects_disk_state() { + // The exe-local path almost certainly does not exist under target/ during a + // test run; existing_keydb_path() returns Some only when the head path is + // actually present on disk. Assert the two agree. + let head_exists = keydb_search_paths() + .first() + .map(|p| p.exists()) + .unwrap_or(false); + assert_eq!(existing_keydb_path().is_some(), head_exists); +} + +// ── MapfileSource: persisted unit keys read back from a mapfile ───────────── + +/// A minimal ddrescue-style mapfile carrying two persisted unit keys in its +/// `# freemkv-uk:` header plus one data line (the "current state" + region). +fn mapfile_with_keys() -> String { + "# Rescue Logfile. Created by freemkv test\n\ + # freemkv-uk: 0:11111111111111111111111111111111\n\ + # freemkv-uk: 1:22222222222222222222222222222222\n\ + 0x0 0x200 +\n" + .to_string() +} + +#[test] +fn mapfile_source_reads_persisted_unit_keys() { + let s = Scratch::new("mapfile_keys"); + let path = s.write("rip.mapfile", &mapfile_with_keys()); + + let mut src = MapfileSource::new(&path); + // MapfileSource ignores DiscInputs — disc identity is implicit in the path. + let first = src.next_key(&inputs("ignored")); + match first { + Some(Key::Unit(uks)) => { + assert_eq!( + uks, + vec![(0u32, [0x11u8; 16]), (1u32, [0x22u8; 16])], + "both persisted unit keys must be read back, in order" + ); + } + other => panic!("expected terminal Key::Unit from the mapfile, got {other:?}"), + } +} + +#[test] +fn mapfile_source_is_one_shot() { + let s = Scratch::new("mapfile_oneshot"); + let path = s.write("rip.mapfile", &mapfile_with_keys()); + + let mut src = MapfileSource::new(&path); + assert!( + src.next_key(&inputs("x")).is_some(), + "first ask yields the UK set" + ); + assert!( + src.next_key(&inputs("x")).is_none(), + "the mapfile holds exactly one UK set — a second ask is exhausted" + ); +} + +#[test] +fn mapfile_source_missing_or_keyless_offers_nothing() { + // Missing file: silent None, not an error. + let mut missing = MapfileSource::new("/nonexistent/rip.mapfile"); + assert!(missing.next_key(&inputs("x")).is_none()); + assert!(!missing.errored()); + + // A mapfile with NO freemkv-uk header (unresolved / VID-only): nothing. + let s = Scratch::new("mapfile_keyless"); + let path = s.write( + "rip.mapfile", + "# Rescue Logfile. Created by freemkv test\n0x0 0x200 +\n", + ); + let mut keyless = MapfileSource::new(&path); + assert!( + keyless.next_key(&inputs("x")).is_none(), + "a keyless mapfile offers no candidate" + ); +} + +// ── OnlineSource: validation + unconfigured no-op (no network in CI) ───────── + +#[test] +fn online_source_unconfigured_is_silent_no_op() { + // Empty base URL → a clean "no service" None, no network, not an error. + let mut src = OnlineSource::new("", ""); + assert!(src.next_key(&inputs(DISC_HASH)).is_none()); + assert!( + !src.errored(), + "an unconfigured online source is not errored" + ); +} + +#[test] +fn online_source_one_shot_after_unconfigured_ask() { + let mut src = OnlineSource::new("", ""); + assert!(src.next_key(&inputs(DISC_HASH)).is_none()); + // `asked` latched — a second ask is a no-op None regardless. + assert!(src.next_key(&inputs(DISC_HASH)).is_none()); +} + +#[test] +fn online_source_metadata() { + let src = OnlineSource::new("https://example.invalid/keys", "tok"); + assert_eq!(src.label(), "online"); + assert!( + src.needs_samples(), + "the key service validates against ciphertext" + ); + // No host-cert serving today — a no-op empty, no network touched. + assert!(KeySource::host_certs(&src).is_empty()); +} + +#[test] +fn validate_keyserver_url_gates_scheme_and_ssrf() { + // Public literal IP (no DNS) passes. + assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok()); + // Internal / metadata / bad-scheme are rejected at config time. + assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err()); + assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err()); + assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err()); + assert!(validate_keyserver_url("ftp://example.com/keys").is_err()); + assert!(validate_keyserver_url("").is_err()); +} + +// ── MultiSource: ordering, precedence, aggregation, nesting ───────────────── + +/// A scripted source for composition tests: yields its queued keys in order, +/// then None, and reports its configured `needs_samples`/`errored`. +struct ScriptedSource { + queue: std::vec::IntoIter, + needs_samples: bool, + errored: bool, + label: &'static str, +} + +impl ScriptedSource { + fn new(label: &'static str, keys: Vec) -> Self { + Self { + queue: keys.into_iter(), + needs_samples: false, + errored: false, + label, + } + } + fn with_needs_samples(mut self, v: bool) -> Self { + self.needs_samples = v; + self + } + fn with_errored(mut self, v: bool) -> Self { + self.errored = v; + self + } +} + +impl KeySource for ScriptedSource { + fn next_key(&mut self, _inputs: &DiscInputs) -> Option { + self.queue.next() + } + fn needs_samples(&self) -> bool { + self.needs_samples + } + fn errored(&self) -> bool { + self.errored + } + fn label(&self) -> &'static str { + self.label + } +} + +fn vol(b: u8) -> Key { + Key::Volume([b; 16]) +} + +#[test] +fn multi_source_preserves_caller_order() { + // Caller supplies [A, B]; MultiSource must exhaust A fully before B, + // preserving within-source order — the "which sources, in what order" + // policy lives entirely with the caller. + let a = ScriptedSource::new("A", vec![vol(0xa1), vol(0xa2)]); + let b = ScriptedSource::new("B", vec![vol(0xb1)]); + let mut multi = MultiSource::new(vec![Box::new(a), Box::new(b)]); + + let got = drain(&mut multi, &inputs("x")); + assert_eq!( + tags(&got), + tags(&[vol(0xa1), vol(0xa2), vol(0xb1)]), + "A is exhausted (in order) before B is consulted" + ); +} + +#[test] +fn multi_source_order_is_reversible() { + // The SAME two sources in the opposite order yield the opposite precedence — + // proving the order is the caller's, not baked in. + let a = ScriptedSource::new("A", vec![vol(0xa1)]); + let b = ScriptedSource::new("B", vec![vol(0xb1)]); + let mut multi = MultiSource::new(vec![Box::new(b), Box::new(a)]); + + let got = drain(&mut multi, &inputs("x")); + assert_eq!( + tags(&got), + tags(&[vol(0xb1), vol(0xa1)]), + "B-first ordering wins" + ); +} + +#[test] +fn multi_source_skips_empty_sources() { + // An empty source in the middle is transparently skipped to the next. + let empty = ScriptedSource::new("empty", vec![]); + let real = ScriptedSource::new("real", vec![vol(0xc1)]); + let mut multi = MultiSource::new(vec![Box::new(empty), Box::new(real)]); + + assert_eq!(tags(&drain(&mut multi, &inputs("x"))), tags(&[vol(0xc1)])); +} + +#[test] +fn multi_source_aggregates_needs_samples_and_errored() { + // needs_samples / errored are OR-aggregated across the composed sources. + let plain = ScriptedSource::new("plain", vec![]); + let sampler = ScriptedSource::new("sampler", vec![]).with_needs_samples(true); + let multi = MultiSource::new(vec![Box::new(plain), Box::new(sampler)]); + assert!( + multi.needs_samples(), + "any source needing samples propagates" + ); + + let ok = ScriptedSource::new("ok", vec![]); + let bad = ScriptedSource::new("bad", vec![]).with_errored(true); + let multi2 = MultiSource::new(vec![Box::new(ok), Box::new(bad)]); + assert!(multi2.errored(), "any errored source propagates"); + + // All-clean composition reports neither. + let c1 = ScriptedSource::new("c1", vec![]); + let c2 = ScriptedSource::new("c2", vec![]); + let clean = MultiSource::new(vec![Box::new(c1), Box::new(c2)]); + assert!(!clean.needs_samples()); + assert!(!clean.errored()); +} + +#[test] +fn multi_source_nests() { + // MultiSource is itself a KeySource, so it composes inside another + // MultiSource — inner [A, B] then outer C: order A, B, C. + let a = ScriptedSource::new("A", vec![vol(0xa1)]); + let b = ScriptedSource::new("B", vec![vol(0xb1)]); + let inner = MultiSource::new(vec![Box::new(a), Box::new(b)]); + let c = ScriptedSource::new("C", vec![vol(0xc1)]); + let mut outer = MultiSource::new(vec![Box::new(inner), Box::new(c)]); + + assert_eq!( + tags(&drain(&mut outer, &inputs("x"))), + tags(&[vol(0xa1), vol(0xb1), vol(0xc1)]), + "nested MultiSource preserves the flattened caller order" + ); +} + +#[test] +fn multi_source_real_keydb_then_mapfile_precedence() { + // End-to-end precedence with the REAL sources over fixture files: a + // keydb-first chain hands the keydb's per-disc VUK ahead of the mapfile's + // terminal UK. (Resume chains flip this to [Mapfile, Keydb].) + let s = Scratch::new("multi_real"); + let keydb = s.write("keydb.cfg", &keydb_with_disc_entry()); + let map = s.write("rip.mapfile", &mapfile_with_keys()); + + let mut multi = MultiSource::new(vec![ + Box::new(KeydbSource::new(&keydb)), + Box::new(MapfileSource::new(&map)), + ]); + let got = drain(&mut multi, &inputs(DISC_HASH)); + + // First candidate is the keydb's VUK (hash hit), proving keydb precedes the + // mapfile; the mapfile's terminal Unit appears later in the chain. + assert!( + matches!(got.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]), + "keydb VUK leads the keydb-first chain, got {got:?}" + ); + assert!( + got.iter() + .any(|k| matches!(k, Key::Unit(uks) if uks.contains(&(0u32, [0x11u8; 16])))), + "the mapfile's terminal unit keys follow once the keydb is exhausted" + ); + // KeydbSource needs samples → the composed chain demands them too. + assert!(multi.needs_samples()); +} + +/// A keydb file that exists but exercises the `Path`-typed constructor with a +/// borrowed path (the apps pass `&Path`/`PathBuf` interchangeably). +#[test] +fn keydb_source_accepts_borrowed_path() { + let s = Scratch::new("keydb_borrow"); + let path: &Path = &s.path("keydb.cfg"); + std::fs::write(path, keydb_with_disc_entry()).unwrap(); + let mut src = KeydbSource::new(path); + assert!(src.next_key(&inputs(DISC_HASH)).is_some()); +}