diff --git a/src/keydb.rs b/src/keydb.rs index 8dd4468..738089e 100644 --- a/src/keydb.rs +++ b/src/keydb.rs @@ -653,1316 +653,5 @@ impl KeySource for KeydbSource { } #[cfg(test)] -mod tests { - use super::*; - use crate::keydb_format::DiscEntry; - - /// A fixed, in-the-past mtime — `touch -t CCYYMMDDhhmm.ss`. - #[cfg(unix)] - const PINNED_MTIME: &str = "202601011200.00"; - - // Force a file's mtime to PINNED_MTIME so two different files can be made bit-identical to - // a (len, mtime) stamp on purpose, rather than hoping the filesystem produces that case. - #[cfg(unix)] - fn pin_mtime(p: &std::path::Path) { - let ok = std::process::Command::new("touch") - .arg("-t") - .arg(PINNED_MTIME) - .arg(p) - .status() - .expect("touch must run") - .success(); - assert!(ok, "touch -t failed"); - } - - use libfreemkv::aacs::derive::derive_vuk; - use libfreemkv::aacs::types::DeviceKey; - use std::collections::HashMap; - - // ── A test ResolveCtx, so get_unit_keys's path selection can be exercised without - // a real Disc. Each accessor returns exactly what a case needs. ────────── - struct MockCtx { - disc_hash: String, - vid: Option, - mkb: Vec, - enc_title_keys: Vec<[u8; 16]>, - } - impl ResolveCtx for MockCtx { - fn disc_hash(&self) -> &str { - &self.disc_hash - } - fn title(&self) -> Option<&str> { - None - } - fn vid(&self) -> Option { - self.vid - } - fn mkb(&self) -> Result<&[u8], Error> { - Ok(&self.mkb) - } - fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { - Ok(&self.enc_title_keys) - } - fn samples(&self, _n: usize) -> Result>, Error> { - Ok(Vec::new()) - } - } - - fn ctx(hash: &str, enc: Vec<[u8; 16]>, vid: Option) -> MockCtx { - MockCtx { - disc_hash: hash.into(), - vid, - mkb: Vec::new(), - enc_title_keys: enc, - } - } - - fn dk() -> DeviceKey { - DeviceKey { - key: [0x22u8; 16], - node: 1, - uv: 2, - u_mask_shift: 0, - } - } - - fn blank_entry(hash: &str) -> DiscEntry { - DiscEntry { - disc_hash: hash.into(), - title: String::new(), - media_key: None, - vid: None, - vuk: None, - unit_keys: Vec::new(), - mkb_version: None, - volume_size: None, - is_uhd: false, - } - } - - fn db_with(entry: DiscEntry, device_keys: Vec) -> KeyDb { - let mut entries = HashMap::new(); - entries.insert(entry.disc_hash.clone(), entry); - KeyDb { - device_keys, - processing_keys: Vec::new(), - host_certs: Vec::new(), - disc_entries: entries, - } - } - - // The committed (cps, key) pairs resolve_and_apply derives from a source's Unit Keys (idx - // -> idx + 1). KATs compare against THIS. - fn committed(uks: &[UnitKey]) -> Vec<(u32, [u8; 16])> { - uks.iter() - .map(|u| (u.idx.saturating_add(1), u.key)) - .collect() - } - - const HASH: &str = "0xaabb"; - - // MockCtx implements the full trait so it can stand in for a real disc, - // but KeydbSource never calls title()/samples(); 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()); - } - - // Issue #46 — de-conflated resolution: a matched entry with a Media Key but - // NO VID on this path (locked VID-per-path rule) is reported `matched` with a - // `NoVid` miss path — the exact case behind the reporter's "no entry". - #[test] - fn resolve_matched_media_key_without_vid_reports_no_vid_not_a_miss() { - let mut e = blank_entry(HASH); - e.media_key = Some([0x33u8; 16]); - let db = db_with(e, Vec::new()); - // enc_title_keys present so derivation is attempted; ctx supplies no VID. - let r = KeydbSource::resolve_from(&db, &ctx(HASH, vec![[0x44u8; 16]], None)); - assert!(r.keys.is_empty(), "no VID -> nothing derivable"); - assert!(r.matched, "the disc WAS found — not a true miss"); - assert_eq!(r.miss_path, vec![KeyNode::NoVid]); - let shape = r.shape.expect("a matched entry carries a shape"); - assert!(shape.has_media_key); - assert!(!shape.has_vuk); - assert!(!shape.has_unit_keys); - assert_eq!(shape.enc_title_keys_len, 1); - assert!(!shape.vid_available); - } - - // KU J23: stored unit keys for SOME CPS units plus a Media Key with no VID. The stored - // keys come back, and the miss path still says `NoVid`: the VID would derive the rest. - #[test] - fn partial_stored_keys_with_a_media_key_and_no_vid_report_no_vid() { - let mut e = blank_entry(HASH); - e.media_key = Some([0x33u8; 16]); - e.unit_keys = vec![(1, [0x55u8; 16])]; - let db = db_with(e, Vec::new()); - let r = KeydbSource::resolve_from(&db, &ctx(HASH, vec![[0x44u8; 16]; 2], None)); - assert_eq!(r.keys.len(), 1, "the stored key"); - assert_eq!(r.miss_path, vec![KeyNode::NoVid]); - } - - // A hash that is NOT in the keydb is the one true miss: not matched, no - // shape, no miss path (the library renders this as `no entry`). - #[test] - fn resolve_unmatched_hash_is_a_true_miss() { - let db = db_with(blank_entry(HASH), Vec::new()); - let r = KeydbSource::resolve_from(&db, &ctx("0xdeadbeef", Vec::new(), None)); - assert!(!r.matched); - assert!(r.keys.is_empty()); - assert!(r.shape.is_none()); - assert!(r.miss_path.is_empty()); - // The store size travels with the miss so the verdict can name it. - assert_eq!(r.entries_loaded, 1); - } - - // Matched but no derivation material at all (no stored keys, no enc title - // keys): matched with an EMPTY miss path, which the library renders as the - // generic `no derivable key` — still distinct from a true miss. - #[test] - fn resolve_matched_with_no_material_has_empty_miss_path() { - let db = db_with(blank_entry(HASH), Vec::new()); - let r = KeydbSource::resolve_from(&db, &ctx(HASH, Vec::new(), None)); - assert!(r.matched); - assert!(r.keys.is_empty()); - assert!( - r.miss_path.is_empty(), - "no specific reason -> library supplies NoDerivableKey" - ); - let shape = r.shape.expect("matched -> shape present"); - assert!(!shape.has_vuk && !shape.has_media_key && !shape.has_unit_keys); - } - - // Matched entry that DOES resolve: keys present, matched, empty miss path, - // and the shape reflects the stored unit keys. - #[test] - fn resolve_matched_with_stored_unit_keys_reports_keys_and_shape() { - let mut e = blank_entry(HASH); - e.unit_keys = vec![(1, [0xA0u8; 16])]; - let db = db_with(e, Vec::new()); - let r = KeydbSource::resolve_from(&db, &ctx(HASH, Vec::new(), None)); - assert!(!r.keys.is_empty()); - assert!(r.matched); - assert!(r.miss_path.is_empty()); - let shape = r.shape.expect("matched -> shape"); - assert!(shape.has_unit_keys); - assert_eq!(shape.unit_keys_len, 1); - } - - // KAT (a): disc with terminal Unit Keys, no enc_title_keys. Stored terminal unit keys are - // returned with CPS numbering preserved. - #[test] - fn kat_a_disc_with_unit_keys_is_terminal_and_preserves_cps_numbering() { - let mut e = blank_entry(HASH); - e.unit_keys = vec![(1, [0xA0u8; 16]), (2, [0xB1u8; 16])]; - // VUK present but no enc_title_keys → nothing to boil, stored stands. - e.vuk = Some([0x11u8; 16]); - let db = db_with(e, Vec::new()); - - let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)); - assert_eq!( - committed(&got), - vec![(1u32, [0xA0u8; 16]), (2u32, [0xB1u8; 16])], - "terminal keydb unit keys must commit byte-identically to the stored (cps, key) pairs" - ); - } - - // A CPS unit number of 0 is invalid (valid numbering is >= 1); it must be - // skipped, not mapped to idx 0 where `num - 1` would collide with unit 1. - #[test] - fn stored_unit_key_with_cps_number_zero_is_skipped() { - let mut e = blank_entry(HASH); - e.unit_keys = vec![(0, [0xEEu8; 16]), (1, [0xA0u8; 16])]; - let db = db_with(e, Vec::new()); - let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)); - let keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect(); - assert!( - !keys.contains(&[0xEEu8; 16]), - "a unit-0 key must be dropped, never mapped onto idx 0" - ); - assert_eq!(committed(&got), vec![(1u32, [0xA0u8; 16])]); - } - - // Orphan-unit completeness (the real keydb bug): a PARTIAL stored list plus a VUK that - // boils BOTH declared units must return BOTH, not shadow the VUK with the partial list. - #[test] - fn union_partial_stored_plus_vuk_yields_all_declared_units() { - let vuk = [0x5Au8; 16]; - let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; // two declared CPS units - let derived = crate::uks_from_vuk(&vuk, &enc); // [d0, d1] - - let mut e = blank_entry(HASH); - e.unit_keys = vec![(1, [0xA0u8; 16])]; // PARTIAL: only uk1 stored - e.vuk = Some(vuk); - let db = db_with(e, Vec::new()); - - let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); - let got_keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect(); - assert!(got_keys.contains(&[0xA0u8; 16]), "the stored uk1 is kept"); - assert!( - got_keys.contains(&derived[1].key), - "the VUK-derived SECOND CPS unit is added, not shadowed by the partial stored list" - ); - assert!( - got.len() >= 2, - "a partial stored list must no longer shadow the complete VUK" - ); - } - - // ── KAT (b): disc with VUK ──────────────────────────────────────────────── - /// A hash hit with only a VUK derives the terminal keys via `uk_from_vuk` - /// over the disc's encrypted title keys — byte-identical to the OLD - /// `Key::Volume(vuk)` → resolver path (which called the same primitive). - #[test] - fn kat_b_disc_with_vuk_derives_via_uk_from_vuk() { - let vuk = [0x5Au8; 16]; - // Two encrypted title keys (arbitrary ciphertext; both sides decrypt the - // SAME bytes, which is the parity claim). - let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; - - let mut e = blank_entry(HASH); - e.vuk = Some(vuk); - let db = db_with(e, Vec::new()); - - let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); - // Reference: the boil primitive directly — the OLD derivation. - let expect = crate::uks_from_vuk(&vuk, &enc); - assert_eq!( - got, expect, - "VUK path must equal uk_from_vuk(vuk, enc_title_keys)" - ); - // And the committed numbering is 1-based positional. - assert_eq!( - committed(&got).iter().map(|(n, _)| *n).collect::>(), - vec![1, 2] - ); - } - - // KAT (c): MK + physical (unlock) VID derives MK -> VUK -> UK. The PHYSICAL VID must win - // over the keydb's stored VID. - #[test] - fn kat_c_disc_with_mk_uses_physical_vid_over_keydb_vid() { - let mk = [0x77u8; 16]; - let vid_phys = [0x42u8; 16]; - let vid_keydb = [0x99u8; 16]; // deliberately different — must NOT be used - let enc = vec![[0x10u8; 16]]; - - let mut e = blank_entry(HASH); - e.media_key = Some(mk); - e.vid = Some(vid_keydb); - let db = db_with(e, Vec::new()); - - let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys)))); - // Reference uses the PHYSICAL VID. - let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc); - assert_eq!(got, expect, "MK path must use the physical (unlock) VID"); - // Sanity: it must NOT match the keydb-VID derivation (different VID → - // different VUK → different keys), proving the right VID was selected. - let wrong = crate::uks_from_vuk(&derive_vuk(&mk, &vid_keydb), &enc); - assert_ne!( - got, wrong, - "must not derive with the keydb VID when a physical VID exists" - ); - } - - // ── KAT (d): disc with MK + keydb VID (ISO path, no physical VID) ────────── - /// A hash hit with a Media Key but NO physical VID falls back to the keydb - /// entry's stored VID (`vid`, the `I` field) — the non-physical / ISO - /// path — and derives `MK → VUK → UK` against it. - #[test] - fn kat_d_disc_with_mk_falls_back_to_keydb_vid() { - let mk = [0x77u8; 16]; - let vid_keydb = [0x99u8; 16]; - let enc = vec![[0x10u8; 16], [0x20u8; 16]]; - - let mut e = blank_entry(HASH); - e.media_key = Some(mk); - e.vid = Some(vid_keydb); - let db = db_with(e, Vec::new()); - - // ctx.vid() == None → ISO path. - let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); - let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_keydb), &enc); - assert_eq!( - got, expect, - "MK path must use the keydb VID when no physical VID is present" - ); - } - - // ── KAT (e): disc with MK + NO VID anywhere → empty ─────────────────────── - /// A hash hit with a Media Key but neither a physical VID nor a stored keydb - /// VID cannot derive a VUK — the locked VID-per-path rule. It must return - /// EMPTY, never a guessed/zero-VID key (wrong-keys safety). - #[test] - fn kat_e_disc_with_mk_no_vid_returns_empty() { - let mut e = blank_entry(HASH); - e.media_key = Some([0x77u8; 16]); - e.vid = None; // no keydb VID - let db = db_with(e, Vec::new()); - - // ctx.vid() == None and no keydb VID → cannot derive. - let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, vec![[0x10u8; 16]], None)); - assert!( - got.is_empty(), - "MK with no VID source must yield no keys, never a guess" - ); - } - - /// Build a 4-byte MKB record header (type + 3-byte big-endian total length, - /// header included) and append `body`. No crypto — just the record framing - /// libfreemkv's MKB parser expects. - fn mkb_record(rec_type: u8, body: &[u8]) -> Vec { - let total = 4 + body.len(); - let mut rec = vec![ - rec_type, - ((total >> 16) & 0xFF) as u8, - ((total >> 8) & 0xFF) as u8, - (total & 0xFF) as u8, - ]; - rec.extend_from_slice(body); - rec - } - - // KAT (f): disc with NO per-disc MK, resolved via the keydb PK pool (PK -> MK -> VUK -> UK) - // with a known-answer MKB/PK construction. - #[test] - fn kat_f_disc_with_pk_pool_yields_uks() { - // Planted PK and the MK it resolves to (see libfreemkv boil.rs KAT). - let pk: [u8; 16] = [ - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, - 0xFF, 0x00, - ]; - let mk: [u8; 16] = [ - 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, - 0xAE, 0xAF, - ]; - // cvalue = AES-E(pk, mk_raw); verify = AES-E(mk, magic||pad); SD uv. - let cv: [u8; 16] = [ - 0x72, 0x23, 0x96, 0x80, 0xB5, 0xC5, 0x2B, 0x9D, 0x63, 0xE9, 0xEC, 0x92, 0xCF, 0xAF, - 0xDE, 0x1B, - ]; - let mk_dv: [u8; 16] = [ - 0x05, 0xA7, 0x4C, 0xC9, 0xD0, 0x2E, 0x9F, 0x4B, 0x42, 0xDF, 0x2C, 0x0A, 0xAD, 0x79, - 0x58, 0xF4, - ]; - let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00]; - - // Synthetic MKB: type/version (0x10), verify (0x86), one-entry SD index - // (0x04 = [u_mask_shift=0][uv]), one-entry cvalue table (0x05). - let mut sd = vec![0u8]; - sd.extend_from_slice(&uv); - let mut mkb = Vec::new(); - mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); - mkb.extend_from_slice(&mkb_record(0x86, &mk_dv)); - mkb.extend_from_slice(&mkb_record(0x04, &sd)); - mkb.extend_from_slice(&mkb_record(0x05, &cv)); - - // Disc entry with NO stored MK/VUK/UK — only the global PK pool can resolve. - let e = blank_entry(HASH); - let mut db = db_with(e, Vec::new()); - db.processing_keys = vec![pk]; - - let enc = vec![[0x10u8; 16], [0x20u8; 16]]; - let vid_phys = [0x42u8; 16]; - let ctx = MockCtx { - disc_hash: HASH.into(), - vid: Some(Vid(vid_phys)), - mkb, - enc_title_keys: enc.clone(), - }; - - let got = KeydbSource::unit_keys_from(&db, &ctx); - assert!(!got.is_empty(), "PK pool must yield Unit Keys for the disc"); - // Byte-identical to deriving from the recovered MK via the public chain. - let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc); - assert_eq!( - got, expect, - "PK path must equal MK → VUK → UK from the recovered Media Key" - ); - } - - /// A PK pool that does NOT resolve the disc's MKB yields nothing — never a - /// wrong key. (Same MKB as KAT (f) but a corrupt PK.) - #[test] - fn pk_pool_that_does_not_validate_yields_no_key() { - let mk_dv: [u8; 16] = [ - 0x05, 0xA7, 0x4C, 0xC9, 0xD0, 0x2E, 0x9F, 0x4B, 0x42, 0xDF, 0x2C, 0x0A, 0xAD, 0x79, - 0x58, 0xF4, - ]; - let cv: [u8; 16] = [ - 0x72, 0x23, 0x96, 0x80, 0xB5, 0xC5, 0x2B, 0x9D, 0x63, 0xE9, 0xEC, 0x92, 0xCF, 0xAF, - 0xDE, 0x1B, - ]; - let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00]; - let mut sd = vec![0u8]; - sd.extend_from_slice(&uv); - let mut mkb = Vec::new(); - mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); - mkb.extend_from_slice(&mkb_record(0x86, &mk_dv)); - mkb.extend_from_slice(&mkb_record(0x04, &sd)); - mkb.extend_from_slice(&mkb_record(0x05, &cv)); - - let mut db = db_with(blank_entry(HASH), Vec::new()); - db.processing_keys = vec![[0x00u8; 16]]; // does not validate - - let ctx = MockCtx { - disc_hash: HASH.into(), - vid: Some(Vid([0x42u8; 16])), - mkb, - enc_title_keys: vec![[0x10u8; 16]], - }; - assert!( - KeydbSource::unit_keys_from(&db, &ctx).is_empty(), - "a non-validating PK pool must resolve nothing, never a wrong key" - ); - } - - /// No per-disc entry → no key, even with a universal device-key pool present. - /// Without a matched entry there is no per-disc anchor, so the global pools - /// are never consulted (the cross-disc MK-pool brute stays retired). - #[test] - fn no_disc_hit_yields_no_key() { - let db = db_with(blank_entry("0xother"), vec![dk()]); - let got = - KeydbSource::unit_keys_from(&db, &ctx(HASH, vec![[0x10u8; 16]], Some(Vid([1u8; 16])))); - assert!( - got.is_empty(), - "a hash miss resolves nothing from the keydb" - ); - } - - /// Empty keydb resolves nothing. - #[test] - fn empty_keydb_yields_no_key() { - let db = KeyDb { - device_keys: Vec::new(), - processing_keys: Vec::new(), - host_certs: Vec::new(), - disc_entries: HashMap::new(), - }; - assert!(KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)).is_empty()); - } - - /// A missing keydb file is silent (Ok empty), never an error. - #[test] - fn get_uk_missing_keydb_is_ok_empty() { - let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); - let got = src - .get_unit_keys(&ctx(HASH, Vec::new(), None)) - .expect("missing keydb is not an error"); - assert!(got.is_empty()); - } - - #[test] - fn label_is_keydb() { - assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb"); - } - - // KSK1: keyed by disc hash, so `resolve` asks it once per rip, not once per - // piece (KU §2.3 step 8, N-KU10). - #[test] - fn keydb_source_answer_does_not_depend_on_samples() { - assert!(!KeydbSource::new("/nonexistent/keydb.cfg").answer_depends_on_samples()); - // KU J23: a keydb's VID need is per disc (its `NoVid` miss path), not blanket. - assert!(!KeydbSource::new("/nonexistent/keydb.cfg").uses_vid()); - } - - // A stamp with no mtime (mtime-less FS) or a future mtime (clock skew) must - // not be stuck un-settled forever — it now settles via the inode-identity - // fallback once the observation has aged past the granularity. - #[test] - fn is_settled_falls_back_when_mtime_is_absent_or_in_the_future() { - use std::time::{Duration, SystemTime}; - let entry = |modified: Option, stamped_at: SystemTime| CacheEntry { - stamp: FileStamp { - dev: 1, - ino: 2, - len: 3, - modified, - }, - stamped_at, - db: Arc::new(KeyDb::empty()), - stats: crate::keydb_format::ParseStats::default(), - }; - let gran = Duration::from_secs(2); - let now = SystemTime::now(); - - // None mtime, stamped well in the past → settled via the fallback. - assert!( - entry(None, now - Duration::from_secs(3600)).is_settled(gran), - "a mtime-less entry must settle once it has aged past the granularity" - ); - // None mtime, just stamped → still inside the settle window, NOT settled. - assert!( - !entry(None, now).is_settled(gran), - "a freshly stamped mtime-less entry is too young to trust yet" - ); - // A FUTURE mtime (clock skew) with an old stamp → still settles via the - // fallback rather than being wedged un-settled forever. - assert!( - entry( - Some(now + Duration::from_secs(3600)), - now - Duration::from_secs(3600) - ) - .is_settled(gran), - "a future mtime must not wedge the entry un-settled forever" - ); - // Sanity: a real past mtime still settles the normal way. - assert!( - entry(Some(now - Duration::from_secs(3600)), now).is_settled(gran), - "a normal past mtime still settles" - ); - } - - /// Best-first with no disc input: unannotated certs in file order, then revoked - /// ones by latest revocation. The trait ignores its MKB argument. - #[test] - fn host_certs_ranked_orders_unannotated_then_latest_revocation_first() { - let dir = scratch("ranked"); - let path = dir.join("keydb.cfg"); - let hc = |marker: u8, note: &str| { - format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}{}\n", - "00".repeat(20), - format!("{marker:02x}").repeat(92), - note - ) - }; - let cfg = [ - hc(0x68, " ; Revoked in MKBv68"), - hc(0xA1, ""), - hc(0x72, " ; Revoked in MKBv72"), - hc(0xB2, ""), - ] - .concat(); - std::fs::write(&path, cfg).unwrap(); - let src = KeydbSource::new(&path); - let markers = - |certs: Vec| -> Vec { certs.iter().map(|c| c.certificate[0]).collect() }; - let want = vec![0xA1, 0xB2, 0x72, 0x68]; - assert_eq!( - markers(src.host_certs()), - want, - "inherent (production credentials)" - ); - assert_eq!( - markers(KeySource::host_certs(&src, Some(99))), - want, - "trait ignores mkb" - ); - assert_eq!(markers(KeySource::host_certs(&src, None)), want); - std::fs::remove_dir_all(&dir).ok(); - } - - /// No keydb → no host credentials, not an error (inherent and trait forms). - #[test] - fn host_certs_empty_when_keydb_missing() { - let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); - assert!(src.host_certs().is_empty()); - assert!(KeySource::host_certs(&src, None).is_empty()); - assert!(KeySource::host_certs(&src, Some(68)).is_empty()); - } - - /// The TRAIT `host_certs` surfaces a `| HC |` row for any `mkb` argument. - /// Placeholder all-zero material (never a real key). - #[test] - fn trait_host_certs_returns_keydb_hc_row() { - let dir = std::env::temp_dir().join(format!("fmk_hc_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("keydb.cfg"); - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(20), - "00".repeat(92) - ); - std::fs::write(&path, line).unwrap(); - - let src = KeydbSource::new(&path); - // A cert with no revocation annotation is returned for ANY mkb arg. - let certs = KeySource::host_certs(&src, Some(70)); - assert_eq!(certs.len(), 1, "trait host_certs must surface the HC row"); - assert_eq!(certs[0].certificate.len(), 92); - - std::fs::remove_dir_all(&dir).ok(); - } - - // Caching: the 62 MiB / 181k-entry keydb is parsed ONCE. A single AACS-cert rip drives this - // source at least three times; the mtime is pinned into the PAST first, since an entry is - // only trusted once settled. - #[cfg(unix)] - #[test] - fn repeated_lookups_parse_the_keydb_once() { - let dir = scratch("cache-hit"); - let path = dir.join("keydb.cfg"); - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(20), - "00".repeat(92) - ); - std::fs::write(&path, &line).unwrap(); - pin_mtime(&path); - - let src = KeydbSource::new(&path); - assert_eq!(src.host_certs().len(), 1); - assert_eq!(KeySource::host_certs(&src, Some(70)).len(), 1); - assert!( - src.get_unit_keys(&ctx(HASH, Vec::new(), None)) - .unwrap() - .is_empty() - ); - assert_eq!( - src.parse_count(), - 1, - "three lookups over an unchanged file must parse it once" - ); - } - - // The cache must not go stale: a keydb replaced UNDERNEATH the source has a different - // size/mtime stamp and must be re-read. - #[test] - fn a_changed_keydb_file_is_reparsed() { - let dir = scratch("cache-stamp"); - let path = dir.join("keydb.cfg"); - let hc = |b: &str, n: usize| { - format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(20), - b.repeat(n) - ) - }; - std::fs::write(&path, hc("00", 92)).unwrap(); - let src = KeydbSource::new(&path); - assert_eq!(src.host_certs()[0].certificate.len(), 92); - - // A different LENGTH guarantees a different stamp regardless of the - // filesystem's mtime resolution. - std::fs::write(&path, hc("11", 100)).unwrap(); - assert_eq!( - src.host_certs()[0].certificate.len(), - 100, - "a replaced keydb must be re-read, not served from a stale cache" - ); - assert_eq!(src.parse_count(), 2, "one parse per distinct file"); - } - - // Two stale-cache discriminators, tested ONE AT A TIME (each test disables the other via - // `mtime_granularity`). Discriminator 1, the inode: an ATOMIC RENAME must be re-read even - // with identical length and mtime. - #[cfg(unix)] - #[test] - fn a_same_length_same_mtime_rename_is_still_re_read() { - let dir = scratch("cache-inode"); - let path = dir.join("keydb.cfg"); - let replacement = dir.join("keydb.cfg.new"); - let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n"); - - std::fs::write(&path, body(&"01".repeat(16))).unwrap(); - pin_mtime(&path); - let src = KeydbSource::new(&path).with_mtime_granularity(std::time::Duration::ZERO); - assert_eq!( - src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key, - [0x01u8; 16] - ); - - // Same length, same mtime, different inode. - std::fs::write(&replacement, body(&"02".repeat(16))).unwrap(); - pin_mtime(&replacement); - std::fs::rename(&replacement, &path).unwrap(); - assert_eq!( - std::fs::metadata(&path).unwrap().len(), - body(&"01".repeat(16)).len() as u64, - "the two keydbs must be the same size for this test to mean anything" - ); - - assert_eq!( - src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key, - [0x02u8; 16], - "a renamed-in keydb of the same size and mtime must not be served from the stale cache" - ); - assert_eq!(src.parse_count(), 2, "the replacement must be re-parsed"); - } - - // Discriminator 2, the settle window: a keydb rewritten IN PLACE (same inode/length, mtime - // forced back) must still be re-read while too young to trust. - #[cfg(unix)] - #[test] - fn an_in_place_rewrite_under_an_unsettled_stamp_is_still_re_read() { - use std::io::Write as _; - let dir = scratch("cache-settle"); - let path = dir.join("keydb.cfg"); - let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n"); - - std::fs::write(&path, body(&"01".repeat(16))).unwrap(); - pin_mtime(&path); - let ino_before = std::fs::metadata(&path) - .map(|m| std::os::unix::fs::MetadataExt::ino(&m)) - .unwrap(); - let src = KeydbSource::new(&path) - .with_mtime_granularity(std::time::Duration::from_secs(10 * 365 * 24 * 3600)); - assert_eq!( - src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key, - [0x01u8; 16] - ); - - // Overwrite the SAME inode in place (no truncate, no rename), same - // length, then force the mtime back: every field of the old stamp - // matches. - let mut f = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); - f.write_all(body(&"02".repeat(16)).as_bytes()).unwrap(); - f.sync_all().unwrap(); - drop(f); - pin_mtime(&path); - assert_eq!( - std::fs::metadata(&path) - .map(|m| std::os::unix::fs::MetadataExt::ino(&m)) - .unwrap(), - ino_before, - "the rewrite must reuse the inode for this test to mean anything" - ); - - assert_eq!( - src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key, - [0x02u8; 16], - "an in-place rewrite under an indistinguishable stamp must not be served from cache" - ); - } - - // A corrupt-but-parseable keydb must keep saying so on EVERY hit, not just the parse that - // produced the summary (a cache hit skips `parse`). - #[cfg(unix)] - #[test] - fn a_corrupt_keydb_warns_on_every_lookup_not_only_on_the_parse() { - let dir = scratch("cache-warn"); - let path = dir.join("keydb.cfg"); - // Parseable (one good disc row) but damaged: two rows the parser must - // reject and count. - std::fs::write( - &path, - "0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n\ - | PK | 0xnothex\n\ - | DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n", - ) - .unwrap(); - pin_mtime(&path); - - let src = KeydbSource::new(&path); - for _ in 0..3 { - assert!( - !src.get_unit_keys(&ctx(HASH, Vec::new(), None)) - .unwrap() - .is_empty(), - "the healthy row must still resolve — corruption is not a source failure here" - ); - } - assert_eq!( - src.parse_count(), - 1, - "the cache must still do its job: one parse for three lookups" - ); - assert_eq!( - src.warning_count(), - 3, - "the corruption summary must be re-emitted on every cache hit, not once per process" - ); - } - - // A HEALTHY keydb must stay silent — the re-emission above must not turn into a per-lookup - // log line for every operator with an intact file. - #[cfg(unix)] - #[test] - fn a_healthy_keydb_warns_on_no_lookup() { - let dir = scratch("cache-quiet"); - let path = dir.join("keydb.cfg"); - std::fs::write( - &path, - "0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n", - ) - .unwrap(); - pin_mtime(&path); - - let src = KeydbSource::new(&path); - for _ in 0..3 { - let _ = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap(); - } - assert_eq!( - src.warning_count(), - 0, - "an intact keydb must produce no corruption summary at all" - ); - } - - // The cost side of the settle rule, asserted as a decision: a keydb written just now is - // re-read on every lookup until quiet for the granularity. - #[test] - fn a_freshly_written_keydb_is_not_trusted_from_cache() { - let dir = scratch("cache-fresh"); - let path = dir.join("keydb.cfg"); - std::fs::write( - &path, - "0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n", - ) - .unwrap(); - - let src = KeydbSource::new(&path); - assert!( - !src.get_unit_keys(&ctx(HASH, Vec::new(), None)) - .unwrap() - .is_empty() - ); - assert!( - !src.get_unit_keys(&ctx(HASH, Vec::new(), None)) - .unwrap() - .is_empty() - ); - assert_eq!( - src.parse_count(), - 2, - "a keydb whose mtime is younger than the granularity must be re-read, not trusted" - ); - } - - // `save()` replaces the very file this source reads, so it drops the cached parse ITSELF - // rather than trusting the (size, mtime) stamp — the one change the stamp cannot see. - #[cfg(unix)] - #[test] - fn save_invalidates_the_cache_even_when_the_file_stamp_is_unchanged() { - let dir = scratch("cache-save"); - let path = dir.join("keydb.cfg"); - let k1 = "01".repeat(16); - let k2 = "02".repeat(16); - let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n"); - - std::fs::write(&path, body(&k1)).unwrap(); - pin_mtime(&path); - - let src = KeydbSource::new(&path); - let first = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap(); - assert_eq!(first[0].key, [0x01u8; 16]); - - // Same byte length, written THROUGH this source, then stamped back so - // (size, mtime) is bit-identical to what the cache recorded. - src.save(body(&k2).as_bytes()).expect("save must succeed"); - pin_mtime(&path); - assert_eq!( - std::fs::metadata(&path).unwrap().len(), - body(&k1).len() as u64, - "the two keydbs must be the same size for this test to mean anything" - ); - - let second = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap(); - assert_eq!( - second[0].key, [0x02u8; 16], - "a save through this source must invalidate its own cache, not wait for the stamp to change" - ); - } - - // A corrupt keydb is a source FAILURE, not "this disc has no key": both used to collapse - // into Ok(Vec::new()) with ZERO tracing. Only a MISSING file is genuinely benign. - #[test] - fn a_corrupt_keydb_is_an_error_not_an_empty_answer() { - let dir = scratch("corrupt"); - let path = dir.join("keydb.cfg"); - // Invalid UTF-8 (a lone continuation byte) → InvalidData from load. - std::fs::write(&path, [0x30u8, 0x78, 0xFF, 0xFE, 0x0A]).unwrap(); - - let src = KeydbSource::new(&path); - let err = src - .get_unit_keys(&ctx(HASH, Vec::new(), None)) - .expect_err("a corrupt keydb must not look like a disc with no key"); - assert_eq!( - err.code(), - libfreemkv::error::E_KEYDB_INVALID, - "an unusable keydb must report itself, not E7022" - ); - assert_ne!( - err.code(), - libfreemkv::error::E_NO_DISC_KEY, - "a corrupt keydb says NOTHING about whether this disc has a key" - ); - } - - /// The contrast case, through the same code: a keydb that simply is not - /// there stays the documented benign miss (`Ok(empty)`), so the fix above - /// cannot have turned "no keydb configured" into a hard failure. - #[test] - fn a_missing_keydb_is_still_a_benign_empty() { - let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); - assert!( - src.get_unit_keys(&ctx(HASH, Vec::new(), None)) - .expect("a missing keydb is not an error") - .is_empty() - ); - } - - // ── save / update (moved from libfreemkv::keydb) ────────────────────────── - - // Per project convention, tests never touch /tmp (wiped on reboot). Anchor - // scratch under the crate's target/ (gitignored), not /tmp. - fn scratch(tag: &str) -> std::path::PathBuf { - use std::sync::atomic::{AtomicU64, Ordering}; - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let d = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("target/test-scratch") - .join(format!("keydb-save-{}-{}-{}", std::process::id(), tag, n)); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// The headline behaviour of the move: `save` writes to the SOURCE'S OWN - /// path, never a hardcoded default. Creates the parent dir, reports the - /// destination, and round-trips the content. - #[test] - fn save_writes_to_the_sources_own_path() { - let dir = scratch("save-path"); - let target = dir.join("nested").join("mykeys.cfg"); - let src = KeydbSource::new(&target); - - let body = b"0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF = Test\n"; - let result = src.save(body).expect("save must succeed"); - - assert_eq!( - result.path, target, - "save must write to the source's own path, not a default" - ); - assert_eq!(result.entries, 1, "one 0x entry"); - assert!(target.exists(), "keydb file must exist at the source path"); - assert!( - std::fs::read_to_string(&target) - .unwrap() - .contains("0xDEADBEEF"), - "content must round-trip" - ); - } - - /// `update` runs the injected fetch, then saves the returned bytes to the - /// source's path — the transport is supplied by the caller, never built - /// here. - #[test] - fn update_uses_injected_fetch_then_saves_to_path() { - let dir = scratch("update-path"); - let target = dir.join("k.cfg"); - let src = KeydbSource::new(&target); - - let body = b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD = Test\n".to_vec(); - let result = src - .update(|_url| Ok(body.clone()), "http://example.test/keydb.zip") - .expect("update must succeed with a good fetch"); - - assert_eq!(result.path, target, "update must save to the source's path"); - assert_eq!(result.entries, 1); - assert!(target.exists()); - } - - /// A failing injected fetch propagates as-is; nothing is written. - #[test] - fn update_propagates_fetch_error_and_writes_nothing() { - let dir = scratch("update-err"); - let target = dir.join("k.cfg"); - let src = KeydbSource::new(&target); - - let result = src.update( - |_| { - Err(Error::KeydbConnect { - host: "x".to_string(), - }) - }, - "http://x/", - ); - assert!(matches!(result, Err(Error::KeydbConnect { .. }))); - assert!(!target.exists(), "a fetch failure must write no keydb"); - } - - /// `save` rejects bytes with no recognisable keydb entries. - #[test] - fn save_rejects_empty_text() { - let dir = scratch("save-empty"); - let src = KeydbSource::new(dir.join("k.cfg")); - let garbage = b"this is not a keydb\njust random text\n"; - assert!(matches!(src.save(garbage), Err(Error::KeydbInvalid))); - } - - /// DK/PK/HC rows with the RIGHT prefix but MALFORMED content (bad hex, a - /// short cert) parse to nothing, so `save` must count zero real entries and - /// reject — a prefix-only count let unparseable content persist past the - /// `entries > 0` guard. - #[test] - fn save_rejects_prefixed_but_unparseable_dk_pk_hc_rows() { - let dir = scratch("save-prefixed-junk"); - let src = KeydbSource::new(dir.join("k.cfg")); - // Each line has a valid keydb PREFIX but content no parser accepts: PK not - // 16 bytes of hex, DK's DEVICE_KEY not valid hex, HC cert far shorter than - // the 92-byte minimum. - let junk = b"| PK | 0xnothex\n\ - | DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n\ - | HC | HOST_PRIV_KEY 0x00 | HOST_CERT 0x0011\n"; - assert!( - matches!(src.save(junk), Err(Error::KeydbInvalid)), - "prefixed-but-unparseable rows must not count as entries" - ); - - // Contrast: the SAME shapes, but well-formed, ARE counted and saved. - let good = format!( - "| PK | 0x{}\n| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "11".repeat(16), - "00".repeat(20), - "00".repeat(92), - ); - let ok = src - .save(good.as_bytes()) - .expect("well-formed rows must save"); - assert_eq!( - ok.entries, 2, - "one PK + one HC row are the two real entries" - ); - } - - // A keydb.cfg over the size cap or not valid UTF-8 must surface as a - // SOURCE FAILURE (KeydbInvalid), never a silent empty result. - #[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] - fn save_recognises_gzip_magic() { - let dir = scratch("save-gz"); - let src = KeydbSource::new(dir.join("k.cfg")); - let bad_gz = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]; - match src.save(&bad_gz).unwrap_err() { - Error::KeydbParse | Error::KeydbInvalid => {} - e => panic!("wrong error kind for truncated gzip: {e:?}"), - } - } - - /// `save` recognises ZIP magic (PK\x03\x04) and routes to extract_zip; a - /// truncated zip is a parse/invalid error, never a plain-text UTF-8 error. - #[test] - fn save_recognises_zip_magic() { - let dir = scratch("save-zip"); - let src = KeydbSource::new(dir.join("k.cfg")); - let bad_zip = b"PK\x03\x04garbage that is not a real zip"; - match src.save(bad_zip).unwrap_err() { - Error::KeydbParse | Error::KeydbInvalid => {} - e => panic!("wrong error for bad zip: {e:?}"), - } - } - - // `extract_zip` on a WELL-FORMED zip with no `*.cfg`/`*.CFG` member must - // return `Error::KeydbInvalid` — never a parse error or a silent pick. - #[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 (unwritable - // parent) must clean up and surface `Error::KeydbWrite`. - #[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] - fn read_capped_to_string_enforces_size_cap() { - let too_big = vec![b'A'; (MAX_KEYDB_BYTES + 1) as usize]; - assert!(matches!( - read_capped_to_string(std::io::Cursor::new(too_big)), - Err(Error::KeydbInvalid) - )); - let at_cap = vec![b'A'; MAX_KEYDB_BYTES as usize]; - assert!(read_capped_to_string(std::io::Cursor::new(at_cap)).is_ok()); - // Non-UTF-8 is a parse error, not the size error. - assert!(matches!( - read_capped_to_string(std::io::Cursor::new(vec![0xFFu8, 0xFE])), - Err(Error::KeydbParse) - )); - } - - /// `write_atomic` replaces an existing file in place and leaves no stray - /// temp sibling. - #[test] - fn write_atomic_replaces_existing_and_leaves_no_temp() { - let dir = scratch("atomic"); - let path = dir.join("freemkv").join("keydb.cfg"); - - write_atomic(&path, "0xAAAA = old\n").unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xAAAA = old\n"); - write_atomic(&path, "0xBBBB = new\n").unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xBBBB = new\n"); - - let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap()) - .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:?}"); - } - - /// A failed write (parent path is a file → ENOTDIR on create_dir_all) leaves - /// the prior keydb intact and surfaces KeydbWrite. - #[test] - fn write_atomic_failure_preserves_prior_keydb() { - let dir = scratch("preserve"); - let good = dir.join("keydb.cfg"); - write_atomic(&good, "0xGOOD = keep\n").unwrap(); - - let doomed = good.join("freemkv").join("keydb.cfg"); - assert!(matches!( - write_atomic(&doomed, "0xBAD = partial\n"), - Err(Error::KeydbWrite { .. }) - )); - assert_eq!(std::fs::read_to_string(&good).unwrap(), "0xGOOD = keep\n"); - } - - /// Load-failure verdicts by IO kind — a keydb that can't be read must NEVER - /// masquerade as "this disc has no key". Permission-denied and corrupt/oversized - /// files surface as distinct loud errors; only a genuinely-absent file is the - /// documented benign case (online-only is a valid mode). - #[test] - fn load_failure_maps_io_kind_to_the_right_verdict() { - use std::io::{Error as IoErr, ErrorKind}; - let src = KeydbSource::new("/nonexistent/keydb.cfg"); - - // Missing file → benign None (no keydb present; not an error on its own). - assert!( - src.load_failure(&IoErr::from(ErrorKind::NotFound)) - .is_none(), - "a missing keydb is the benign online-only case" - ); - // Permission denied → LOUD KeydbLoad, never a silent no-key. - assert!( - matches!( - src.load_failure(&IoErr::from(ErrorKind::PermissionDenied)), - Some(Error::KeydbLoad { .. }) - ), - "no read permission must surface as KeydbLoad, not 'no key'" - ); - // Over the size cap / non-UTF8 → LOUD KeydbInvalid (corrupt/truncated). - assert!( - matches!( - src.load_failure(&IoErr::from(ErrorKind::InvalidData)), - Some(Error::KeydbInvalid) - ), - "an unusable keydb must surface as KeydbInvalid" - ); - } -} +#[path = "keydb_tests.rs"] +mod tests; diff --git a/src/keydb_format.rs b/src/keydb_format.rs index 72e34a5..8ff629d 100644 --- a/src/keydb_format.rs +++ b/src/keydb_format.rs @@ -865,1624 +865,5 @@ impl KeyDb { } #[cfg(test)] -mod tests { - use super::*; - - // `load` must enforce `MAX_KEYDB_BYTES` through a bounded read, not a stat - // (a prior metadata-based cap was skippable via a FIFO/char device - // reporting len 0). Asserts both edges of the boundary. - #[test] - fn load_enforces_the_size_cap_at_both_edges() { - let dir = std::env::temp_dir() - .join("fmkv-keydb-cap") - .join(format!("{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - - // Exactly at the cap is accepted. Use a comment line so `parse` is happy - // and the content is valid UTF-8 of a known length. - let at = dir.join("at_cap.cfg"); - let body = vec![b'#'; MAX_KEYDB_BYTES as usize]; - std::fs::write(&at, &body).unwrap(); - assert!( - KeyDb::load(&at).is_ok(), - "a file exactly at MAX_KEYDB_BYTES must load" - ); - - // One byte over is rejected as InvalidData, not truncated and not OOM. - let over = dir.join("over_cap.cfg"); - let mut body = vec![b'#'; MAX_KEYDB_BYTES as usize]; - body.push(b'#'); - std::fs::write(&over, &body).unwrap(); - let err = KeyDb::load(&over).expect_err("over-cap file must be rejected"); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - - std::fs::remove_dir_all(&dir).ok(); - } - - // `to_keydb_cfg` is the exact inverse of `parse`: parse a known line set, - // serialize, re-parse, and every field survives (keys, host cert, and the - // MKBv/UHD comment metadata). - #[test] - fn to_keydb_cfg_round_trips_through_parse() { - let h = |b: u8, n: usize| format!("{b:02x}").repeat(n); - let cert = h(0x99, 92); // AACS 1.0 host cert is 92 bytes - let src = format!( - "| HC | HOST_PRIV_KEY 0x{priv20} | HOST_CERT 0x{cert} ; Revoked in MKBv72\n\ - | DK | DEVICE_KEY 0x{k16} | DEVICE_NODE 0x0a00 | KEY_UV 0x00000e23 | KEY_U_MASK_SHIFT 0x0b\n\ - | PK | 0x{pk16}\n\ - 0x{hash20} = TestDisc | M | 0x{mk16} | I | 0x{id16} | V | 0x{vuk16} | U | 1-0x{u1} 2-0x{u2} ; MKBv76 VolumeSize: 81309007872 (UHD)\n", - hash20 = h(0xab, 20), - priv20 = h(0x88, 20), - cert = cert, - k16 = h(0x66, 16), - pk16 = h(0x77, 16), - mk16 = h(0x11, 16), - id16 = h(0x22, 16), - vuk16 = h(0x33, 16), - u1 = h(0x44, 16), - u2 = h(0x55, 16), - ); - let a = KeyDb::parse(&src); - let b = KeyDb::parse(&a.to_keydb_cfg()); - - // Per-disc entry: every field round-trips. - assert_eq!(a.disc_entries.len(), 1); - assert_eq!(b.disc_entries.len(), 1); - let ea = a.disc_entries.values().next().unwrap(); - let eb = b.disc_entries.values().next().unwrap(); - assert_eq!(ea.disc_hash, eb.disc_hash); - assert_eq!(ea.title, eb.title, "title"); - assert_eq!(ea.media_key, eb.media_key, "M"); - assert_eq!(ea.vid, eb.vid, "I/vid"); - assert_eq!(ea.vuk, eb.vuk, "V"); - assert_eq!(ea.unit_keys, eb.unit_keys, "U"); - assert_eq!(ea.mkb_version, eb.mkb_version, "MKBv"); - assert_eq!(ea.is_uhd, eb.is_uhd, "UHD"); - // Concrete values (not just self-consistency). - assert_eq!(ea.vid, Some([0x22u8; 16])); - assert_eq!(ea.vuk, Some([0x33u8; 16])); - assert_eq!(ea.unit_keys, vec![(1, [0x44u8; 16]), (2, [0x55u8; 16])]); - assert_eq!(ea.mkb_version, Some(76)); - assert!(ea.is_uhd); - - // Device key, processing key, host cert all survive byte-for-byte. - assert_eq!(a.device_keys.len(), b.device_keys.len()); - assert_eq!(a.device_keys[0].key, b.device_keys[0].key); - assert_eq!(a.device_keys[0].node, b.device_keys[0].node); - assert_eq!(a.device_keys[0].uv, b.device_keys[0].uv); - assert_eq!(a.device_keys[0].u_mask_shift, b.device_keys[0].u_mask_shift); - assert_eq!(a.processing_keys, b.processing_keys); - assert_eq!(a.host_certs.len(), 1); - assert_eq!(b.host_certs.len(), 1); - assert_eq!( - a.host_certs[0].cert.private_key, - b.host_certs[0].cert.private_key - ); - assert_eq!( - a.host_certs[0].cert.certificate, - b.host_certs[0].cert.certificate - ); - assert_eq!( - a.host_certs[0].revoked_at_mkb, - b.host_certs[0].revoked_at_mkb - ); - assert_eq!(b.host_certs[0].revoked_at_mkb, Some(72)); - } - - // REAL-DATA IDEMPOTENCE: parse → serialize (S1) → parse → serialize (S2); S1 must equal S2 - // byte-for-byte. REQUIRES a real keydb (KEYDB_PATH env); `#[ignore]` by default. - #[test] - #[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] - fn to_keydb_cfg_is_idempotent_on_real_keydb() { - let path = real_keydb_path(); - let db1 = KeyDb::load(&path).unwrap(); - let s1 = db1.to_keydb_cfg(); - let db2 = KeyDb::parse(&s1); - let s2 = db2.to_keydb_cfg(); - assert_eq!(s1.len(), s2.len(), "serialized byte length drifted"); - assert!(s1 == s2, "to_keydb_cfg is NOT idempotent (S1 != S2)"); - // No rows lost crossing the round trip. - assert_eq!( - db1.disc_entries.len(), - db2.disc_entries.len(), - "disc-entry count drift" - ); - assert_eq!(db1.device_keys.len(), db2.device_keys.len(), "DK drift"); - assert_eq!( - db1.processing_keys.len(), - db2.processing_keys.len(), - "PK drift" - ); - assert_eq!(db1.host_certs.len(), db2.host_certs.len(), "HC drift"); - } - - // REAL-SCALE parser health: a genuine published keydb must parse with ZERO - // rejected rows — the assertion the rejection counter exists to make - // possible. `#[ignore]` by default; run with KEYDB_PATH set. - #[test] - #[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] - fn a_real_keydb_parses_with_no_rejected_rows() { - let path = real_keydb_path(); - let text = std::fs::read_to_string(&path).expect("keydb must be readable"); - let (db, stats) = KeyDb::parse_counted(&text); - assert!( - db.disc_entries.len() > 170_000, - "expected a full-size keydb, got {} entries", - db.disc_entries.len() - ); - assert_eq!( - stats.rejected(), - 0, - "a healthy keydb must not lose rows to the parser: {stats:?}" - ); - assert_eq!( - stats.disc_duplicate, 0, - "duplicate disc hashes make one row's keys unreachable: {stats:?}" - ); - } - - /// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found. - fn keydb_path() -> Option { - let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?); - if path.exists() { Some(path) } else { None } - } - - // The real keydb for the `#[ignore]`d real-scale tests, or a LOUD failure. - fn real_keydb_path() -> std::path::PathBuf { - keydb_path().expect( - "KEYDB_PATH must point at a real keydb.cfg; run: \ - KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored", - ) - } - - #[test] - fn test_parse_disc_entry() { - // Synthetic placeholders, DISTINCT per field so a field mix-up (M read - // as V, I as U, …) is caught — no real key material in code. - let z40 = "00".repeat(20); - let mk = "11".repeat(16); - let id = "22".repeat(16); - let vuk = "33".repeat(16); - let uk = "44".repeat(16); - let line = format!( - "0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{mk} | I | 0x{id} | V | 0x{vuk} | U | 1-0x{uk} ; MKBv77" - ); - let entry = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim - assert_eq!(entry.media_key, Some([0x11u8; 16]), "M field"); - assert_eq!(entry.vid, Some([0x22u8; 16]), "I field"); - assert_eq!(entry.vuk, Some([0x33u8; 16]), "V field"); - assert_eq!(entry.unit_keys, vec![(1u32, [0x44u8; 16])], "U field"); - assert_eq!(entry.mkb_version, Some(77)); - } - - #[test] - fn disc_entry_strips_a_comment_glued_to_a_trailing_v_field() { - // A row whose LAST field is V (no U after it) with the `; MKBv…` - // comment glued onto the hex value; before M/I/V stripped it too, - // parse_hex16 silently dropped the VUK. - let z40 = "00".repeat(20); - let z32 = "00".repeat(16); - let line = - format!("0x{z40} = SAMPLE (Sample) | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} ; MKBv64"); - let entry = KeyDb::parse_disc_entry(&line).unwrap(); - assert!(entry.media_key.is_some(), "M key must survive"); - assert!( - entry.vuk.is_some(), - "a VUK with a trailing ; comment must not be dropped" - ); - } - - // NOTE: key fields below use obvious repeated-byte / zero placeholders. - // NEVER put real — or real-looking — key material in code; these tests - // exercise the parser's field-splitting only. - - #[test] - fn test_parse_device_key() { - let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48"; - let dk = KeyDb::parse_device_key(line).unwrap(); - assert_eq!(dk.node, 0x0800); - assert_eq!(dk.u_mask_shift, 0x17); - } - - #[test] - fn test_orphan_dk_row_loads_into_processing_keys() { - // `| DK |` row without position fields = an orphan DK; it lands in - // `processing_keys` and the brute walker handles it. - let cfg = r#" -| DK | DEVICE_KEY 0x01010101010101010101010101010101 ; orphan, no position fields -| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48 -| PK | 0x03030303030303030303030303030303 ; legacy PK row still works -"#; - let db = KeyDb::parse(cfg); - assert_eq!( - db.device_keys.len(), - 1, - "positioned DK row should land in device_keys" - ); - // Orphan DK + legacy PK row both end up in processing_keys. - assert_eq!( - db.processing_keys.len(), - 2, - "orphan DK row + legacy PK row both belong in processing_keys" - ); - assert_eq!(db.processing_keys[0][..4], [0x01, 0x01, 0x01, 0x01]); - assert_eq!(db.processing_keys[1][..4], [0x03, 0x03, 0x03, 0x03]); - } - - #[test] - fn test_parse_orphan_dk_rejects_lines_with_position_fields() { - // The parser must NOT pick up a positioned DK row as an orphan - // (that would double-count). parse_orphan_dk explicitly checks. - let positioned = "| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17"; - assert!( - KeyDb::parse_orphan_dk(positioned).is_none(), - "positioned DK must not match orphan parser" - ); - let orphan = "| DK | DEVICE_KEY 0x01010101010101010101010101010101"; - let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse"); - assert_eq!(key[..4], [0x01, 0x01, 0x01, 0x01]); - } - - #[test] - fn test_parse_host_cert() { - // 20-byte priv + 92-byte cert, all zeros — placeholders, not a key. - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked", - "00".repeat(20), - "00".repeat(92) - ); - let hc = KeyDb::parse_host_cert(&line).unwrap(); - assert_eq!(hc.cert.private_key, [0u8; 20]); - assert_eq!(hc.cert.certificate.len(), 92); - } - - #[test] - fn test_parse_hex_rejects_non_ascii_without_panic() { - // A 4-byte UTF-8 scalar has byte-len 4 (passes the even check); the - // old &str-slice path panicked on the mid-codepoint boundary. The - // byte-wise parser must instead return None. - assert!(parse_hex("😀").is_none()); - // Mixed: leading hex then a 2-byte UTF-8 scalar (byte-len even). - assert!(parse_hex("ABé").is_none()); - // Sanity: well-formed hex still parses. - assert_eq!(parse_hex("0x00FF"), Some(vec![0x00, 0xFF])); - // Odd byte length still rejected. - assert!(parse_hex("ABC").is_none()); - } - - #[test] - fn test_hc2_before_hc_is_not_dropped() { - // An HC2 row appearing before any HC row must still land its AACS 2.0 - // credentials on a HostCert rather than being silently discarded. - let cfg = format!( - "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(32), - "00".repeat(132) - ); - let db = KeyDb::parse(&cfg); - assert_eq!( - db.host_certs.len(), - 1, - "HC2-only row must create a HostCert" - ); - assert!(db.host_certs[0].cert.private_key_v2.is_some()); - assert!(db.host_certs[0].cert.certificate_v2.is_some()); - assert!( - db.host_certs[0].cert.certificate.is_empty(), - "v1 cert stays empty for an HC2-only carrier" - ); - } - - #[test] - fn test_hc2_after_hc_augments_existing() { - let cfg = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(20), - "00".repeat(92), - "00".repeat(32), - "00".repeat(132) - ); - let db = KeyDb::parse(&cfg); - assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC"); - assert_eq!(db.host_certs[0].cert.certificate.len(), 92); - assert!(db.host_certs[0].cert.certificate_v2.is_some()); - } - - #[test] - fn test_parse_host_cert_rejects_short_v1_cert() { - // A too-short AACS 1.0 cert must be dropped at parse time. - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", - "00".repeat(20), - "00".repeat(10) - ); - assert!(KeyDb::parse_host_cert(&line).is_none()); - } - - /// REQUIRES a real keydb; `#[ignore]` by default. Run with: - /// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`. - #[test] - #[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] - fn test_parse_full_keydb() { - let path = real_keydb_path(); - - let db = KeyDb::load(&path).unwrap(); - - assert_eq!(db.device_keys.len(), 4); - assert_eq!(db.processing_keys.len(), 3); - assert!(!db.host_certs.is_empty()); - assert!(db.disc_entries.len() > 170000); - - // Look up any disc entry carrying a full key set. - let entry = db - .disc_entries - .values() - .find(|e| e.vuk.is_some() && e.media_key.is_some() && !e.unit_keys.is_empty()) - .expect("no disc entry with a full key set"); - assert!(entry.media_key.is_some()); - assert!(entry.vuk.is_some()); - assert!(!entry.unit_keys.is_empty()); - - eprintln!( - "Parsed {} disc entries, {} DK, {} PK", - db.disc_entries.len(), - db.device_keys.len(), - db.processing_keys.len() - ); - } - - // `load_counted` must read the HANDLE it was given, never re-resolve the path (identity - // stamp + bytes must match). - #[cfg(unix)] - #[test] - fn load_counted_reads_the_handle_not_the_path() { - let dir = std::env::temp_dir().join(format!( - "fmk-keysources-load-seam-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("keydb.cfg"); - let other = dir.join("replacement.cfg"); - let row = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n"); - std::fs::write(&path, row(&"01".repeat(16))).unwrap(); - std::fs::write(&other, row(&"02".repeat(16))).unwrap(); - - let handle = std::fs::File::open(&path).unwrap(); - // The path now names a DIFFERENT inode; the handle still names the old - // one, exactly as it would mid-race. - std::fs::rename(&other, &path).unwrap(); - - let (db, _) = KeyDb::load_counted(handle, &path).expect("the open handle is still valid"); - assert_eq!( - db.disc_entries - .values() - .next() - .expect("one entry") - .unit_keys[0] - .1, - [0x01u8; 16], - "the parse must come from the handle that was stamped, not from a re-opened path" - ); - - std::fs::remove_dir_all(&dir).ok(); - } - - // ── Debug redaction: key material must never reach a log or a panic ──── - - /// The only key bytes in these fixtures are the byte 0x5A repeated; the - /// assertion is that NO run of them survives into the formatted string, in - /// either the hex or the Rust-array rendering a derived `Debug` produces. - fn assert_no_key_bytes(what: &str, rendered: &str) { - assert!( - !rendered.contains("90"), - "{what} Debug leaked a decimal key byte: {rendered}" - ); - assert!( - !rendered.contains("5a") && !rendered.contains("5A"), - "{what} Debug leaked a hex key byte: {rendered}" - ); - // A derived `Debug` over a byte array renders `[90, 90, ...]`; matched - // as "`[` immediately followed by a digit" rather than "any `[`", - // since callers legitimately render a `Vec` of redacting structs. - let bytes = rendered.as_bytes(); - assert!( - !rendered - .match_indices('[') - .any(|(i, _)| bytes.get(i + 1).is_some_and(u8::is_ascii_digit)), - "{what} Debug rendered a raw byte array: {rendered}" - ); - } - - // `{:?}` on a `DiscEntry` must not print the Media Key, VID, VUK or any - // Unit Key. Catches the mutation that restores `#[derive(Debug)]`. - #[test] - fn disc_entry_debug_is_redacted() { - let e = DiscEntry { - disc_hash: Arc::from("0xaabb"), - title: "T".into(), - media_key: Some([0x5A; 16]), - vid: Some([0x5A; 16]), - vuk: Some([0x5A; 16]), - unit_keys: vec![(1, [0x5A; 16])], - mkb_version: Some(76), - volume_size: Some(1), - is_uhd: true, - }; - let rendered = format!("{e:?}"); - assert_no_key_bytes("DiscEntry", &rendered); - // The non-secret identifying fields are still useful and still printed. - assert!(rendered.contains("0xaabb"), "{rendered}"); - assert!(rendered.contains("unit_keys_len"), "{rendered}"); - } - - /// `{:?}` on a whole `KeyDb` must print COUNTS only. The derive dumped every - /// processing key plus all ~181k disc entries' key material — the single - /// worst leak in the crate, since a `KeyDb` is what a key source holds. - #[test] - fn keydb_debug_is_redacted() { - let mut db = KeyDb::empty(); - db.processing_keys.push([0x5A; 16]); - db.device_keys.push(DeviceKey { - key: [0x5A; 16], - node: 1, - uv: 2, - u_mask_shift: 3, - }); - db.host_certs.push(KeydbHostCert { - cert: HostCert { - private_key: [0x5A; 20], - certificate: vec![0x5A; 92], - private_key_v2: None, - certificate_v2: None, - }, - revoked_at_mkb: None, - }); - let e = DiscEntry { - disc_hash: Arc::from("0xaabb"), - title: "T".into(), - media_key: Some([0x5A; 16]), - vid: None, - vuk: Some([0x5A; 16]), - unit_keys: vec![(1, [0x5A; 16])], - mkb_version: None, - volume_size: None, - is_uhd: false, - }; - db.disc_entries.insert(e.disc_hash.clone(), e); - - let rendered = format!("{db:?}"); - assert_no_key_bytes("KeyDb", &rendered); - assert!( - rendered.contains("disc_entries_len"), - "counts, not contents: {rendered}" - ); - assert!( - rendered.contains("processing_keys_len"), - "counts, not contents: {rendered}" - ); - } - - // The SIBLING type: `KeyDb`'s hand-written `Debug` protects the whole-db rendering only; - // `{:?}` on one `KeydbHostCert` element walks a different path carrying the AACS host - // PRIVATE key. - #[test] - fn host_cert_debug_is_redacted_including_the_sibling_wrapper() { - let hc = KeydbHostCert { - cert: HostCert { - private_key: [0x5A; 20], - certificate: vec![0x5A; 92], - private_key_v2: Some([0x5A; 32]), - certificate_v2: Some(vec![0x5A; 132]), - }, - revoked_at_mkb: Some(76), - }; - assert_no_key_bytes("KeydbHostCert", &format!("{hc:?}")); - assert_no_key_bytes("Vec", &format!("{:?}", vec![hc.clone()])); - assert_no_key_bytes("HostCert", &format!("{:?}", hc.cert)); - // Positive half: the private keys must be present-but-masked, not - // omitted. A `Debug` that simply dropped the fields would satisfy the - // negative assertions above while hiding that a key is held at all. - assert_eq!( - format!("{hc:?}").matches("").count(), - 2, - "both host private keys (1.0 and 2.0) must render as : {hc:?}" - ); - // The non-secret revocation field is what the wrapper exists for and is - // still printed — redaction must not become "print nothing useful". - assert!( - format!("{hc:?}").contains("76"), - "the revocation generation is not secret and must survive: {hc:?}" - ); - } - - /// The `Vec` on the other public `KeyDb` field, for the same - /// reason: `device_keys` is reachable element-wise and each entry holds a - /// 16-byte device key. Also inherited from libfreemkv's redacting impl. - #[test] - fn device_key_debug_is_redacted() { - let dk = DeviceKey { - key: [0x5A; 16], - node: 1, - uv: 2, - u_mask_shift: 3, - }; - assert_no_key_bytes("DeviceKey", &format!("{dk:?}")); - assert_no_key_bytes("Vec", &format!("{:?}", vec![dk])); - } - - // ── Parser rejection accounting ── every malformed row must be COUNTED, - // per reason. Catches restoring a bare `if let Some(x) = .. { }` with no - // `else`, which let a byte-shifted download resolve nothing with zero log. - #[test] - fn parse_counts_every_rejected_row_by_reason() { - let good_pk = format!("| PK | 0x{}", "ab".repeat(16)); - let cfg = format!( - "{good_pk}\n\ - | DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n\ - | PK | 0xnothex\n\ - | HC | HOST_PRIV_KEY 0x00 | HOST_CERT 0x00\n\ - | HC2 | HOST_PRIV_KEY 0x00 | HOST_CERT 0x00\n\ - 0xdeadbeef and no equals sign here\n" - ); - let (db, stats) = KeyDb::parse_counted(&cfg); - assert_eq!(stats.dk_rejected, 1, "the bad DK row must be counted"); - assert_eq!(stats.pk_rejected, 1, "the bad PK row must be counted"); - assert_eq!(stats.hc_rejected, 1, "the short HC row must be counted"); - assert_eq!(stats.hc2_rejected, 1, "the short HC2 row must be counted"); - // A `0x…` row missing " = " is malformed (a hash the user sees but that - // never resolves); it is COUNTED now, not silently ignored — exactly the - // "hash in the file but nothing resolves" bug class. - assert_eq!( - stats.disc_rejected, 1, - "a prefixed row missing \" = \" is counted" - ); - assert_eq!(stats.rejected(), 5, "five rows rejected in total"); - // The one GOOD row still loaded — counting must not change parsing. - assert_eq!(db.processing_keys, vec![[0xABu8; 16]]); - } - - /// A duplicated disc hash silently last-wins through `HashMap::insert`: - /// one of the two rows' keys becomes unreachable. Not necessarily - /// corruption, but never something to discover silently. - #[test] - fn parse_counts_duplicate_disc_hashes() { - let k1 = "01".repeat(16); - let k2 = "02".repeat(16); - let cfg = format!("0xAAAA = T | U | 1-0x{k1}\n0xaaaa = T | U | 1-0x{k2}\n"); - let (db, stats) = KeyDb::parse_counted(&cfg); - assert_eq!(db.disc_entries.len(), 1, "same hash ⇒ one entry survives"); - assert_eq!( - stats.disc_duplicate, 1, - "the overwritten row must be counted, not vanish" - ); - // Last wins — pinned so the counter documents the ACTUAL behaviour. - assert_eq!(db.get_uk("0xaaaa"), vec![(1, [0x02u8; 16])]); - } - - /// A keydb is a database: EVERY disc row must load, with no count-based cap - /// silently dropping entries. Regression guard for the removed - /// `MAX_DISC_ENTRIES` cap — a disc verifiably in the file must resolve. - #[test] - fn parse_keeps_every_disc_row_no_entry_cap() { - let k = "01".repeat(16); - let n = 600_000; // comfortably past the old 500k cap - let mut cfg = String::with_capacity(n * 16); - for i in 0..n { - cfg.push_str(&format!("0x{i:x} = T | U | 1-0x{k}\n")); - } - let (db, stats) = KeyDb::parse_counted(&cfg); - assert_eq!(db.disc_entries.len(), n, "every disc row must be kept"); - assert_eq!(stats.rejected(), 0, "no row dropped"); - } - - // ── whole-flow robustness: every way a row a user can SEE in the file could - // silently fail to load. Each is a regression guard for a real gap. ── - - /// A minimal, well-formed disc row for `hash` (lowercase hex, no 0x). - fn disc_line(hash: &str) -> String { - let uk = "01".repeat(16); - let vuk = "02".repeat(16); - format!("0x{hash} = TITLE | V | 0x{vuk} | U | 1-0x{uk}") - } - - /// A UTF-8 BOM on the first line must not eat the first disc row. - #[test] - fn parse_strips_leading_bom_first_row_survives() { - let h = "aa".repeat(20); - let cfg = format!("\u{feff}{}\n", disc_line(&h)); - let (db, stats) = KeyDb::parse_counted(&cfg); - assert_eq!(db.disc_entries.len(), 1, "BOM must not drop the first row"); - assert!(db.find_disc(&format!("0x{h}")).is_some()); - assert_eq!(stats.rejected(), 0); - } - - /// CRLF (`\r\n`) line endings must load — `.lines()` + `trim()` handle the `\r`. - #[test] - fn parse_handles_crlf_line_endings() { - let h = "bb".repeat(20); - let cfg = format!("{}\r\n{}\r\n", disc_line(&h), disc_line(&"cc".repeat(20))); - let (db, _) = KeyDb::parse_counted(&cfg); - assert_eq!(db.disc_entries.len(), 2); - assert!(db.find_disc(&format!("0x{h}")).is_some()); - } - - /// Leading whitespace on a disc row must not drop it (trim covers it). - #[test] - fn parse_tolerates_leading_whitespace() { - let h = "dd".repeat(20); - let cfg = format!(" \t{}\n", disc_line(&h)); - let (db, _) = KeyDb::parse_counted(&cfg); - assert!(db.find_disc(&format!("0x{h}")).is_some()); - } - - /// An uppercase `0X` prefix AND uppercase hash must load and be findable by - /// any case / prefix form (the lookup normalizes). - #[test] - fn parse_and_lookup_are_case_and_prefix_insensitive() { - let cfg = "0XABCDEF0123456789ABCDEF0123456789ABCDEF01 = T | V | 0x0202020202020202020202020202020202 | U | 1-0x0101010101010101010101010101010101\n" - .replace("0202020202020202020202020202020202", &"02".repeat(16)) - .replace("0101010101010101010101010101010101", &"01".repeat(16)); - let (db, _) = KeyDb::parse_counted(&cfg); - // uppercase-with-0x, lowercase-with-0x, and no-prefix all resolve. - assert!( - db.find_disc("0xABCDEF0123456789ABCDEF0123456789ABCDEF01") - .is_some() - ); - assert!( - db.find_disc("0xabcdef0123456789abcdef0123456789abcdef01") - .is_some() - ); - assert!( - db.find_disc("abcdef0123456789abcdef0123456789abcdef01") - .is_some() - ); - } - - /// A `0x…` row missing the `" = "` separator must be COUNTED as rejected, - /// never silently ignored — otherwise a visibly-present disc vanishes. - #[test] - fn parse_counts_a_prefixed_row_missing_the_separator() { - let h = "ee".repeat(20); - // no " = " (bare "=") — malformed for the grammar. - let cfg = format!( - "0x{h}=TITLE|V|0x{}|U|1-0x{}\n", - "02".repeat(16), - "01".repeat(16) - ); - let (db, stats) = KeyDb::parse_counted(&cfg); - assert!( - db.find_disc(&format!("0x{h}")).is_none(), - "malformed row not loaded" - ); - assert_eq!( - stats.disc_rejected, 1, - "the drop MUST be counted, not silent" - ); - assert!( - stats.log() || stats.rejected() > 0, - "a rejection is diagnosable" - ); - } - - /// Duplicate hash: last row wins AND the collision is counted (a keydb with - /// the same hash twice means one row's keys become unreachable). - #[test] - fn parse_counts_duplicate_hash_last_wins() { - let h = "ff".repeat(20); - let cfg = format!("{}\n{}\n", disc_line(&h), disc_line(&h)); - let (db, stats) = KeyDb::parse_counted(&cfg); - assert_eq!(db.disc_entries.len(), 1); - assert_eq!(stats.disc_duplicate, 1, "the overwrite must be counted"); - } - - /// A row carrying ONLY terminal unit keys (no V field) still loads and is - /// findable — VUK-less discs must resolve from their stored unit keys. - #[test] - fn parse_keeps_unit_key_only_rows() { - let h = "12".repeat(20); - let cfg = format!("0x{h} = TITLE | U | 1-0x{}\n", "01".repeat(16)); - let (db, _) = KeyDb::parse_counted(&cfg); - let e = db.find_disc(&format!("0x{h}")).expect("row loads"); - assert!(e.vuk.is_none()); - assert_eq!(e.unit_keys.len(), 1, "the stored unit key is kept"); - } - - /// A disc row rejected by the FIELD parser is counted separately from one - /// dropped by the cap. - #[test] - fn parse_counts_unparseable_disc_row() { - // Nothing in today's grammar can be a disc row AND fail - // `parse_disc_entry`, so assert the healthy case is zero, so a future - // parser change that starts rejecting rows cannot do it silently. - let k = "01".repeat(16); - let (_, stats) = KeyDb::parse_counted(&format!("0xAAAA = T | U | 1-0x{k}\n")); - assert_eq!(stats.rejected(), 0, "a healthy keydb rejects nothing"); - assert_eq!(stats.disc_duplicate, 0); - } - - // ── `0X` disc rows / the save() lockstep ── an uppercase row was once - // dropped with no diagnostic. Catches the mutation that removes the `0X` - // arm of `is_disc_entry_line`. - #[test] - fn disc_row_with_uppercase_0x_prefix_is_parsed() { - let v = "33".repeat(16); - let db = KeyDb::parse(&format!("0XABCDEF = T | V | 0x{v}\n")); - assert_eq!( - db.disc_entries.len(), - 1, - "an uppercase 0X disc row must parse, like every other hex field" - ); - // The hash is lowercased on the way in, so lookup is unaffected. - assert_eq!(db.find_vuk("0xabcdef"), Some([0x33u8; 16])); - assert!(is_disc_entry_line("0XAA = T")); - assert!(is_disc_entry_line("0xaa = T")); - // Still not a disc row without the " = " separator. - assert!(!is_disc_entry_line("0XAABBCC")); - } - - // ── One allocation per disc hash, not two ── map key and entry's own - // `disc_hash` must be the SAME allocation (used to be a clone, ~13 MB of - // duplicate text across the real keydb's 181k entries). - #[test] - fn disc_hash_is_stored_once_not_twice() { - let v = "33".repeat(16); - let db = KeyDb::parse(&format!("0xABCDEF = T | V | 0x{v}\n")); - let (key, entry) = db.disc_entries.iter().next().expect("one entry"); - assert!( - Arc::ptr_eq(key, &entry.disc_hash), - "the map key must BE the entry's disc_hash allocation, not a clone" - ); - } - - // ══ Hardening additions ══ parse_hex / parse_hex16 / parse_hex20 ══ - - #[test] - fn parse_hex_strips_lower_and_upper_prefixes() { - // Both lower- and upper-case prefixes are stripped (trim_start_matches - // "0x" then "0X"). Without one of those strips a value would be off by - // a nibble or fail length checks. - assert_eq!(parse_hex("0xABCD"), Some(vec![0xAB, 0xCD])); - assert_eq!(parse_hex("0XABCD"), Some(vec![0xAB, 0xCD])); - assert_eq!(parse_hex("ABCD"), Some(vec![0xAB, 0xCD])); - } - - #[test] - fn parse_hex_mixed_case_nibbles() { - // to_digit(16) accepts both cases. - assert_eq!(parse_hex("aB"), Some(vec![0xAB])); - assert_eq!(parse_hex("Ff00"), Some(vec![0xFF, 0x00])); - } - - #[test] - fn parse_hex_rejects_non_hex_digit() { - // 'G' is not a hex digit → None (not silently 0). - assert!(parse_hex("0xGG").is_none()); - assert!(parse_hex("12ZZ").is_none()); - } - - #[test] - fn parse_hex_empty_is_empty_vec() { - // Empty (or bare "0x") → Some(empty): even byte-length 0 passes, and - // there are no nibbles to reject. parse_hex16/20 then reject on length. - assert_eq!(parse_hex(""), Some(vec![])); - assert_eq!(parse_hex("0x"), Some(vec![])); - } - - #[test] - fn parse_hex16_enforces_exactly_16_bytes() { - assert!(parse_hex16(&format!("0x{}", "00".repeat(15))).is_none()); - assert!(parse_hex16(&format!("0x{}", "00".repeat(17))).is_none()); - assert_eq!( - parse_hex16(&format!("0x{}", "00".repeat(16))), - Some([0u8; 16]) - ); - } - - #[test] - fn parse_hex20_enforces_exactly_20_bytes() { - assert!(parse_hex20(&format!("0x{}", "00".repeat(19))).is_none()); - assert_eq!( - parse_hex20(&format!("0x{}", "11".repeat(20))), - Some([0x11u8; 20]) - ); - } - - // ── Disc entry field parsing ─────────────────────────────────────────── - - #[test] - fn disc_entry_hash_is_lowercased() { - // The disc_hash key is lowercased so HashMap lookups are - // case-insensitive (find_disc lowercases its query too). - let z32 = "00".repeat(16); - let line = format!("0xABCDEF = T | M | 0x{z32}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(&*e.disc_hash, "0xabcdef"); - } - - #[test] - fn disc_entry_title_kept_verbatim_even_with_parens() { - // Faithful copy: the title is kept VERBATIM, parens and all — NOT reduced - // to the parenthesised substring (which truncated real multi-paren titles - // and broke serialize→parse idempotence). - let line = "0x00 = RAW_NAME (Display Name) | M | 0x".to_string() + &"00".repeat(16); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.title, "RAW_NAME (Display Name)"); - } - - #[test] - fn disc_entry_title_without_parens_uses_whole() { - let line = "0x00 = PlainTitle | M | 0x".to_string() + &"00".repeat(16); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.title, "PlainTitle"); - } - - #[test] - fn disc_entry_malformed_parens_falls_back_to_whole_title() { - // The title is kept verbatim regardless of paren placement — a malformed - // ')' before '(' is not special-cased; the whole string is the title. - let line = "0x00 = FILM) (X | M | 0x".to_string() + &"00".repeat(16); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.title, "FILM) (X"); - } - - #[test] - fn disc_entry_parses_all_tagged_fields() { - // M, I, V, U each populate their field. U accepts "n-0xKEY". - let m = "11".repeat(16); - let i = "22".repeat(16); - let v = "33".repeat(16); - let u = "44".repeat(16); - let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.media_key, Some([0x11u8; 16])); - assert_eq!(e.vid, Some([0x22u8; 16])); - assert_eq!(e.vuk, Some([0x33u8; 16])); - assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]); - } - - #[test] - fn disc_entry_multiple_unit_keys_space_separated() { - // The U field carries space-separated "n-0xKEY" pairs. - let k1 = "01".repeat(16); - let k2 = "02".repeat(16); - let line = format!("0xAA = T | U | 1-0x{k1} 2-0x{k2}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.unit_keys, vec![(1, [0x01u8; 16]), (2, [0x02u8; 16])]); - } - - #[test] - fn disc_entry_unit_key_strips_trailing_comment() { - // "U | 1-0xKEY ; comment" — the ';' comment must be stripped before - // splitting unit keys. - let k = "05".repeat(16); - let line = format!("0xAA = T | U | 1-0x{k} ; MKBv77 note"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.unit_keys, vec![(1, [0x05u8; 16])]); - } - - #[test] - fn disc_entry_skips_unparseable_unit_key_pair() { - // A bad nibble in one unit key drops just that pair (parse_hex16 → - // None), keeping the valid ones — no panic, no half-garbage key. - let good = "07".repeat(16); - let line = format!("0xAA = T | U | 1-0xZZ 2-0x{good}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.unit_keys, vec![(2, [0x07u8; 16])]); - } - - #[test] - fn disc_entry_field_with_short_hex_is_none_not_panic() { - // A 30-hex-char (15-byte) M value fails parse_hex16 → media_key None. - let short = "00".repeat(15); - let line = format!("0xAA = T | M | 0x{short}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert!(e.media_key.is_none()); - } - - // ── find_disc / find_vuk: prefix-agnostic lookup ─────────────────────── - - #[test] - fn find_disc_matches_with_and_without_0x_and_case() { - let v = "33".repeat(16); - let line = format!("0xABCDEF = T | V | 0x{v}"); - let db = KeyDb::parse(&line); - // Stored key is "0xabcdef". Query in several shapes. - assert!(db.find_disc("0xABCDEF").is_some()); - assert!(db.find_disc("ABCDEF").is_some()); // no prefix - assert!(db.find_disc("0xabcdef").is_some()); - assert!(db.find_disc(" 0xAbCdEf ").is_some()); // padded + mixed case - assert_eq!(db.find_vuk("ABCDEF"), Some([0x33u8; 16])); - assert!(db.find_disc("0xDEADBE").is_none()); - } - - // ── Comments / blank lines / unknown lines ───────────────────────────── - - #[test] - fn parse_ignores_comments_and_blank_lines() { - let cfg = "\n; a comment\n# another\n \n"; - let db = KeyDb::parse(cfg); - assert!(db.device_keys.is_empty()); - assert!(db.processing_keys.is_empty()); - assert!(db.disc_entries.is_empty()); - assert!(db.host_certs.is_empty()); - } - - #[test] - fn parse_empty_or_keyless_file_is_lenient_not_error() { - // parse() never errors; a keyless file is an empty KeyDb (documented - // contract — load() errors only on read failure, not empty content). - let db = KeyDb::parse("; nothing here\n"); - assert_eq!(db.disc_entries.len(), 0); - } - - #[test] - fn parse_device_key_requires_all_four_fields() { - // Missing KEY_U_MASK_SHIFT → parse_device_key returns None; it has - // DEVICE_NODE + KEY_UV so parse_orphan_dk also rejects it (has - // position fields), so nothing is loaded. - let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400"; - assert!(KeyDb::parse_device_key(line).is_none()); - let db = KeyDb::parse(line); - assert!(db.device_keys.is_empty()); - assert!(db.processing_keys.is_empty()); - } - - #[test] - fn parse_device_key_accepts_uppercase_0x_prefix() { - // Regression: a case-sensitive `trim_start_matches("0x")` left an - // uppercase `0X` prefix unstripped, so `from_str_radix` failed and - // the WHOLE device key was silently dropped. - let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F \ - | DEVICE_NODE 0X0001 | KEY_UV 0X00000002 | KEY_U_MASK_SHIFT 0X03"; - let dk = KeyDb::parse_device_key(line).expect("uppercase 0X prefix must parse"); - assert_eq!(dk.node, 1); - assert_eq!(dk.uv, 2); - assert_eq!(dk.u_mask_shift, 3); - } - - #[test] - fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() { - // v2 priv must be exactly 32 bytes; cert must be >= 132. - let bad_priv = format!( - "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", - "00".repeat(31), - "00".repeat(132) - ); - assert!(KeyDb::parse_host_cert_v2(&bad_priv).is_none()); - let short_cert = format!( - "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", - "00".repeat(32), - "00".repeat(131) - ); - assert!(KeyDb::parse_host_cert_v2(&short_cert).is_none()); - } - - #[test] - fn parse_processing_key_pk_row() { - // "| PK | 0x..." → 16-byte processing key. A trailing comment is - // stripped at ';'. - let line = format!("| PK | 0x{} ; MKBv64", "AB".repeat(16)); - let pk = KeyDb::parse_processing_key(&line).unwrap(); - 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] - fn disc_entry_comment_uhd_mkb_and_volume_size() { - // Canonical UHD comment grammar. - let z = "00".repeat(16); - let line = format!( - "0xAA = T | M | 0x{z} | U | 1-0x{z} ; MKBv76/BEE/FindVUK 1.74 - VolumeSize: 81309007872 (UHD)" - ); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.mkb_version, Some(76)); - assert_eq!(e.volume_size, Some(81_309_007_872)); - assert!(e.is_uhd); - } - - #[test] - fn disc_entry_comment_bd_is_not_uhd() { - // "(BD)" comment ⇒ is_uhd false, VolumeSize still parsed. - let z = "00".repeat(16); - let line = - format!("0xAA = T | M | 0x{z} ; MKBv68/FindVUK 1.24 - VolumeSize: 37672976384 (BD)"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert!(!e.is_uhd); - assert_eq!(e.volume_size, Some(37_672_976_384)); - assert_eq!(e.mkb_version, Some(68)); - } - - #[test] - fn disc_entry_no_comment_all_metadata_none_and_fields_still_parse() { - // Regression: with NO trailing comment the three new fields default to - // None/false AND the U/M/I/V fields still parse correctly. - let m = "11".repeat(16); - let i = "22".repeat(16); - let v = "33".repeat(16); - let u = "44".repeat(16); - let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.mkb_version, None); - assert_eq!(e.volume_size, None); - assert!(!e.is_uhd); - // Unchanged field parsing. - assert_eq!(e.media_key, Some([0x11u8; 16])); - assert_eq!(e.vid, Some([0x22u8; 16])); - assert_eq!(e.vuk, Some([0x33u8; 16])); - assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]); - } - - // ── Host-cert revocation: parse + host_certs(mkb) filter ──────────────── - - #[test] - #[allow(deprecated)] - fn host_cert_revoked_parses_and_filters_by_mkb() { - let revoked_line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked in MKBv72", - "00".repeat(20), - "11".repeat(92), - ); - let hc = KeyDb::parse_host_cert(&revoked_line).unwrap(); - assert_eq!(hc.revoked_at_mkb, Some(72)); - - let db = KeyDb::parse(&revoked_line); - assert_eq!(db.host_certs.len(), 1); - // Revoked in MKBv72 ⇒ unusable at gen >= 72, usable below it. - assert!( - db.host_certs(Some(72)).is_empty(), - "a cert revoked in MKBv72 must be excluded at gen 72" - ); - assert_eq!( - db.host_certs(Some(71)).len(), - 1, - "still usable at gen 71 (below the revocation generation)" - ); - assert_eq!( - db.host_certs(None).len(), - 1, - "unknown disc MKB ⇒ cannot filter ⇒ cert returned" - ); - } - - #[test] - #[allow(deprecated)] - fn host_cert_without_revocation_included_for_all_mkb() { - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", - "00".repeat(20), - "22".repeat(92), - ); - let hc = KeyDb::parse_host_cert(&line).unwrap(); - assert_eq!(hc.revoked_at_mkb, None); - - let db = KeyDb::parse(&line); - assert_eq!(db.host_certs(Some(99)).len(), 1); - assert_eq!(db.host_certs(Some(1)).len(), 1); - assert_eq!(db.host_certs(None).len(), 1); - } - - #[test] - #[allow(deprecated)] - fn hc2_revocation_propagates_when_hc_has_none() { - // The HC line carries no annotation; the revocation lives on the HC2 - // line. The combined cert must still be filtered by that generation - // rather than being treated as never-revoked. - let cfg = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked in MKBv72\n", - "00".repeat(20), - "00".repeat(92), - "00".repeat(32), - "00".repeat(132), - ); - let db = KeyDb::parse(&cfg); - assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC"); - assert_eq!(db.host_certs[0].revoked_at_mkb, Some(72)); - assert!( - db.host_certs(Some(72)).is_empty(), - "combined cert revoked in MKBv72 must be excluded at gen 72" - ); - assert_eq!( - db.host_certs(Some(71)).len(), - 1, - "still usable below gen 72" - ); - } - - // ── Standalone accessors: get_vid / get_uk / get_uks ──────────────────── - - #[test] - fn get_vid_hit_and_miss() { - let i = "22".repeat(16); - let line = format!("0xABCDEF = T | I | 0x{i}"); - let db = KeyDb::parse(&line); - // Hit — prefix-agnostic, same form find_disc accepts. - assert_eq!(db.get_vid("ABCDEF"), Some([0x22u8; 16])); - assert_eq!(db.get_vid("0xabcdef"), Some([0x22u8; 16])); - // Miss. - assert_eq!(db.get_vid("0xDEADBE"), None); - } - - #[test] - fn get_uk_hit_and_miss() { - let k1 = "01".repeat(16); - let k2 = "02".repeat(16); - let line = format!("0xABCDEF = T | U | 1-0x{k1} 2-0x{k2}"); - let db = KeyDb::parse(&line); - assert_eq!( - db.get_uk("ABCDEF"), - vec![(1, [0x01u8; 16]), (2, [0x02u8; 16])] - ); - // Miss ⇒ empty. - assert!(db.get_uk("0xDEADBE").is_empty()); - } - - #[test] - fn get_uks_lists_only_entries_with_unit_keys() { - let k = "03".repeat(16); - let v = "33".repeat(16); - let with_uk = format!("0xAAAA = T | U | 1-0x{k}"); - // An entry with only a VUK (no unit keys) must be excluded. - let no_uk = format!("0xBBBB = T | V | 0x{v}"); - let db = KeyDb::parse(&format!("{with_uk}\n{no_uk}\n")); - let uks = db.get_uks(); - assert_eq!(uks.len(), 1, "only the entry with unit keys is listed"); - assert_eq!(uks[0].0, "0xaaaa"); - 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 AACS 2.0 - // credentials, recovered by re-`parse`; also an empty title serializes - // as the literal `Unknown`. - #[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, VolumeSize, - // UHD); exercise each optional sub-field independently, not just - // all-three-true. - #[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); - } - - // ── HC line: full parse into (priv[20], cert[92], revoked) ────────────── - - /// A real-world-shaped `| HC |` line — a 20-byte private key, a 92-byte - /// cert that begins with the AACS host-cert header `02 01 00 5c ff ff 80 00 - /// 02 10 …`, and a `; Revoked in MKBv72` note — must parse to exactly those - /// three: 20-byte key, 92-byte cert, and `revoked_at_mkb = Some(72)`. - #[test] - fn hc_line_parses_into_key_cert_and_revocation_generation() { - // Cert = 4-byte header + 6-byte host id + filler to the 92-byte length. - // Synthetic bytes only; no real key material. - let mut cert = vec![0x02u8, 0x01, 0x00, 0x5c, 0xff, 0xff, 0x80, 0x00, 0x02, 0x10]; - cert.resize(92, 0xcc); - let cert_hex: String = cert.iter().map(|b| format!("{b:02x}")).collect(); - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{cert_hex} ; Revoked in MKBv72", - "88".repeat(20), - ); - let hc = KeyDb::parse_host_cert(&line).expect("a well-formed HC line must parse"); - assert_eq!(hc.cert.private_key, [0x88u8; 20], "20-byte private key"); - assert_eq!(hc.cert.certificate.len(), 92, "AACS 1.0 cert is 92 bytes"); - assert_eq!( - &hc.cert.certificate[..4], - &[0x02, 0x01, 0x00, 0x5c], - "header preserved" - ); - assert_eq!( - hc.revoked_at_mkb, - Some(72), - "the ; Revoked in MKBvN note captures N" - ); - // A 1.0 HC line carries no AACS 2.0 credentials. - assert!(hc.cert.private_key_v2.is_none()); - assert!(hc.cert.certificate_v2.is_none()); - } - - /// A `| HC |` line with NO `; Revoked …` note is live: `revoked_at_mkb` - /// is `None`, so `host_certs` returns it at every generation. - #[test] - fn hc_line_without_note_is_live() { - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", - "00".repeat(20), - "ab".repeat(92), - ); - let hc = KeyDb::parse_host_cert(&line).unwrap(); - assert_eq!(hc.revoked_at_mkb, None, "no note ⇒ never revoked"); - } - - // ── host_certs(mkb): the exact per-generation revocation filter ───────── - - /// The hardware-critical filter: several certs revoked at DIFFERENT - /// generations plus one live cert. Each cert carries a distinct marker byte - /// so the returned SET is identified exactly, not merely counted. A cert - /// revoked at generation R is usable iff the disc's generation is `< R`. - #[test] - #[allow(deprecated)] - fn host_certs_filters_several_certs_across_mkb_generations() { - let hc = |marker: u8, note: &str| { - format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}{}\n", - "00".repeat(20), - format!("{marker:02x}").repeat(92), - note - ) - }; - let cfg = format!( - "{}{}{}{}{}", - hc(0x53, " ; Revoked in MKBv53"), - hc(0x58, " ; Revoked in MKBv58"), - hc(0x70, " ; Revoked in MKBv70"), - hc(0x72, " ; Revoked in MKBv72"), - hc(0x11, ""), // live: no revocation note - ); - let db = KeyDb::parse(&cfg); - assert_eq!(db.host_certs.len(), 5); - - // The first cert byte of each cert host_certs(mkb) hands back, sorted. - let markers = |mkb: Option| { - let mut m: Vec = db - .host_certs(mkb) - .iter() - .map(|c| c.certificate[0]) - .collect(); - m.sort_unstable(); - m - }; - - // gen 77: every revocation has fired; only the live cert survives. - assert_eq!(markers(Some(77)), vec![0x11]); - // gen 50: below every revocation ⇒ all five are usable. - assert_eq!(markers(Some(50)), vec![0x11, 0x53, 0x58, 0x70, 0x72]); - // gen 58: revoked-at-53 and revoked-at-58 are out at the boundary - // (disc_gen < revoked is false); 70, 72 and the live cert remain. - assert_eq!(markers(Some(58)), vec![0x11, 0x70, 0x72]); - // gen 70: 70 itself is out at its own generation; only 72 + live remain. - assert_eq!(markers(Some(70)), vec![0x11, 0x72]); - // unknown disc MKB ⇒ cannot filter ⇒ every cert is returned. - assert_eq!(markers(None), vec![0x11, 0x53, 0x58, 0x70, 0x72]); - } - - // ── Cert selection by the embedded 6-byte Host ID ────────────────────── - - /// The AACS host ID lives INSIDE the 92-byte cert (offset 4, 6 bytes): a - /// cert beginning `02 01 00 5c ff ff 80 00 02 10 …` carries host id - /// `ff ff 80 00 02 10`. The parser keeps the cert bytes verbatim, so a - /// caller can select the cert for a specific host by that embedded id. - #[test] - fn host_cert_selected_by_embedded_6_byte_host_id() { - let cert_hex = |host_id: [u8; 6]| { - let mut bytes = vec![0x02u8, 0x01, 0x00, 0x5c]; // 4-byte header - bytes.extend_from_slice(&host_id); // host id at offset 4 - bytes.resize(92, 0xcc); // pad to the 92-byte AACS 1.0 cert length - bytes.iter().map(|b| format!("{b:02x}")).collect::() - }; - let id_a = [0xff, 0xff, 0x80, 0x00, 0x02, 0x10]; - let id_b = [0xff, 0xff, 0x80, 0x00, 0x02, 0x11]; - let cfg = format!( - "| HC | HOST_PRIV_KEY 0x{p} | HOST_CERT 0x{a}\n\ - | HC | HOST_PRIV_KEY 0x{p} | HOST_CERT 0x{b}\n", - p = "00".repeat(20), - a = cert_hex(id_a), - b = cert_hex(id_b), - ); - let db = KeyDb::parse(&cfg); - assert_eq!(db.host_certs.len(), 2); - - let find = |want: [u8; 6]| { - db.host_certs - .iter() - .find(|hc| hc.cert.certificate.get(4..10) == Some(&want[..])) - }; - let hit = find(id_b).expect("the cert carrying host id …0211 must be found"); - assert_eq!(&hit.cert.certificate[4..10], &id_b); - assert_eq!(hit.cert.certificate.len(), 92); - // A host id no cert carries selects nothing — and the byte-range slice - // must not panic when it does not match. - assert!(find([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).is_none()); - } - - /// A 1.0-only keydb (only `| HC |` rows, no `| HC2 |`) must leave every - /// cert's AACS 2.0 fields empty — no phantom v2 credentials appear. - #[test] - fn keydb_with_only_hc_rows_yields_no_v2_certs() { - let cfg = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n\ - | HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(20), - "ab".repeat(92), - "01".repeat(20), - "cd".repeat(92), - ); - let db = KeyDb::parse(&cfg); - assert_eq!(db.host_certs.len(), 2); - assert!( - db.host_certs - .iter() - .all(|hc| hc.cert.private_key_v2.is_none()), - "no HC2 rows ⇒ no v2 private keys" - ); - assert!( - db.host_certs - .iter() - .all(|hc| hc.cert.certificate_v2.is_none()), - "no HC2 rows ⇒ no v2 certs" - ); - } - - /// The HC2 (AACS 2.0, type 0x11) slot: a full HC + HC2 pair parses into the - /// 1.0 fields AND the v2 fields, with the v2 private key exactly 32 bytes - /// and the v2 cert 132 bytes. Complements the field-ordering HC2 tests by - /// asserting the concrete v2 byte lengths a valid slot must yield. - #[test] - fn hc2_slot_parses_into_v2_key_and_cert_with_correct_lengths() { - let cfg = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n\ - | HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(20), - "00".repeat(92), - "5b".repeat(32), - "6c".repeat(132), - ); - let db = KeyDb::parse(&cfg); - assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC"); - let c = &db.host_certs[0].cert; - assert_eq!(c.certificate.len(), 92, "1.0 cert retained"); - assert_eq!( - c.private_key_v2, - Some([0x5bu8; 32]), - "32-byte v2 private key" - ); - assert_eq!( - c.certificate_v2.as_ref().map(|v| v.len()), - Some(132), - "132-byte v2 cert" - ); - } - - // KEYDB-parser integration tests relocated from libfreemkv; exercise the parser end-to-end - // against a real keydb.cfg. - - /// REQUIRES a real keydb; `#[ignore]` by default. Run with: - /// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`. - #[test] - #[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] - fn test_vuk_derivation() { - // Pick any UHD entry with a known MK, VID, and VUK from KEYDB. - // VUK = AES-DEC(MK, VID) XOR VID - let path = real_keydb_path(); - - let db = KeyDb::load(&path).unwrap(); - - // Find a disc with both MK, vid, and VUK so we can verify derivation - let entry = db - .disc_entries - .values() - .find(|e| e.media_key.is_some() && e.vid.is_some() && e.vuk.is_some()) - .expect("No disc with MK + VID + VUK"); - - let mk = entry.media_key.unwrap(); - let vid = entry.vid.unwrap(); - let expected_vuk = entry.vuk.unwrap(); - - let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid); - assert_eq!( - derived, expected_vuk, - "VUK derivation failed for disc: {} (hash {})", - entry.title, entry.disc_hash - ); - eprintln!("VUK derivation verified for: {}", entry.title); - } - - /// REQUIRES a real keydb AND an encrypted unit dump; `#[ignore]` by default. - /// Run with: `KEYDB_PATH= ENCRYPTED_UNIT_PATH= \ - /// cargo test --release -- --ignored`. - #[test] - #[ignore = "needs KEYDB_PATH + ENCRYPTED_UNIT_PATH: cargo test -- --ignored"] - fn test_decrypt_real_unit() { - // Try decrypting a real encrypted aligned unit from a UHD sample. - // This disc is AACS 2.0 (BEE) so unit key alone won't work — bus - // decryption is needed first — but this verifies the pipeline. - let unit_path = std::path::PathBuf::from( - std::env::var("ENCRYPTED_UNIT_PATH") - .expect("ENCRYPTED_UNIT_PATH must point at an encrypted aligned unit"), - ); - assert!( - unit_path.exists(), - "ENCRYPTED_UNIT_PATH does not exist: {}", - unit_path.display() - ); - - let original = std::fs::read(&unit_path).unwrap(); - assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN); - assert!( - !libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs), - "Unit should be encrypted" - ); - - let kp = real_keydb_path(); - let db = KeyDb::load(&kp).unwrap(); - - // Candidate entries: any UHD entry that carries unit keys. - let candidate_entries: Vec<&DiscEntry> = db - .disc_entries - .values() - .filter(|e| !e.unit_keys.is_empty()) - .collect(); - - eprintln!("Found {} entries with unit keys", candidate_entries.len()); - // Without candidates nothing below runs and the test would "pass" having - // exercised nothing — a parser regression that dropped every unit key - // would look exactly like the expected AACS 2.0 outcome. - assert!( - !candidate_entries.is_empty(), - "the fixture keydb must carry at least one entry with unit keys" - ); - - // Try each entry's unit keys: apply the key, then ask whether it opened - // the unit (the segregated primitives — decrypt, then structural check). - let mut attempts = 0usize; - let mut any_key_changed_the_unit = false; - for entry in &candidate_entries { - for (_, key) in &entry.unit_keys { - let mut unit = original.clone(); - libfreemkv::test_util::decrypt_unit(&mut unit, key); - attempts += 1; - any_key_changed_the_unit |= unit != original; - if libfreemkv::aacs::content::is_clean(&unit, libfreemkv::disc::ContentFormat::BdTs) - { - eprintln!("SUCCESS: Decrypted with entry {}", entry.disc_hash); - // Count TS sync bytes - let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count(); - eprintln!(" TS sync bytes: {}/32", ts); - return; - } - } - } - - // Reaching here is the EXPECTED outcome for this AACS 2.0 (BEE) sample. - assert!(attempts > 0, "no unit key was tried at all"); - assert!( - any_key_changed_the_unit, - "decrypt_unit left the aligned unit byte-identical for all {attempts} keys — \ - the decrypt primitive is a no-op, not a failed key" - ); - eprintln!( - "No unit key worked over {attempts} attempts \ - (expected for AACS 2.0 BEE disc — needs read_data_key)" - ); - } - - /// REQUIRES a real keydb; `#[ignore]` by default. Run with: - /// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`. - #[test] - #[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] - fn test_resolve_keys_vuk_path() { - // Test the full resolve chain using VUK path - let path = real_keydb_path(); - let db = KeyDb::load(&path).unwrap(); - - // Find any BD entry with a VUK and unit keys. `expect`, not an early - // `return`: a `return` here would ship green even if a parser - // regression stopped populating `vid`/`vuk` entirely. - let entry = db - .disc_entries - .values() - .find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.vid.is_some()) - .expect("the fixture keydb must carry an entry with VUK + unit keys + VID"); - let vuk = entry.vuk.unwrap(); - let vid = entry.vid.unwrap(); - let hash_hex = format!("0x{}", libfreemkv::hex::strip_hex_prefix(&entry.disc_hash)); - - // We need the actual Unit_Key_RO.inf from the disc to compute disc hash. - // Since we don't have it, we can at least test that the KEYDB lookup - // works with a known hash. - let found = db.find_disc(&hash_hex); - assert!(found.is_some()); - assert_eq!(found.unwrap().vuk, Some(vuk)); - - // Verify VUK derivation if we have MK + VID - if let Some(mk) = entry.media_key { - let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid); - assert_eq!(derived, vuk, "VUK derivation mismatch"); - eprintln!("VUK derivation verified"); - } - } -} +#[path = "keydb_format_tests.rs"] +mod tests; diff --git a/src/keydb_format_tests.rs b/src/keydb_format_tests.rs new file mode 100644 index 0000000..6672460 --- /dev/null +++ b/src/keydb_format_tests.rs @@ -0,0 +1,1617 @@ +use super::*; + +// `load` must enforce `MAX_KEYDB_BYTES` through a bounded read, not a stat +// (a prior metadata-based cap was skippable via a FIFO/char device +// reporting len 0). Asserts both edges of the boundary. +#[test] +fn load_enforces_the_size_cap_at_both_edges() { + let dir = std::env::temp_dir() + .join("fmkv-keydb-cap") + .join(format!("{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + // Exactly at the cap is accepted. Use a comment line so `parse` is happy + // and the content is valid UTF-8 of a known length. + let at = dir.join("at_cap.cfg"); + let body = vec![b'#'; MAX_KEYDB_BYTES as usize]; + std::fs::write(&at, &body).unwrap(); + assert!( + KeyDb::load(&at).is_ok(), + "a file exactly at MAX_KEYDB_BYTES must load" + ); + + // One byte over is rejected as InvalidData, not truncated and not OOM. + let over = dir.join("over_cap.cfg"); + let mut body = vec![b'#'; MAX_KEYDB_BYTES as usize]; + body.push(b'#'); + std::fs::write(&over, &body).unwrap(); + let err = KeyDb::load(&over).expect_err("over-cap file must be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + + std::fs::remove_dir_all(&dir).ok(); +} + +// `to_keydb_cfg` is the exact inverse of `parse`: parse a known line set, +// serialize, re-parse, and every field survives (keys, host cert, and the +// MKBv/UHD comment metadata). +#[test] +fn to_keydb_cfg_round_trips_through_parse() { + let h = |b: u8, n: usize| format!("{b:02x}").repeat(n); + let cert = h(0x99, 92); // AACS 1.0 host cert is 92 bytes + let src = format!( + "| HC | HOST_PRIV_KEY 0x{priv20} | HOST_CERT 0x{cert} ; Revoked in MKBv72\n\ + | DK | DEVICE_KEY 0x{k16} | DEVICE_NODE 0x0a00 | KEY_UV 0x00000e23 | KEY_U_MASK_SHIFT 0x0b\n\ + | PK | 0x{pk16}\n\ + 0x{hash20} = TestDisc | M | 0x{mk16} | I | 0x{id16} | V | 0x{vuk16} | U | 1-0x{u1} 2-0x{u2} ; MKBv76 VolumeSize: 81309007872 (UHD)\n", + hash20 = h(0xab, 20), + priv20 = h(0x88, 20), + cert = cert, + k16 = h(0x66, 16), + pk16 = h(0x77, 16), + mk16 = h(0x11, 16), + id16 = h(0x22, 16), + vuk16 = h(0x33, 16), + u1 = h(0x44, 16), + u2 = h(0x55, 16), + ); + let a = KeyDb::parse(&src); + let b = KeyDb::parse(&a.to_keydb_cfg()); + + // Per-disc entry: every field round-trips. + assert_eq!(a.disc_entries.len(), 1); + assert_eq!(b.disc_entries.len(), 1); + let ea = a.disc_entries.values().next().unwrap(); + let eb = b.disc_entries.values().next().unwrap(); + assert_eq!(ea.disc_hash, eb.disc_hash); + assert_eq!(ea.title, eb.title, "title"); + assert_eq!(ea.media_key, eb.media_key, "M"); + assert_eq!(ea.vid, eb.vid, "I/vid"); + assert_eq!(ea.vuk, eb.vuk, "V"); + assert_eq!(ea.unit_keys, eb.unit_keys, "U"); + assert_eq!(ea.mkb_version, eb.mkb_version, "MKBv"); + assert_eq!(ea.is_uhd, eb.is_uhd, "UHD"); + // Concrete values (not just self-consistency). + assert_eq!(ea.vid, Some([0x22u8; 16])); + assert_eq!(ea.vuk, Some([0x33u8; 16])); + assert_eq!(ea.unit_keys, vec![(1, [0x44u8; 16]), (2, [0x55u8; 16])]); + assert_eq!(ea.mkb_version, Some(76)); + assert!(ea.is_uhd); + + // Device key, processing key, host cert all survive byte-for-byte. + assert_eq!(a.device_keys.len(), b.device_keys.len()); + assert_eq!(a.device_keys[0].key, b.device_keys[0].key); + assert_eq!(a.device_keys[0].node, b.device_keys[0].node); + assert_eq!(a.device_keys[0].uv, b.device_keys[0].uv); + assert_eq!(a.device_keys[0].u_mask_shift, b.device_keys[0].u_mask_shift); + assert_eq!(a.processing_keys, b.processing_keys); + assert_eq!(a.host_certs.len(), 1); + assert_eq!(b.host_certs.len(), 1); + assert_eq!( + a.host_certs[0].cert.private_key, + b.host_certs[0].cert.private_key + ); + assert_eq!( + a.host_certs[0].cert.certificate, + b.host_certs[0].cert.certificate + ); + assert_eq!( + a.host_certs[0].revoked_at_mkb, + b.host_certs[0].revoked_at_mkb + ); + assert_eq!(b.host_certs[0].revoked_at_mkb, Some(72)); +} + +// REAL-DATA IDEMPOTENCE: parse → serialize (S1) → parse → serialize (S2); S1 must equal S2 +// byte-for-byte. REQUIRES a real keydb (KEYDB_PATH env); `#[ignore]` by default. +#[test] +#[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] +fn to_keydb_cfg_is_idempotent_on_real_keydb() { + let path = real_keydb_path(); + let db1 = KeyDb::load(&path).unwrap(); + let s1 = db1.to_keydb_cfg(); + let db2 = KeyDb::parse(&s1); + let s2 = db2.to_keydb_cfg(); + assert_eq!(s1.len(), s2.len(), "serialized byte length drifted"); + assert!(s1 == s2, "to_keydb_cfg is NOT idempotent (S1 != S2)"); + // No rows lost crossing the round trip. + assert_eq!( + db1.disc_entries.len(), + db2.disc_entries.len(), + "disc-entry count drift" + ); + assert_eq!(db1.device_keys.len(), db2.device_keys.len(), "DK drift"); + assert_eq!( + db1.processing_keys.len(), + db2.processing_keys.len(), + "PK drift" + ); + assert_eq!(db1.host_certs.len(), db2.host_certs.len(), "HC drift"); +} + +// REAL-SCALE parser health: a genuine published keydb must parse with ZERO +// rejected rows — the assertion the rejection counter exists to make +// possible. `#[ignore]` by default; run with KEYDB_PATH set. +#[test] +#[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] +fn a_real_keydb_parses_with_no_rejected_rows() { + let path = real_keydb_path(); + let text = std::fs::read_to_string(&path).expect("keydb must be readable"); + let (db, stats) = KeyDb::parse_counted(&text); + assert!( + db.disc_entries.len() > 170_000, + "expected a full-size keydb, got {} entries", + db.disc_entries.len() + ); + assert_eq!( + stats.rejected(), + 0, + "a healthy keydb must not lose rows to the parser: {stats:?}" + ); + assert_eq!( + stats.disc_duplicate, 0, + "duplicate disc hashes make one row's keys unreachable: {stats:?}" + ); +} + +/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found. +fn keydb_path() -> Option { + let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?); + if path.exists() { Some(path) } else { None } +} + +// The real keydb for the `#[ignore]`d real-scale tests, or a LOUD failure. +fn real_keydb_path() -> std::path::PathBuf { + keydb_path().expect( + "KEYDB_PATH must point at a real keydb.cfg; run: \ + KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored", + ) +} + +#[test] +fn test_parse_disc_entry() { + // Synthetic placeholders, DISTINCT per field so a field mix-up (M read + // as V, I as U, …) is caught — no real key material in code. + let z40 = "00".repeat(20); + let mk = "11".repeat(16); + let id = "22".repeat(16); + let vuk = "33".repeat(16); + let uk = "44".repeat(16); + let line = format!( + "0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{mk} | I | 0x{id} | V | 0x{vuk} | U | 1-0x{uk} ; MKBv77" + ); + let entry = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim + assert_eq!(entry.media_key, Some([0x11u8; 16]), "M field"); + assert_eq!(entry.vid, Some([0x22u8; 16]), "I field"); + assert_eq!(entry.vuk, Some([0x33u8; 16]), "V field"); + assert_eq!(entry.unit_keys, vec![(1u32, [0x44u8; 16])], "U field"); + assert_eq!(entry.mkb_version, Some(77)); +} + +#[test] +fn disc_entry_strips_a_comment_glued_to_a_trailing_v_field() { + // A row whose LAST field is V (no U after it) with the `; MKBv…` + // comment glued onto the hex value; before M/I/V stripped it too, + // parse_hex16 silently dropped the VUK. + let z40 = "00".repeat(20); + let z32 = "00".repeat(16); + let line = + format!("0x{z40} = SAMPLE (Sample) | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} ; MKBv64"); + let entry = KeyDb::parse_disc_entry(&line).unwrap(); + assert!(entry.media_key.is_some(), "M key must survive"); + assert!( + entry.vuk.is_some(), + "a VUK with a trailing ; comment must not be dropped" + ); +} + +// NOTE: key fields below use obvious repeated-byte / zero placeholders. +// NEVER put real — or real-looking — key material in code; these tests +// exercise the parser's field-splitting only. + +#[test] +fn test_parse_device_key() { + let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48"; + let dk = KeyDb::parse_device_key(line).unwrap(); + assert_eq!(dk.node, 0x0800); + assert_eq!(dk.u_mask_shift, 0x17); +} + +#[test] +fn test_orphan_dk_row_loads_into_processing_keys() { + // `| DK |` row without position fields = an orphan DK; it lands in + // `processing_keys` and the brute walker handles it. + let cfg = r#" +| DK | DEVICE_KEY 0x01010101010101010101010101010101 ; orphan, no position fields +| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48 +| PK | 0x03030303030303030303030303030303 ; legacy PK row still works +"#; + let db = KeyDb::parse(cfg); + assert_eq!( + db.device_keys.len(), + 1, + "positioned DK row should land in device_keys" + ); + // Orphan DK + legacy PK row both end up in processing_keys. + assert_eq!( + db.processing_keys.len(), + 2, + "orphan DK row + legacy PK row both belong in processing_keys" + ); + assert_eq!(db.processing_keys[0][..4], [0x01, 0x01, 0x01, 0x01]); + assert_eq!(db.processing_keys[1][..4], [0x03, 0x03, 0x03, 0x03]); +} + +#[test] +fn test_parse_orphan_dk_rejects_lines_with_position_fields() { + // The parser must NOT pick up a positioned DK row as an orphan + // (that would double-count). parse_orphan_dk explicitly checks. + let positioned = "| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17"; + assert!( + KeyDb::parse_orphan_dk(positioned).is_none(), + "positioned DK must not match orphan parser" + ); + let orphan = "| DK | DEVICE_KEY 0x01010101010101010101010101010101"; + let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse"); + assert_eq!(key[..4], [0x01, 0x01, 0x01, 0x01]); +} + +#[test] +fn test_parse_host_cert() { + // 20-byte priv + 92-byte cert, all zeros — placeholders, not a key. + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked", + "00".repeat(20), + "00".repeat(92) + ); + let hc = KeyDb::parse_host_cert(&line).unwrap(); + assert_eq!(hc.cert.private_key, [0u8; 20]); + assert_eq!(hc.cert.certificate.len(), 92); +} + +#[test] +fn test_parse_hex_rejects_non_ascii_without_panic() { + // A 4-byte UTF-8 scalar has byte-len 4 (passes the even check); the + // old &str-slice path panicked on the mid-codepoint boundary. The + // byte-wise parser must instead return None. + assert!(parse_hex("😀").is_none()); + // Mixed: leading hex then a 2-byte UTF-8 scalar (byte-len even). + assert!(parse_hex("ABé").is_none()); + // Sanity: well-formed hex still parses. + assert_eq!(parse_hex("0x00FF"), Some(vec![0x00, 0xFF])); + // Odd byte length still rejected. + assert!(parse_hex("ABC").is_none()); +} + +#[test] +fn test_hc2_before_hc_is_not_dropped() { + // An HC2 row appearing before any HC row must still land its AACS 2.0 + // credentials on a HostCert rather than being silently discarded. + let cfg = format!( + "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(32), + "00".repeat(132) + ); + let db = KeyDb::parse(&cfg); + assert_eq!( + db.host_certs.len(), + 1, + "HC2-only row must create a HostCert" + ); + assert!(db.host_certs[0].cert.private_key_v2.is_some()); + assert!(db.host_certs[0].cert.certificate_v2.is_some()); + assert!( + db.host_certs[0].cert.certificate.is_empty(), + "v1 cert stays empty for an HC2-only carrier" + ); +} + +#[test] +fn test_hc2_after_hc_augments_existing() { + let cfg = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(20), + "00".repeat(92), + "00".repeat(32), + "00".repeat(132) + ); + let db = KeyDb::parse(&cfg); + assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC"); + assert_eq!(db.host_certs[0].cert.certificate.len(), 92); + assert!(db.host_certs[0].cert.certificate_v2.is_some()); +} + +#[test] +fn test_parse_host_cert_rejects_short_v1_cert() { + // A too-short AACS 1.0 cert must be dropped at parse time. + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", + "00".repeat(20), + "00".repeat(10) + ); + assert!(KeyDb::parse_host_cert(&line).is_none()); +} + +/// REQUIRES a real keydb; `#[ignore]` by default. Run with: +/// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`. +#[test] +#[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] +fn test_parse_full_keydb() { + let path = real_keydb_path(); + + let db = KeyDb::load(&path).unwrap(); + + assert_eq!(db.device_keys.len(), 4); + assert_eq!(db.processing_keys.len(), 3); + assert!(!db.host_certs.is_empty()); + assert!(db.disc_entries.len() > 170000); + + // Look up any disc entry carrying a full key set. + let entry = db + .disc_entries + .values() + .find(|e| e.vuk.is_some() && e.media_key.is_some() && !e.unit_keys.is_empty()) + .expect("no disc entry with a full key set"); + assert!(entry.media_key.is_some()); + assert!(entry.vuk.is_some()); + assert!(!entry.unit_keys.is_empty()); + + eprintln!( + "Parsed {} disc entries, {} DK, {} PK", + db.disc_entries.len(), + db.device_keys.len(), + db.processing_keys.len() + ); +} + +// `load_counted` must read the HANDLE it was given, never re-resolve the path (identity +// stamp + bytes must match). +#[cfg(unix)] +#[test] +fn load_counted_reads_the_handle_not_the_path() { + let dir = std::env::temp_dir().join(format!( + "fmk-keysources-load-seam-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("keydb.cfg"); + let other = dir.join("replacement.cfg"); + let row = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n"); + std::fs::write(&path, row(&"01".repeat(16))).unwrap(); + std::fs::write(&other, row(&"02".repeat(16))).unwrap(); + + let handle = std::fs::File::open(&path).unwrap(); + // The path now names a DIFFERENT inode; the handle still names the old + // one, exactly as it would mid-race. + std::fs::rename(&other, &path).unwrap(); + + let (db, _) = KeyDb::load_counted(handle, &path).expect("the open handle is still valid"); + assert_eq!( + db.disc_entries + .values() + .next() + .expect("one entry") + .unit_keys[0] + .1, + [0x01u8; 16], + "the parse must come from the handle that was stamped, not from a re-opened path" + ); + + std::fs::remove_dir_all(&dir).ok(); +} + +// ── Debug redaction: key material must never reach a log or a panic ──── + +/// The only key bytes in these fixtures are the byte 0x5A repeated; the +/// assertion is that NO run of them survives into the formatted string, in +/// either the hex or the Rust-array rendering a derived `Debug` produces. +fn assert_no_key_bytes(what: &str, rendered: &str) { + assert!( + !rendered.contains("90"), + "{what} Debug leaked a decimal key byte: {rendered}" + ); + assert!( + !rendered.contains("5a") && !rendered.contains("5A"), + "{what} Debug leaked a hex key byte: {rendered}" + ); + // A derived `Debug` over a byte array renders `[90, 90, ...]`; matched + // as "`[` immediately followed by a digit" rather than "any `[`", + // since callers legitimately render a `Vec` of redacting structs. + let bytes = rendered.as_bytes(); + assert!( + !rendered + .match_indices('[') + .any(|(i, _)| bytes.get(i + 1).is_some_and(u8::is_ascii_digit)), + "{what} Debug rendered a raw byte array: {rendered}" + ); +} + +// `{:?}` on a `DiscEntry` must not print the Media Key, VID, VUK or any +// Unit Key. Catches the mutation that restores `#[derive(Debug)]`. +#[test] +fn disc_entry_debug_is_redacted() { + let e = DiscEntry { + disc_hash: Arc::from("0xaabb"), + title: "T".into(), + media_key: Some([0x5A; 16]), + vid: Some([0x5A; 16]), + vuk: Some([0x5A; 16]), + unit_keys: vec![(1, [0x5A; 16])], + mkb_version: Some(76), + volume_size: Some(1), + is_uhd: true, + }; + let rendered = format!("{e:?}"); + assert_no_key_bytes("DiscEntry", &rendered); + // The non-secret identifying fields are still useful and still printed. + assert!(rendered.contains("0xaabb"), "{rendered}"); + assert!(rendered.contains("unit_keys_len"), "{rendered}"); +} + +/// `{:?}` on a whole `KeyDb` must print COUNTS only. The derive dumped every +/// processing key plus all ~181k disc entries' key material — the single +/// worst leak in the crate, since a `KeyDb` is what a key source holds. +#[test] +fn keydb_debug_is_redacted() { + let mut db = KeyDb::empty(); + db.processing_keys.push([0x5A; 16]); + db.device_keys.push(DeviceKey { + key: [0x5A; 16], + node: 1, + uv: 2, + u_mask_shift: 3, + }); + db.host_certs.push(KeydbHostCert { + cert: HostCert { + private_key: [0x5A; 20], + certificate: vec![0x5A; 92], + private_key_v2: None, + certificate_v2: None, + }, + revoked_at_mkb: None, + }); + let e = DiscEntry { + disc_hash: Arc::from("0xaabb"), + title: "T".into(), + media_key: Some([0x5A; 16]), + vid: None, + vuk: Some([0x5A; 16]), + unit_keys: vec![(1, [0x5A; 16])], + mkb_version: None, + volume_size: None, + is_uhd: false, + }; + db.disc_entries.insert(e.disc_hash.clone(), e); + + let rendered = format!("{db:?}"); + assert_no_key_bytes("KeyDb", &rendered); + assert!( + rendered.contains("disc_entries_len"), + "counts, not contents: {rendered}" + ); + assert!( + rendered.contains("processing_keys_len"), + "counts, not contents: {rendered}" + ); +} + +// The SIBLING type: `KeyDb`'s hand-written `Debug` protects the whole-db rendering only; +// `{:?}` on one `KeydbHostCert` element walks a different path carrying the AACS host +// PRIVATE key. +#[test] +fn host_cert_debug_is_redacted_including_the_sibling_wrapper() { + let hc = KeydbHostCert { + cert: HostCert { + private_key: [0x5A; 20], + certificate: vec![0x5A; 92], + private_key_v2: Some([0x5A; 32]), + certificate_v2: Some(vec![0x5A; 132]), + }, + revoked_at_mkb: Some(76), + }; + assert_no_key_bytes("KeydbHostCert", &format!("{hc:?}")); + assert_no_key_bytes("Vec", &format!("{:?}", vec![hc.clone()])); + assert_no_key_bytes("HostCert", &format!("{:?}", hc.cert)); + // Positive half: the private keys must be present-but-masked, not + // omitted. A `Debug` that simply dropped the fields would satisfy the + // negative assertions above while hiding that a key is held at all. + assert_eq!( + format!("{hc:?}").matches("").count(), + 2, + "both host private keys (1.0 and 2.0) must render as : {hc:?}" + ); + // The non-secret revocation field is what the wrapper exists for and is + // still printed — redaction must not become "print nothing useful". + assert!( + format!("{hc:?}").contains("76"), + "the revocation generation is not secret and must survive: {hc:?}" + ); +} + +/// The `Vec` on the other public `KeyDb` field, for the same +/// reason: `device_keys` is reachable element-wise and each entry holds a +/// 16-byte device key. Also inherited from libfreemkv's redacting impl. +#[test] +fn device_key_debug_is_redacted() { + let dk = DeviceKey { + key: [0x5A; 16], + node: 1, + uv: 2, + u_mask_shift: 3, + }; + assert_no_key_bytes("DeviceKey", &format!("{dk:?}")); + assert_no_key_bytes("Vec", &format!("{:?}", vec![dk])); +} + +// ── Parser rejection accounting ── every malformed row must be COUNTED, +// per reason. Catches restoring a bare `if let Some(x) = .. { }` with no +// `else`, which let a byte-shifted download resolve nothing with zero log. +#[test] +fn parse_counts_every_rejected_row_by_reason() { + let good_pk = format!("| PK | 0x{}", "ab".repeat(16)); + let cfg = format!( + "{good_pk}\n\ + | DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n\ + | PK | 0xnothex\n\ + | HC | HOST_PRIV_KEY 0x00 | HOST_CERT 0x00\n\ + | HC2 | HOST_PRIV_KEY 0x00 | HOST_CERT 0x00\n\ + 0xdeadbeef and no equals sign here\n" + ); + let (db, stats) = KeyDb::parse_counted(&cfg); + assert_eq!(stats.dk_rejected, 1, "the bad DK row must be counted"); + assert_eq!(stats.pk_rejected, 1, "the bad PK row must be counted"); + assert_eq!(stats.hc_rejected, 1, "the short HC row must be counted"); + assert_eq!(stats.hc2_rejected, 1, "the short HC2 row must be counted"); + // A `0x…` row missing " = " is malformed (a hash the user sees but that + // never resolves); it is COUNTED now, not silently ignored — exactly the + // "hash in the file but nothing resolves" bug class. + assert_eq!( + stats.disc_rejected, 1, + "a prefixed row missing \" = \" is counted" + ); + assert_eq!(stats.rejected(), 5, "five rows rejected in total"); + // The one GOOD row still loaded — counting must not change parsing. + assert_eq!(db.processing_keys, vec![[0xABu8; 16]]); +} + +/// A duplicated disc hash silently last-wins through `HashMap::insert`: +/// one of the two rows' keys becomes unreachable. Not necessarily +/// corruption, but never something to discover silently. +#[test] +fn parse_counts_duplicate_disc_hashes() { + let k1 = "01".repeat(16); + let k2 = "02".repeat(16); + let cfg = format!("0xAAAA = T | U | 1-0x{k1}\n0xaaaa = T | U | 1-0x{k2}\n"); + let (db, stats) = KeyDb::parse_counted(&cfg); + assert_eq!(db.disc_entries.len(), 1, "same hash ⇒ one entry survives"); + assert_eq!( + stats.disc_duplicate, 1, + "the overwritten row must be counted, not vanish" + ); + // Last wins — pinned so the counter documents the ACTUAL behaviour. + assert_eq!(db.get_uk("0xaaaa"), vec![(1, [0x02u8; 16])]); +} + +/// A keydb is a database: EVERY disc row must load, with no count-based cap +/// silently dropping entries. Regression guard for the removed +/// `MAX_DISC_ENTRIES` cap — a disc verifiably in the file must resolve. +#[test] +fn parse_keeps_every_disc_row_no_entry_cap() { + let k = "01".repeat(16); + let n = 600_000; // comfortably past the old 500k cap + let mut cfg = String::with_capacity(n * 16); + for i in 0..n { + cfg.push_str(&format!("0x{i:x} = T | U | 1-0x{k}\n")); + } + let (db, stats) = KeyDb::parse_counted(&cfg); + assert_eq!(db.disc_entries.len(), n, "every disc row must be kept"); + assert_eq!(stats.rejected(), 0, "no row dropped"); +} + +// ── whole-flow robustness: every way a row a user can SEE in the file could +// silently fail to load. Each is a regression guard for a real gap. ── + +/// A minimal, well-formed disc row for `hash` (lowercase hex, no 0x). +fn disc_line(hash: &str) -> String { + let uk = "01".repeat(16); + let vuk = "02".repeat(16); + format!("0x{hash} = TITLE | V | 0x{vuk} | U | 1-0x{uk}") +} + +/// A UTF-8 BOM on the first line must not eat the first disc row. +#[test] +fn parse_strips_leading_bom_first_row_survives() { + let h = "aa".repeat(20); + let cfg = format!("\u{feff}{}\n", disc_line(&h)); + let (db, stats) = KeyDb::parse_counted(&cfg); + assert_eq!(db.disc_entries.len(), 1, "BOM must not drop the first row"); + assert!(db.find_disc(&format!("0x{h}")).is_some()); + assert_eq!(stats.rejected(), 0); +} + +/// CRLF (`\r\n`) line endings must load — `.lines()` + `trim()` handle the `\r`. +#[test] +fn parse_handles_crlf_line_endings() { + let h = "bb".repeat(20); + let cfg = format!("{}\r\n{}\r\n", disc_line(&h), disc_line(&"cc".repeat(20))); + let (db, _) = KeyDb::parse_counted(&cfg); + assert_eq!(db.disc_entries.len(), 2); + assert!(db.find_disc(&format!("0x{h}")).is_some()); +} + +/// Leading whitespace on a disc row must not drop it (trim covers it). +#[test] +fn parse_tolerates_leading_whitespace() { + let h = "dd".repeat(20); + let cfg = format!(" \t{}\n", disc_line(&h)); + let (db, _) = KeyDb::parse_counted(&cfg); + assert!(db.find_disc(&format!("0x{h}")).is_some()); +} + +/// An uppercase `0X` prefix AND uppercase hash must load and be findable by +/// any case / prefix form (the lookup normalizes). +#[test] +fn parse_and_lookup_are_case_and_prefix_insensitive() { + let cfg = "0XABCDEF0123456789ABCDEF0123456789ABCDEF01 = T | V | 0x0202020202020202020202020202020202 | U | 1-0x0101010101010101010101010101010101\n" + .replace("0202020202020202020202020202020202", &"02".repeat(16)) + .replace("0101010101010101010101010101010101", &"01".repeat(16)); + let (db, _) = KeyDb::parse_counted(&cfg); + // uppercase-with-0x, lowercase-with-0x, and no-prefix all resolve. + assert!( + db.find_disc("0xABCDEF0123456789ABCDEF0123456789ABCDEF01") + .is_some() + ); + assert!( + db.find_disc("0xabcdef0123456789abcdef0123456789abcdef01") + .is_some() + ); + assert!( + db.find_disc("abcdef0123456789abcdef0123456789abcdef01") + .is_some() + ); +} + +/// A `0x…` row missing the `" = "` separator must be COUNTED as rejected, +/// never silently ignored — otherwise a visibly-present disc vanishes. +#[test] +fn parse_counts_a_prefixed_row_missing_the_separator() { + let h = "ee".repeat(20); + // no " = " (bare "=") — malformed for the grammar. + let cfg = format!( + "0x{h}=TITLE|V|0x{}|U|1-0x{}\n", + "02".repeat(16), + "01".repeat(16) + ); + let (db, stats) = KeyDb::parse_counted(&cfg); + assert!( + db.find_disc(&format!("0x{h}")).is_none(), + "malformed row not loaded" + ); + assert_eq!( + stats.disc_rejected, 1, + "the drop MUST be counted, not silent" + ); + assert!( + stats.log() || stats.rejected() > 0, + "a rejection is diagnosable" + ); +} + +/// Duplicate hash: last row wins AND the collision is counted (a keydb with +/// the same hash twice means one row's keys become unreachable). +#[test] +fn parse_counts_duplicate_hash_last_wins() { + let h = "ff".repeat(20); + let cfg = format!("{}\n{}\n", disc_line(&h), disc_line(&h)); + let (db, stats) = KeyDb::parse_counted(&cfg); + assert_eq!(db.disc_entries.len(), 1); + assert_eq!(stats.disc_duplicate, 1, "the overwrite must be counted"); +} + +/// A row carrying ONLY terminal unit keys (no V field) still loads and is +/// findable — VUK-less discs must resolve from their stored unit keys. +#[test] +fn parse_keeps_unit_key_only_rows() { + let h = "12".repeat(20); + let cfg = format!("0x{h} = TITLE | U | 1-0x{}\n", "01".repeat(16)); + let (db, _) = KeyDb::parse_counted(&cfg); + let e = db.find_disc(&format!("0x{h}")).expect("row loads"); + assert!(e.vuk.is_none()); + assert_eq!(e.unit_keys.len(), 1, "the stored unit key is kept"); +} + +/// A disc row rejected by the FIELD parser is counted separately from one +/// dropped by the cap. +#[test] +fn parse_counts_unparseable_disc_row() { + // Nothing in today's grammar can be a disc row AND fail + // `parse_disc_entry`, so assert the healthy case is zero, so a future + // parser change that starts rejecting rows cannot do it silently. + let k = "01".repeat(16); + let (_, stats) = KeyDb::parse_counted(&format!("0xAAAA = T | U | 1-0x{k}\n")); + assert_eq!(stats.rejected(), 0, "a healthy keydb rejects nothing"); + assert_eq!(stats.disc_duplicate, 0); +} + +// ── `0X` disc rows / the save() lockstep ── an uppercase row was once +// dropped with no diagnostic. Catches the mutation that removes the `0X` +// arm of `is_disc_entry_line`. +#[test] +fn disc_row_with_uppercase_0x_prefix_is_parsed() { + let v = "33".repeat(16); + let db = KeyDb::parse(&format!("0XABCDEF = T | V | 0x{v}\n")); + assert_eq!( + db.disc_entries.len(), + 1, + "an uppercase 0X disc row must parse, like every other hex field" + ); + // The hash is lowercased on the way in, so lookup is unaffected. + assert_eq!(db.find_vuk("0xabcdef"), Some([0x33u8; 16])); + assert!(is_disc_entry_line("0XAA = T")); + assert!(is_disc_entry_line("0xaa = T")); + // Still not a disc row without the " = " separator. + assert!(!is_disc_entry_line("0XAABBCC")); +} + +// ── One allocation per disc hash, not two ── map key and entry's own +// `disc_hash` must be the SAME allocation (used to be a clone, ~13 MB of +// duplicate text across the real keydb's 181k entries). +#[test] +fn disc_hash_is_stored_once_not_twice() { + let v = "33".repeat(16); + let db = KeyDb::parse(&format!("0xABCDEF = T | V | 0x{v}\n")); + let (key, entry) = db.disc_entries.iter().next().expect("one entry"); + assert!( + Arc::ptr_eq(key, &entry.disc_hash), + "the map key must BE the entry's disc_hash allocation, not a clone" + ); +} + +// ══ Hardening additions ══ parse_hex / parse_hex16 / parse_hex20 ══ + +#[test] +fn parse_hex_strips_lower_and_upper_prefixes() { + // Both lower- and upper-case prefixes are stripped (trim_start_matches + // "0x" then "0X"). Without one of those strips a value would be off by + // a nibble or fail length checks. + assert_eq!(parse_hex("0xABCD"), Some(vec![0xAB, 0xCD])); + assert_eq!(parse_hex("0XABCD"), Some(vec![0xAB, 0xCD])); + assert_eq!(parse_hex("ABCD"), Some(vec![0xAB, 0xCD])); +} + +#[test] +fn parse_hex_mixed_case_nibbles() { + // to_digit(16) accepts both cases. + assert_eq!(parse_hex("aB"), Some(vec![0xAB])); + assert_eq!(parse_hex("Ff00"), Some(vec![0xFF, 0x00])); +} + +#[test] +fn parse_hex_rejects_non_hex_digit() { + // 'G' is not a hex digit → None (not silently 0). + assert!(parse_hex("0xGG").is_none()); + assert!(parse_hex("12ZZ").is_none()); +} + +#[test] +fn parse_hex_empty_is_empty_vec() { + // Empty (or bare "0x") → Some(empty): even byte-length 0 passes, and + // there are no nibbles to reject. parse_hex16/20 then reject on length. + assert_eq!(parse_hex(""), Some(vec![])); + assert_eq!(parse_hex("0x"), Some(vec![])); +} + +#[test] +fn parse_hex16_enforces_exactly_16_bytes() { + assert!(parse_hex16(&format!("0x{}", "00".repeat(15))).is_none()); + assert!(parse_hex16(&format!("0x{}", "00".repeat(17))).is_none()); + assert_eq!( + parse_hex16(&format!("0x{}", "00".repeat(16))), + Some([0u8; 16]) + ); +} + +#[test] +fn parse_hex20_enforces_exactly_20_bytes() { + assert!(parse_hex20(&format!("0x{}", "00".repeat(19))).is_none()); + assert_eq!( + parse_hex20(&format!("0x{}", "11".repeat(20))), + Some([0x11u8; 20]) + ); +} + +// ── Disc entry field parsing ─────────────────────────────────────────── + +#[test] +fn disc_entry_hash_is_lowercased() { + // The disc_hash key is lowercased so HashMap lookups are + // case-insensitive (find_disc lowercases its query too). + let z32 = "00".repeat(16); + let line = format!("0xABCDEF = T | M | 0x{z32}"); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(&*e.disc_hash, "0xabcdef"); +} + +#[test] +fn disc_entry_title_kept_verbatim_even_with_parens() { + // Faithful copy: the title is kept VERBATIM, parens and all — NOT reduced + // to the parenthesised substring (which truncated real multi-paren titles + // and broke serialize→parse idempotence). + let line = "0x00 = RAW_NAME (Display Name) | M | 0x".to_string() + &"00".repeat(16); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.title, "RAW_NAME (Display Name)"); +} + +#[test] +fn disc_entry_title_without_parens_uses_whole() { + let line = "0x00 = PlainTitle | M | 0x".to_string() + &"00".repeat(16); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.title, "PlainTitle"); +} + +#[test] +fn disc_entry_malformed_parens_falls_back_to_whole_title() { + // The title is kept verbatim regardless of paren placement — a malformed + // ')' before '(' is not special-cased; the whole string is the title. + let line = "0x00 = FILM) (X | M | 0x".to_string() + &"00".repeat(16); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.title, "FILM) (X"); +} + +#[test] +fn disc_entry_parses_all_tagged_fields() { + // M, I, V, U each populate their field. U accepts "n-0xKEY". + let m = "11".repeat(16); + let i = "22".repeat(16); + let v = "33".repeat(16); + let u = "44".repeat(16); + let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}"); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.media_key, Some([0x11u8; 16])); + assert_eq!(e.vid, Some([0x22u8; 16])); + assert_eq!(e.vuk, Some([0x33u8; 16])); + assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]); +} + +#[test] +fn disc_entry_multiple_unit_keys_space_separated() { + // The U field carries space-separated "n-0xKEY" pairs. + let k1 = "01".repeat(16); + let k2 = "02".repeat(16); + let line = format!("0xAA = T | U | 1-0x{k1} 2-0x{k2}"); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.unit_keys, vec![(1, [0x01u8; 16]), (2, [0x02u8; 16])]); +} + +#[test] +fn disc_entry_unit_key_strips_trailing_comment() { + // "U | 1-0xKEY ; comment" — the ';' comment must be stripped before + // splitting unit keys. + let k = "05".repeat(16); + let line = format!("0xAA = T | U | 1-0x{k} ; MKBv77 note"); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.unit_keys, vec![(1, [0x05u8; 16])]); +} + +#[test] +fn disc_entry_skips_unparseable_unit_key_pair() { + // A bad nibble in one unit key drops just that pair (parse_hex16 → + // None), keeping the valid ones — no panic, no half-garbage key. + let good = "07".repeat(16); + let line = format!("0xAA = T | U | 1-0xZZ 2-0x{good}"); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.unit_keys, vec![(2, [0x07u8; 16])]); +} + +#[test] +fn disc_entry_field_with_short_hex_is_none_not_panic() { + // A 30-hex-char (15-byte) M value fails parse_hex16 → media_key None. + let short = "00".repeat(15); + let line = format!("0xAA = T | M | 0x{short}"); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert!(e.media_key.is_none()); +} + +// ── find_disc / find_vuk: prefix-agnostic lookup ─────────────────────── + +#[test] +fn find_disc_matches_with_and_without_0x_and_case() { + let v = "33".repeat(16); + let line = format!("0xABCDEF = T | V | 0x{v}"); + let db = KeyDb::parse(&line); + // Stored key is "0xabcdef". Query in several shapes. + assert!(db.find_disc("0xABCDEF").is_some()); + assert!(db.find_disc("ABCDEF").is_some()); // no prefix + assert!(db.find_disc("0xabcdef").is_some()); + assert!(db.find_disc(" 0xAbCdEf ").is_some()); // padded + mixed case + assert_eq!(db.find_vuk("ABCDEF"), Some([0x33u8; 16])); + assert!(db.find_disc("0xDEADBE").is_none()); +} + +// ── Comments / blank lines / unknown lines ───────────────────────────── + +#[test] +fn parse_ignores_comments_and_blank_lines() { + let cfg = "\n; a comment\n# another\n \n"; + let db = KeyDb::parse(cfg); + assert!(db.device_keys.is_empty()); + assert!(db.processing_keys.is_empty()); + assert!(db.disc_entries.is_empty()); + assert!(db.host_certs.is_empty()); +} + +#[test] +fn parse_empty_or_keyless_file_is_lenient_not_error() { + // parse() never errors; a keyless file is an empty KeyDb (documented + // contract — load() errors only on read failure, not empty content). + let db = KeyDb::parse("; nothing here\n"); + assert_eq!(db.disc_entries.len(), 0); +} + +#[test] +fn parse_device_key_requires_all_four_fields() { + // Missing KEY_U_MASK_SHIFT → parse_device_key returns None; it has + // DEVICE_NODE + KEY_UV so parse_orphan_dk also rejects it (has + // position fields), so nothing is loaded. + let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400"; + assert!(KeyDb::parse_device_key(line).is_none()); + let db = KeyDb::parse(line); + assert!(db.device_keys.is_empty()); + assert!(db.processing_keys.is_empty()); +} + +#[test] +fn parse_device_key_accepts_uppercase_0x_prefix() { + // Regression: a case-sensitive `trim_start_matches("0x")` left an + // uppercase `0X` prefix unstripped, so `from_str_radix` failed and + // the WHOLE device key was silently dropped. + let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F \ + | DEVICE_NODE 0X0001 | KEY_UV 0X00000002 | KEY_U_MASK_SHIFT 0X03"; + let dk = KeyDb::parse_device_key(line).expect("uppercase 0X prefix must parse"); + assert_eq!(dk.node, 1); + assert_eq!(dk.uv, 2); + assert_eq!(dk.u_mask_shift, 3); +} + +#[test] +fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() { + // v2 priv must be exactly 32 bytes; cert must be >= 132. + let bad_priv = format!( + "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", + "00".repeat(31), + "00".repeat(132) + ); + assert!(KeyDb::parse_host_cert_v2(&bad_priv).is_none()); + let short_cert = format!( + "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", + "00".repeat(32), + "00".repeat(131) + ); + assert!(KeyDb::parse_host_cert_v2(&short_cert).is_none()); +} + +#[test] +fn parse_processing_key_pk_row() { + // "| PK | 0x..." → 16-byte processing key. A trailing comment is + // stripped at ';'. + let line = format!("| PK | 0x{} ; MKBv64", "AB".repeat(16)); + let pk = KeyDb::parse_processing_key(&line).unwrap(); + 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] +fn disc_entry_comment_uhd_mkb_and_volume_size() { + // Canonical UHD comment grammar. + let z = "00".repeat(16); + let line = format!( + "0xAA = T | M | 0x{z} | U | 1-0x{z} ; MKBv76/BEE/FindVUK 1.74 - VolumeSize: 81309007872 (UHD)" + ); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.mkb_version, Some(76)); + assert_eq!(e.volume_size, Some(81_309_007_872)); + assert!(e.is_uhd); +} + +#[test] +fn disc_entry_comment_bd_is_not_uhd() { + // "(BD)" comment ⇒ is_uhd false, VolumeSize still parsed. + let z = "00".repeat(16); + let line = format!("0xAA = T | M | 0x{z} ; MKBv68/FindVUK 1.24 - VolumeSize: 37672976384 (BD)"); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert!(!e.is_uhd); + assert_eq!(e.volume_size, Some(37_672_976_384)); + assert_eq!(e.mkb_version, Some(68)); +} + +#[test] +fn disc_entry_no_comment_all_metadata_none_and_fields_still_parse() { + // Regression: with NO trailing comment the three new fields default to + // None/false AND the U/M/I/V fields still parse correctly. + let m = "11".repeat(16); + let i = "22".repeat(16); + let v = "33".repeat(16); + let u = "44".repeat(16); + let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}"); + let e = KeyDb::parse_disc_entry(&line).unwrap(); + assert_eq!(e.mkb_version, None); + assert_eq!(e.volume_size, None); + assert!(!e.is_uhd); + // Unchanged field parsing. + assert_eq!(e.media_key, Some([0x11u8; 16])); + assert_eq!(e.vid, Some([0x22u8; 16])); + assert_eq!(e.vuk, Some([0x33u8; 16])); + assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]); +} + +// ── Host-cert revocation: parse + host_certs(mkb) filter ──────────────── + +#[test] +#[allow(deprecated)] +fn host_cert_revoked_parses_and_filters_by_mkb() { + let revoked_line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked in MKBv72", + "00".repeat(20), + "11".repeat(92), + ); + let hc = KeyDb::parse_host_cert(&revoked_line).unwrap(); + assert_eq!(hc.revoked_at_mkb, Some(72)); + + let db = KeyDb::parse(&revoked_line); + assert_eq!(db.host_certs.len(), 1); + // Revoked in MKBv72 ⇒ unusable at gen >= 72, usable below it. + assert!( + db.host_certs(Some(72)).is_empty(), + "a cert revoked in MKBv72 must be excluded at gen 72" + ); + assert_eq!( + db.host_certs(Some(71)).len(), + 1, + "still usable at gen 71 (below the revocation generation)" + ); + assert_eq!( + db.host_certs(None).len(), + 1, + "unknown disc MKB ⇒ cannot filter ⇒ cert returned" + ); +} + +#[test] +#[allow(deprecated)] +fn host_cert_without_revocation_included_for_all_mkb() { + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", + "00".repeat(20), + "22".repeat(92), + ); + let hc = KeyDb::parse_host_cert(&line).unwrap(); + assert_eq!(hc.revoked_at_mkb, None); + + let db = KeyDb::parse(&line); + assert_eq!(db.host_certs(Some(99)).len(), 1); + assert_eq!(db.host_certs(Some(1)).len(), 1); + assert_eq!(db.host_certs(None).len(), 1); +} + +#[test] +#[allow(deprecated)] +fn hc2_revocation_propagates_when_hc_has_none() { + // The HC line carries no annotation; the revocation lives on the HC2 + // line. The combined cert must still be filtered by that generation + // rather than being treated as never-revoked. + let cfg = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked in MKBv72\n", + "00".repeat(20), + "00".repeat(92), + "00".repeat(32), + "00".repeat(132), + ); + let db = KeyDb::parse(&cfg); + assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC"); + assert_eq!(db.host_certs[0].revoked_at_mkb, Some(72)); + assert!( + db.host_certs(Some(72)).is_empty(), + "combined cert revoked in MKBv72 must be excluded at gen 72" + ); + assert_eq!( + db.host_certs(Some(71)).len(), + 1, + "still usable below gen 72" + ); +} + +// ── Standalone accessors: get_vid / get_uk / get_uks ──────────────────── + +#[test] +fn get_vid_hit_and_miss() { + let i = "22".repeat(16); + let line = format!("0xABCDEF = T | I | 0x{i}"); + let db = KeyDb::parse(&line); + // Hit — prefix-agnostic, same form find_disc accepts. + assert_eq!(db.get_vid("ABCDEF"), Some([0x22u8; 16])); + assert_eq!(db.get_vid("0xabcdef"), Some([0x22u8; 16])); + // Miss. + assert_eq!(db.get_vid("0xDEADBE"), None); +} + +#[test] +fn get_uk_hit_and_miss() { + let k1 = "01".repeat(16); + let k2 = "02".repeat(16); + let line = format!("0xABCDEF = T | U | 1-0x{k1} 2-0x{k2}"); + let db = KeyDb::parse(&line); + assert_eq!( + db.get_uk("ABCDEF"), + vec![(1, [0x01u8; 16]), (2, [0x02u8; 16])] + ); + // Miss ⇒ empty. + assert!(db.get_uk("0xDEADBE").is_empty()); +} + +#[test] +fn get_uks_lists_only_entries_with_unit_keys() { + let k = "03".repeat(16); + let v = "33".repeat(16); + let with_uk = format!("0xAAAA = T | U | 1-0x{k}"); + // An entry with only a VUK (no unit keys) must be excluded. + let no_uk = format!("0xBBBB = T | V | 0x{v}"); + let db = KeyDb::parse(&format!("{with_uk}\n{no_uk}\n")); + let uks = db.get_uks(); + assert_eq!(uks.len(), 1, "only the entry with unit keys is listed"); + assert_eq!(uks[0].0, "0xaaaa"); + 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 AACS 2.0 +// credentials, recovered by re-`parse`; also an empty title serializes +// as the literal `Unknown`. +#[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, VolumeSize, +// UHD); exercise each optional sub-field independently, not just +// all-three-true. +#[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); +} + +// ── HC line: full parse into (priv[20], cert[92], revoked) ────────────── + +/// A real-world-shaped `| HC |` line — a 20-byte private key, a 92-byte +/// cert that begins with the AACS host-cert header `02 01 00 5c ff ff 80 00 +/// 02 10 …`, and a `; Revoked in MKBv72` note — must parse to exactly those +/// three: 20-byte key, 92-byte cert, and `revoked_at_mkb = Some(72)`. +#[test] +fn hc_line_parses_into_key_cert_and_revocation_generation() { + // Cert = 4-byte header + 6-byte host id + filler to the 92-byte length. + // Synthetic bytes only; no real key material. + let mut cert = vec![0x02u8, 0x01, 0x00, 0x5c, 0xff, 0xff, 0x80, 0x00, 0x02, 0x10]; + cert.resize(92, 0xcc); + let cert_hex: String = cert.iter().map(|b| format!("{b:02x}")).collect(); + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{cert_hex} ; Revoked in MKBv72", + "88".repeat(20), + ); + let hc = KeyDb::parse_host_cert(&line).expect("a well-formed HC line must parse"); + assert_eq!(hc.cert.private_key, [0x88u8; 20], "20-byte private key"); + assert_eq!(hc.cert.certificate.len(), 92, "AACS 1.0 cert is 92 bytes"); + assert_eq!( + &hc.cert.certificate[..4], + &[0x02, 0x01, 0x00, 0x5c], + "header preserved" + ); + assert_eq!( + hc.revoked_at_mkb, + Some(72), + "the ; Revoked in MKBvN note captures N" + ); + // A 1.0 HC line carries no AACS 2.0 credentials. + assert!(hc.cert.private_key_v2.is_none()); + assert!(hc.cert.certificate_v2.is_none()); +} + +/// A `| HC |` line with NO `; Revoked …` note is live: `revoked_at_mkb` +/// is `None`, so `host_certs` returns it at every generation. +#[test] +fn hc_line_without_note_is_live() { + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", + "00".repeat(20), + "ab".repeat(92), + ); + let hc = KeyDb::parse_host_cert(&line).unwrap(); + assert_eq!(hc.revoked_at_mkb, None, "no note ⇒ never revoked"); +} + +// ── host_certs(mkb): the exact per-generation revocation filter ───────── + +/// The hardware-critical filter: several certs revoked at DIFFERENT +/// generations plus one live cert. Each cert carries a distinct marker byte +/// so the returned SET is identified exactly, not merely counted. A cert +/// revoked at generation R is usable iff the disc's generation is `< R`. +#[test] +#[allow(deprecated)] +fn host_certs_filters_several_certs_across_mkb_generations() { + let hc = |marker: u8, note: &str| { + format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}{}\n", + "00".repeat(20), + format!("{marker:02x}").repeat(92), + note + ) + }; + let cfg = format!( + "{}{}{}{}{}", + hc(0x53, " ; Revoked in MKBv53"), + hc(0x58, " ; Revoked in MKBv58"), + hc(0x70, " ; Revoked in MKBv70"), + hc(0x72, " ; Revoked in MKBv72"), + hc(0x11, ""), // live: no revocation note + ); + let db = KeyDb::parse(&cfg); + assert_eq!(db.host_certs.len(), 5); + + // The first cert byte of each cert host_certs(mkb) hands back, sorted. + let markers = |mkb: Option| { + let mut m: Vec = db + .host_certs(mkb) + .iter() + .map(|c| c.certificate[0]) + .collect(); + m.sort_unstable(); + m + }; + + // gen 77: every revocation has fired; only the live cert survives. + assert_eq!(markers(Some(77)), vec![0x11]); + // gen 50: below every revocation ⇒ all five are usable. + assert_eq!(markers(Some(50)), vec![0x11, 0x53, 0x58, 0x70, 0x72]); + // gen 58: revoked-at-53 and revoked-at-58 are out at the boundary + // (disc_gen < revoked is false); 70, 72 and the live cert remain. + assert_eq!(markers(Some(58)), vec![0x11, 0x70, 0x72]); + // gen 70: 70 itself is out at its own generation; only 72 + live remain. + assert_eq!(markers(Some(70)), vec![0x11, 0x72]); + // unknown disc MKB ⇒ cannot filter ⇒ every cert is returned. + assert_eq!(markers(None), vec![0x11, 0x53, 0x58, 0x70, 0x72]); +} + +// ── Cert selection by the embedded 6-byte Host ID ────────────────────── + +/// The AACS host ID lives INSIDE the 92-byte cert (offset 4, 6 bytes): a +/// cert beginning `02 01 00 5c ff ff 80 00 02 10 …` carries host id +/// `ff ff 80 00 02 10`. The parser keeps the cert bytes verbatim, so a +/// caller can select the cert for a specific host by that embedded id. +#[test] +fn host_cert_selected_by_embedded_6_byte_host_id() { + let cert_hex = |host_id: [u8; 6]| { + let mut bytes = vec![0x02u8, 0x01, 0x00, 0x5c]; // 4-byte header + bytes.extend_from_slice(&host_id); // host id at offset 4 + bytes.resize(92, 0xcc); // pad to the 92-byte AACS 1.0 cert length + bytes.iter().map(|b| format!("{b:02x}")).collect::() + }; + let id_a = [0xff, 0xff, 0x80, 0x00, 0x02, 0x10]; + let id_b = [0xff, 0xff, 0x80, 0x00, 0x02, 0x11]; + let cfg = format!( + "| HC | HOST_PRIV_KEY 0x{p} | HOST_CERT 0x{a}\n\ + | HC | HOST_PRIV_KEY 0x{p} | HOST_CERT 0x{b}\n", + p = "00".repeat(20), + a = cert_hex(id_a), + b = cert_hex(id_b), + ); + let db = KeyDb::parse(&cfg); + assert_eq!(db.host_certs.len(), 2); + + let find = |want: [u8; 6]| { + db.host_certs + .iter() + .find(|hc| hc.cert.certificate.get(4..10) == Some(&want[..])) + }; + let hit = find(id_b).expect("the cert carrying host id …0211 must be found"); + assert_eq!(&hit.cert.certificate[4..10], &id_b); + assert_eq!(hit.cert.certificate.len(), 92); + // A host id no cert carries selects nothing — and the byte-range slice + // must not panic when it does not match. + assert!(find([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).is_none()); +} + +/// A 1.0-only keydb (only `| HC |` rows, no `| HC2 |`) must leave every +/// cert's AACS 2.0 fields empty — no phantom v2 credentials appear. +#[test] +fn keydb_with_only_hc_rows_yields_no_v2_certs() { + let cfg = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n\ + | HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(20), + "ab".repeat(92), + "01".repeat(20), + "cd".repeat(92), + ); + let db = KeyDb::parse(&cfg); + assert_eq!(db.host_certs.len(), 2); + assert!( + db.host_certs + .iter() + .all(|hc| hc.cert.private_key_v2.is_none()), + "no HC2 rows ⇒ no v2 private keys" + ); + assert!( + db.host_certs + .iter() + .all(|hc| hc.cert.certificate_v2.is_none()), + "no HC2 rows ⇒ no v2 certs" + ); +} + +/// The HC2 (AACS 2.0, type 0x11) slot: a full HC + HC2 pair parses into the +/// 1.0 fields AND the v2 fields, with the v2 private key exactly 32 bytes +/// and the v2 cert 132 bytes. Complements the field-ordering HC2 tests by +/// asserting the concrete v2 byte lengths a valid slot must yield. +#[test] +fn hc2_slot_parses_into_v2_key_and_cert_with_correct_lengths() { + let cfg = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n\ + | HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(20), + "00".repeat(92), + "5b".repeat(32), + "6c".repeat(132), + ); + let db = KeyDb::parse(&cfg); + assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC"); + let c = &db.host_certs[0].cert; + assert_eq!(c.certificate.len(), 92, "1.0 cert retained"); + assert_eq!( + c.private_key_v2, + Some([0x5bu8; 32]), + "32-byte v2 private key" + ); + assert_eq!( + c.certificate_v2.as_ref().map(|v| v.len()), + Some(132), + "132-byte v2 cert" + ); +} + +// KEYDB-parser integration tests relocated from libfreemkv; exercise the parser end-to-end +// against a real keydb.cfg. + +/// REQUIRES a real keydb; `#[ignore]` by default. Run with: +/// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`. +#[test] +#[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] +fn test_vuk_derivation() { + // Pick any UHD entry with a known MK, VID, and VUK from KEYDB. + // VUK = AES-DEC(MK, VID) XOR VID + let path = real_keydb_path(); + + let db = KeyDb::load(&path).unwrap(); + + // Find a disc with both MK, vid, and VUK so we can verify derivation + let entry = db + .disc_entries + .values() + .find(|e| e.media_key.is_some() && e.vid.is_some() && e.vuk.is_some()) + .expect("No disc with MK + VID + VUK"); + + let mk = entry.media_key.unwrap(); + let vid = entry.vid.unwrap(); + let expected_vuk = entry.vuk.unwrap(); + + let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid); + assert_eq!( + derived, expected_vuk, + "VUK derivation failed for disc: {} (hash {})", + entry.title, entry.disc_hash + ); + eprintln!("VUK derivation verified for: {}", entry.title); +} + +/// REQUIRES a real keydb AND an encrypted unit dump; `#[ignore]` by default. +/// Run with: `KEYDB_PATH= ENCRYPTED_UNIT_PATH= \ +/// cargo test --release -- --ignored`. +#[test] +#[ignore = "needs KEYDB_PATH + ENCRYPTED_UNIT_PATH: cargo test -- --ignored"] +fn test_decrypt_real_unit() { + // Try decrypting a real encrypted aligned unit from a UHD sample. + // This disc is AACS 2.0 (BEE) so unit key alone won't work — bus + // decryption is needed first — but this verifies the pipeline. + let unit_path = std::path::PathBuf::from( + std::env::var("ENCRYPTED_UNIT_PATH") + .expect("ENCRYPTED_UNIT_PATH must point at an encrypted aligned unit"), + ); + assert!( + unit_path.exists(), + "ENCRYPTED_UNIT_PATH does not exist: {}", + unit_path.display() + ); + + let original = std::fs::read(&unit_path).unwrap(); + assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN); + assert!( + !libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs), + "Unit should be encrypted" + ); + + let kp = real_keydb_path(); + let db = KeyDb::load(&kp).unwrap(); + + // Candidate entries: any UHD entry that carries unit keys. + let candidate_entries: Vec<&DiscEntry> = db + .disc_entries + .values() + .filter(|e| !e.unit_keys.is_empty()) + .collect(); + + eprintln!("Found {} entries with unit keys", candidate_entries.len()); + // Without candidates nothing below runs and the test would "pass" having + // exercised nothing — a parser regression that dropped every unit key + // would look exactly like the expected AACS 2.0 outcome. + assert!( + !candidate_entries.is_empty(), + "the fixture keydb must carry at least one entry with unit keys" + ); + + // Try each entry's unit keys: apply the key, then ask whether it opened + // the unit (the segregated primitives — decrypt, then structural check). + let mut attempts = 0usize; + let mut any_key_changed_the_unit = false; + for entry in &candidate_entries { + for (_, key) in &entry.unit_keys { + let mut unit = original.clone(); + libfreemkv::test_util::decrypt_unit(&mut unit, key); + attempts += 1; + any_key_changed_the_unit |= unit != original; + if libfreemkv::aacs::content::is_clean(&unit, libfreemkv::disc::ContentFormat::BdTs) { + eprintln!("SUCCESS: Decrypted with entry {}", entry.disc_hash); + // Count TS sync bytes + let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count(); + eprintln!(" TS sync bytes: {}/32", ts); + return; + } + } + } + + // Reaching here is the EXPECTED outcome for this AACS 2.0 (BEE) sample. + assert!(attempts > 0, "no unit key was tried at all"); + assert!( + any_key_changed_the_unit, + "decrypt_unit left the aligned unit byte-identical for all {attempts} keys — \ + the decrypt primitive is a no-op, not a failed key" + ); + eprintln!( + "No unit key worked over {attempts} attempts \ + (expected for AACS 2.0 BEE disc — needs read_data_key)" + ); +} + +/// REQUIRES a real keydb; `#[ignore]` by default. Run with: +/// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`. +#[test] +#[ignore = "needs a real keydb.cfg: KEYDB_PATH= cargo test -- --ignored"] +fn test_resolve_keys_vuk_path() { + // Test the full resolve chain using VUK path + let path = real_keydb_path(); + let db = KeyDb::load(&path).unwrap(); + + // Find any BD entry with a VUK and unit keys. `expect`, not an early + // `return`: a `return` here would ship green even if a parser + // regression stopped populating `vid`/`vuk` entirely. + let entry = db + .disc_entries + .values() + .find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.vid.is_some()) + .expect("the fixture keydb must carry an entry with VUK + unit keys + VID"); + let vuk = entry.vuk.unwrap(); + let vid = entry.vid.unwrap(); + let hash_hex = format!("0x{}", libfreemkv::hex::strip_hex_prefix(&entry.disc_hash)); + + // We need the actual Unit_Key_RO.inf from the disc to compute disc hash. + // Since we don't have it, we can at least test that the KEYDB lookup + // works with a known hash. + let found = db.find_disc(&hash_hex); + assert!(found.is_some()); + assert_eq!(found.unwrap().vuk, Some(vuk)); + + // Verify VUK derivation if we have MK + VID + if let Some(mk) = entry.media_key { + let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid); + assert_eq!(derived, vuk, "VUK derivation mismatch"); + eprintln!("VUK derivation verified"); + } +} diff --git a/src/keydb_tests.rs b/src/keydb_tests.rs new file mode 100644 index 0000000..6cfca21 --- /dev/null +++ b/src/keydb_tests.rs @@ -0,0 +1,1311 @@ +use super::*; +use crate::keydb_format::DiscEntry; + +/// A fixed, in-the-past mtime — `touch -t CCYYMMDDhhmm.ss`. +#[cfg(unix)] +const PINNED_MTIME: &str = "202601011200.00"; + +// Force a file's mtime to PINNED_MTIME so two different files can be made bit-identical to +// a (len, mtime) stamp on purpose, rather than hoping the filesystem produces that case. +#[cfg(unix)] +fn pin_mtime(p: &std::path::Path) { + let ok = std::process::Command::new("touch") + .arg("-t") + .arg(PINNED_MTIME) + .arg(p) + .status() + .expect("touch must run") + .success(); + assert!(ok, "touch -t failed"); +} + +use libfreemkv::aacs::derive::derive_vuk; +use libfreemkv::aacs::types::DeviceKey; +use std::collections::HashMap; + +// ── A test ResolveCtx, so get_unit_keys's path selection can be exercised without +// a real Disc. Each accessor returns exactly what a case needs. ────────── +struct MockCtx { + disc_hash: String, + vid: Option, + mkb: Vec, + enc_title_keys: Vec<[u8; 16]>, +} +impl ResolveCtx for MockCtx { + fn disc_hash(&self) -> &str { + &self.disc_hash + } + fn title(&self) -> Option<&str> { + None + } + fn vid(&self) -> Option { + self.vid + } + fn mkb(&self) -> Result<&[u8], Error> { + Ok(&self.mkb) + } + fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { + Ok(&self.enc_title_keys) + } + fn samples(&self, _n: usize) -> Result>, Error> { + Ok(Vec::new()) + } +} + +fn ctx(hash: &str, enc: Vec<[u8; 16]>, vid: Option) -> MockCtx { + MockCtx { + disc_hash: hash.into(), + vid, + mkb: Vec::new(), + enc_title_keys: enc, + } +} + +fn dk() -> DeviceKey { + DeviceKey { + key: [0x22u8; 16], + node: 1, + uv: 2, + u_mask_shift: 0, + } +} + +fn blank_entry(hash: &str) -> DiscEntry { + DiscEntry { + disc_hash: hash.into(), + title: String::new(), + media_key: None, + vid: None, + vuk: None, + unit_keys: Vec::new(), + mkb_version: None, + volume_size: None, + is_uhd: false, + } +} + +fn db_with(entry: DiscEntry, device_keys: Vec) -> KeyDb { + let mut entries = HashMap::new(); + entries.insert(entry.disc_hash.clone(), entry); + KeyDb { + device_keys, + processing_keys: Vec::new(), + host_certs: Vec::new(), + disc_entries: entries, + } +} + +// The committed (cps, key) pairs resolve_and_apply derives from a source's Unit Keys (idx +// -> idx + 1). KATs compare against THIS. +fn committed(uks: &[UnitKey]) -> Vec<(u32, [u8; 16])> { + uks.iter() + .map(|u| (u.idx.saturating_add(1), u.key)) + .collect() +} + +const HASH: &str = "0xaabb"; + +// MockCtx implements the full trait so it can stand in for a real disc, +// but KeydbSource never calls title()/samples(); 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()); +} + +// Issue #46 — de-conflated resolution: a matched entry with a Media Key but +// NO VID on this path (locked VID-per-path rule) is reported `matched` with a +// `NoVid` miss path — the exact case behind the reporter's "no entry". +#[test] +fn resolve_matched_media_key_without_vid_reports_no_vid_not_a_miss() { + let mut e = blank_entry(HASH); + e.media_key = Some([0x33u8; 16]); + let db = db_with(e, Vec::new()); + // enc_title_keys present so derivation is attempted; ctx supplies no VID. + let r = KeydbSource::resolve_from(&db, &ctx(HASH, vec![[0x44u8; 16]], None)); + assert!(r.keys.is_empty(), "no VID -> nothing derivable"); + assert!(r.matched, "the disc WAS found — not a true miss"); + assert_eq!(r.miss_path, vec![KeyNode::NoVid]); + let shape = r.shape.expect("a matched entry carries a shape"); + assert!(shape.has_media_key); + assert!(!shape.has_vuk); + assert!(!shape.has_unit_keys); + assert_eq!(shape.enc_title_keys_len, 1); + assert!(!shape.vid_available); +} + +// KU J23: stored unit keys for SOME CPS units plus a Media Key with no VID. The stored +// keys come back, and the miss path still says `NoVid`: the VID would derive the rest. +#[test] +fn partial_stored_keys_with_a_media_key_and_no_vid_report_no_vid() { + let mut e = blank_entry(HASH); + e.media_key = Some([0x33u8; 16]); + e.unit_keys = vec![(1, [0x55u8; 16])]; + let db = db_with(e, Vec::new()); + let r = KeydbSource::resolve_from(&db, &ctx(HASH, vec![[0x44u8; 16]; 2], None)); + assert_eq!(r.keys.len(), 1, "the stored key"); + assert_eq!(r.miss_path, vec![KeyNode::NoVid]); +} + +// A hash that is NOT in the keydb is the one true miss: not matched, no +// shape, no miss path (the library renders this as `no entry`). +#[test] +fn resolve_unmatched_hash_is_a_true_miss() { + let db = db_with(blank_entry(HASH), Vec::new()); + let r = KeydbSource::resolve_from(&db, &ctx("0xdeadbeef", Vec::new(), None)); + assert!(!r.matched); + assert!(r.keys.is_empty()); + assert!(r.shape.is_none()); + assert!(r.miss_path.is_empty()); + // The store size travels with the miss so the verdict can name it. + assert_eq!(r.entries_loaded, 1); +} + +// Matched but no derivation material at all (no stored keys, no enc title +// keys): matched with an EMPTY miss path, which the library renders as the +// generic `no derivable key` — still distinct from a true miss. +#[test] +fn resolve_matched_with_no_material_has_empty_miss_path() { + let db = db_with(blank_entry(HASH), Vec::new()); + let r = KeydbSource::resolve_from(&db, &ctx(HASH, Vec::new(), None)); + assert!(r.matched); + assert!(r.keys.is_empty()); + assert!( + r.miss_path.is_empty(), + "no specific reason -> library supplies NoDerivableKey" + ); + let shape = r.shape.expect("matched -> shape present"); + assert!(!shape.has_vuk && !shape.has_media_key && !shape.has_unit_keys); +} + +// Matched entry that DOES resolve: keys present, matched, empty miss path, +// and the shape reflects the stored unit keys. +#[test] +fn resolve_matched_with_stored_unit_keys_reports_keys_and_shape() { + let mut e = blank_entry(HASH); + e.unit_keys = vec![(1, [0xA0u8; 16])]; + let db = db_with(e, Vec::new()); + let r = KeydbSource::resolve_from(&db, &ctx(HASH, Vec::new(), None)); + assert!(!r.keys.is_empty()); + assert!(r.matched); + assert!(r.miss_path.is_empty()); + let shape = r.shape.expect("matched -> shape"); + assert!(shape.has_unit_keys); + assert_eq!(shape.unit_keys_len, 1); +} + +// KAT (a): disc with terminal Unit Keys, no enc_title_keys. Stored terminal unit keys are +// returned with CPS numbering preserved. +#[test] +fn kat_a_disc_with_unit_keys_is_terminal_and_preserves_cps_numbering() { + let mut e = blank_entry(HASH); + e.unit_keys = vec![(1, [0xA0u8; 16]), (2, [0xB1u8; 16])]; + // VUK present but no enc_title_keys → nothing to boil, stored stands. + e.vuk = Some([0x11u8; 16]); + let db = db_with(e, Vec::new()); + + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)); + assert_eq!( + committed(&got), + vec![(1u32, [0xA0u8; 16]), (2u32, [0xB1u8; 16])], + "terminal keydb unit keys must commit byte-identically to the stored (cps, key) pairs" + ); +} + +// A CPS unit number of 0 is invalid (valid numbering is >= 1); it must be +// skipped, not mapped to idx 0 where `num - 1` would collide with unit 1. +#[test] +fn stored_unit_key_with_cps_number_zero_is_skipped() { + let mut e = blank_entry(HASH); + e.unit_keys = vec![(0, [0xEEu8; 16]), (1, [0xA0u8; 16])]; + let db = db_with(e, Vec::new()); + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)); + let keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect(); + assert!( + !keys.contains(&[0xEEu8; 16]), + "a unit-0 key must be dropped, never mapped onto idx 0" + ); + assert_eq!(committed(&got), vec![(1u32, [0xA0u8; 16])]); +} + +// Orphan-unit completeness (the real keydb bug): a PARTIAL stored list plus a VUK that +// boils BOTH declared units must return BOTH, not shadow the VUK with the partial list. +#[test] +fn union_partial_stored_plus_vuk_yields_all_declared_units() { + let vuk = [0x5Au8; 16]; + let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; // two declared CPS units + let derived = crate::uks_from_vuk(&vuk, &enc); // [d0, d1] + + let mut e = blank_entry(HASH); + e.unit_keys = vec![(1, [0xA0u8; 16])]; // PARTIAL: only uk1 stored + e.vuk = Some(vuk); + let db = db_with(e, Vec::new()); + + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); + let got_keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect(); + assert!(got_keys.contains(&[0xA0u8; 16]), "the stored uk1 is kept"); + assert!( + got_keys.contains(&derived[1].key), + "the VUK-derived SECOND CPS unit is added, not shadowed by the partial stored list" + ); + assert!( + got.len() >= 2, + "a partial stored list must no longer shadow the complete VUK" + ); +} + +// ── KAT (b): disc with VUK ──────────────────────────────────────────────── +/// A hash hit with only a VUK derives the terminal keys via `uk_from_vuk` +/// over the disc's encrypted title keys — byte-identical to the OLD +/// `Key::Volume(vuk)` → resolver path (which called the same primitive). +#[test] +fn kat_b_disc_with_vuk_derives_via_uk_from_vuk() { + let vuk = [0x5Au8; 16]; + // Two encrypted title keys (arbitrary ciphertext; both sides decrypt the + // SAME bytes, which is the parity claim). + let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; + + let mut e = blank_entry(HASH); + e.vuk = Some(vuk); + let db = db_with(e, Vec::new()); + + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); + // Reference: the boil primitive directly — the OLD derivation. + let expect = crate::uks_from_vuk(&vuk, &enc); + assert_eq!( + got, expect, + "VUK path must equal uk_from_vuk(vuk, enc_title_keys)" + ); + // And the committed numbering is 1-based positional. + assert_eq!( + committed(&got).iter().map(|(n, _)| *n).collect::>(), + vec![1, 2] + ); +} + +// KAT (c): MK + physical (unlock) VID derives MK -> VUK -> UK. The PHYSICAL VID must win +// over the keydb's stored VID. +#[test] +fn kat_c_disc_with_mk_uses_physical_vid_over_keydb_vid() { + let mk = [0x77u8; 16]; + let vid_phys = [0x42u8; 16]; + let vid_keydb = [0x99u8; 16]; // deliberately different — must NOT be used + let enc = vec![[0x10u8; 16]]; + + let mut e = blank_entry(HASH); + e.media_key = Some(mk); + e.vid = Some(vid_keydb); + let db = db_with(e, Vec::new()); + + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys)))); + // Reference uses the PHYSICAL VID. + let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc); + assert_eq!(got, expect, "MK path must use the physical (unlock) VID"); + // Sanity: it must NOT match the keydb-VID derivation (different VID → + // different VUK → different keys), proving the right VID was selected. + let wrong = crate::uks_from_vuk(&derive_vuk(&mk, &vid_keydb), &enc); + assert_ne!( + got, wrong, + "must not derive with the keydb VID when a physical VID exists" + ); +} + +// ── KAT (d): disc with MK + keydb VID (ISO path, no physical VID) ────────── +/// A hash hit with a Media Key but NO physical VID falls back to the keydb +/// entry's stored VID (`vid`, the `I` field) — the non-physical / ISO +/// path — and derives `MK → VUK → UK` against it. +#[test] +fn kat_d_disc_with_mk_falls_back_to_keydb_vid() { + let mk = [0x77u8; 16]; + let vid_keydb = [0x99u8; 16]; + let enc = vec![[0x10u8; 16], [0x20u8; 16]]; + + let mut e = blank_entry(HASH); + e.media_key = Some(mk); + e.vid = Some(vid_keydb); + let db = db_with(e, Vec::new()); + + // ctx.vid() == None → ISO path. + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); + let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_keydb), &enc); + assert_eq!( + got, expect, + "MK path must use the keydb VID when no physical VID is present" + ); +} + +// ── KAT (e): disc with MK + NO VID anywhere → empty ─────────────────────── +/// A hash hit with a Media Key but neither a physical VID nor a stored keydb +/// VID cannot derive a VUK — the locked VID-per-path rule. It must return +/// EMPTY, never a guessed/zero-VID key (wrong-keys safety). +#[test] +fn kat_e_disc_with_mk_no_vid_returns_empty() { + let mut e = blank_entry(HASH); + e.media_key = Some([0x77u8; 16]); + e.vid = None; // no keydb VID + let db = db_with(e, Vec::new()); + + // ctx.vid() == None and no keydb VID → cannot derive. + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, vec![[0x10u8; 16]], None)); + assert!( + got.is_empty(), + "MK with no VID source must yield no keys, never a guess" + ); +} + +/// Build a 4-byte MKB record header (type + 3-byte big-endian total length, +/// header included) and append `body`. No crypto — just the record framing +/// libfreemkv's MKB parser expects. +fn mkb_record(rec_type: u8, body: &[u8]) -> Vec { + let total = 4 + body.len(); + let mut rec = vec![ + rec_type, + ((total >> 16) & 0xFF) as u8, + ((total >> 8) & 0xFF) as u8, + (total & 0xFF) as u8, + ]; + rec.extend_from_slice(body); + rec +} + +// KAT (f): disc with NO per-disc MK, resolved via the keydb PK pool (PK -> MK -> VUK -> UK) +// with a known-answer MKB/PK construction. +#[test] +fn kat_f_disc_with_pk_pool_yields_uks() { + // Planted PK and the MK it resolves to (see libfreemkv boil.rs KAT). + let pk: [u8; 16] = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, + 0x00, + ]; + let mk: [u8; 16] = [ + 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, + 0xAF, + ]; + // cvalue = AES-E(pk, mk_raw); verify = AES-E(mk, magic||pad); SD uv. + let cv: [u8; 16] = [ + 0x72, 0x23, 0x96, 0x80, 0xB5, 0xC5, 0x2B, 0x9D, 0x63, 0xE9, 0xEC, 0x92, 0xCF, 0xAF, 0xDE, + 0x1B, + ]; + let mk_dv: [u8; 16] = [ + 0x05, 0xA7, 0x4C, 0xC9, 0xD0, 0x2E, 0x9F, 0x4B, 0x42, 0xDF, 0x2C, 0x0A, 0xAD, 0x79, 0x58, + 0xF4, + ]; + let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00]; + + // Synthetic MKB: type/version (0x10), verify (0x86), one-entry SD index + // (0x04 = [u_mask_shift=0][uv]), one-entry cvalue table (0x05). + let mut sd = vec![0u8]; + sd.extend_from_slice(&uv); + let mut mkb = Vec::new(); + mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + mkb.extend_from_slice(&mkb_record(0x86, &mk_dv)); + mkb.extend_from_slice(&mkb_record(0x04, &sd)); + mkb.extend_from_slice(&mkb_record(0x05, &cv)); + + // Disc entry with NO stored MK/VUK/UK — only the global PK pool can resolve. + let e = blank_entry(HASH); + let mut db = db_with(e, Vec::new()); + db.processing_keys = vec![pk]; + + let enc = vec![[0x10u8; 16], [0x20u8; 16]]; + let vid_phys = [0x42u8; 16]; + let ctx = MockCtx { + disc_hash: HASH.into(), + vid: Some(Vid(vid_phys)), + mkb, + enc_title_keys: enc.clone(), + }; + + let got = KeydbSource::unit_keys_from(&db, &ctx); + assert!(!got.is_empty(), "PK pool must yield Unit Keys for the disc"); + // Byte-identical to deriving from the recovered MK via the public chain. + let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc); + assert_eq!( + got, expect, + "PK path must equal MK → VUK → UK from the recovered Media Key" + ); +} + +/// A PK pool that does NOT resolve the disc's MKB yields nothing — never a +/// wrong key. (Same MKB as KAT (f) but a corrupt PK.) +#[test] +fn pk_pool_that_does_not_validate_yields_no_key() { + let mk_dv: [u8; 16] = [ + 0x05, 0xA7, 0x4C, 0xC9, 0xD0, 0x2E, 0x9F, 0x4B, 0x42, 0xDF, 0x2C, 0x0A, 0xAD, 0x79, 0x58, + 0xF4, + ]; + let cv: [u8; 16] = [ + 0x72, 0x23, 0x96, 0x80, 0xB5, 0xC5, 0x2B, 0x9D, 0x63, 0xE9, 0xEC, 0x92, 0xCF, 0xAF, 0xDE, + 0x1B, + ]; + let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00]; + let mut sd = vec![0u8]; + sd.extend_from_slice(&uv); + let mut mkb = Vec::new(); + mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + mkb.extend_from_slice(&mkb_record(0x86, &mk_dv)); + mkb.extend_from_slice(&mkb_record(0x04, &sd)); + mkb.extend_from_slice(&mkb_record(0x05, &cv)); + + let mut db = db_with(blank_entry(HASH), Vec::new()); + db.processing_keys = vec![[0x00u8; 16]]; // does not validate + + let ctx = MockCtx { + disc_hash: HASH.into(), + vid: Some(Vid([0x42u8; 16])), + mkb, + enc_title_keys: vec![[0x10u8; 16]], + }; + assert!( + KeydbSource::unit_keys_from(&db, &ctx).is_empty(), + "a non-validating PK pool must resolve nothing, never a wrong key" + ); +} + +/// No per-disc entry → no key, even with a universal device-key pool present. +/// Without a matched entry there is no per-disc anchor, so the global pools +/// are never consulted (the cross-disc MK-pool brute stays retired). +#[test] +fn no_disc_hit_yields_no_key() { + let db = db_with(blank_entry("0xother"), vec![dk()]); + let got = + KeydbSource::unit_keys_from(&db, &ctx(HASH, vec![[0x10u8; 16]], Some(Vid([1u8; 16])))); + assert!( + got.is_empty(), + "a hash miss resolves nothing from the keydb" + ); +} + +/// Empty keydb resolves nothing. +#[test] +fn empty_keydb_yields_no_key() { + let db = KeyDb { + device_keys: Vec::new(), + processing_keys: Vec::new(), + host_certs: Vec::new(), + disc_entries: HashMap::new(), + }; + assert!(KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)).is_empty()); +} + +/// A missing keydb file is silent (Ok empty), never an error. +#[test] +fn get_uk_missing_keydb_is_ok_empty() { + let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); + let got = src + .get_unit_keys(&ctx(HASH, Vec::new(), None)) + .expect("missing keydb is not an error"); + assert!(got.is_empty()); +} + +#[test] +fn label_is_keydb() { + assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb"); +} + +// KSK1: keyed by disc hash, so `resolve` asks it once per rip, not once per +// piece (KU §2.3 step 8, N-KU10). +#[test] +fn keydb_source_answer_does_not_depend_on_samples() { + assert!(!KeydbSource::new("/nonexistent/keydb.cfg").answer_depends_on_samples()); + // KU J23: a keydb's VID need is per disc (its `NoVid` miss path), not blanket. + assert!(!KeydbSource::new("/nonexistent/keydb.cfg").uses_vid()); +} + +// A stamp with no mtime (mtime-less FS) or a future mtime (clock skew) must +// not be stuck un-settled forever — it now settles via the inode-identity +// fallback once the observation has aged past the granularity. +#[test] +fn is_settled_falls_back_when_mtime_is_absent_or_in_the_future() { + use std::time::{Duration, SystemTime}; + let entry = |modified: Option, stamped_at: SystemTime| CacheEntry { + stamp: FileStamp { + dev: 1, + ino: 2, + len: 3, + modified, + }, + stamped_at, + db: Arc::new(KeyDb::empty()), + stats: crate::keydb_format::ParseStats::default(), + }; + let gran = Duration::from_secs(2); + let now = SystemTime::now(); + + // None mtime, stamped well in the past → settled via the fallback. + assert!( + entry(None, now - Duration::from_secs(3600)).is_settled(gran), + "a mtime-less entry must settle once it has aged past the granularity" + ); + // None mtime, just stamped → still inside the settle window, NOT settled. + assert!( + !entry(None, now).is_settled(gran), + "a freshly stamped mtime-less entry is too young to trust yet" + ); + // A FUTURE mtime (clock skew) with an old stamp → still settles via the + // fallback rather than being wedged un-settled forever. + assert!( + entry( + Some(now + Duration::from_secs(3600)), + now - Duration::from_secs(3600) + ) + .is_settled(gran), + "a future mtime must not wedge the entry un-settled forever" + ); + // Sanity: a real past mtime still settles the normal way. + assert!( + entry(Some(now - Duration::from_secs(3600)), now).is_settled(gran), + "a normal past mtime still settles" + ); +} + +/// Best-first with no disc input: unannotated certs in file order, then revoked +/// ones by latest revocation. The trait ignores its MKB argument. +#[test] +fn host_certs_ranked_orders_unannotated_then_latest_revocation_first() { + let dir = scratch("ranked"); + let path = dir.join("keydb.cfg"); + let hc = |marker: u8, note: &str| { + format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}{}\n", + "00".repeat(20), + format!("{marker:02x}").repeat(92), + note + ) + }; + let cfg = [ + hc(0x68, " ; Revoked in MKBv68"), + hc(0xA1, ""), + hc(0x72, " ; Revoked in MKBv72"), + hc(0xB2, ""), + ] + .concat(); + std::fs::write(&path, cfg).unwrap(); + let src = KeydbSource::new(&path); + let markers = + |certs: Vec| -> Vec { certs.iter().map(|c| c.certificate[0]).collect() }; + let want = vec![0xA1, 0xB2, 0x72, 0x68]; + assert_eq!( + markers(src.host_certs()), + want, + "inherent (production credentials)" + ); + assert_eq!( + markers(KeySource::host_certs(&src, Some(99))), + want, + "trait ignores mkb" + ); + assert_eq!(markers(KeySource::host_certs(&src, None)), want); + std::fs::remove_dir_all(&dir).ok(); +} + +/// No keydb → no host credentials, not an error (inherent and trait forms). +#[test] +fn host_certs_empty_when_keydb_missing() { + let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); + assert!(src.host_certs().is_empty()); + assert!(KeySource::host_certs(&src, None).is_empty()); + assert!(KeySource::host_certs(&src, Some(68)).is_empty()); +} + +/// The TRAIT `host_certs` surfaces a `| HC |` row for any `mkb` argument. +/// Placeholder all-zero material (never a real key). +#[test] +fn trait_host_certs_returns_keydb_hc_row() { + let dir = std::env::temp_dir().join(format!("fmk_hc_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("keydb.cfg"); + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(20), + "00".repeat(92) + ); + std::fs::write(&path, line).unwrap(); + + let src = KeydbSource::new(&path); + // A cert with no revocation annotation is returned for ANY mkb arg. + let certs = KeySource::host_certs(&src, Some(70)); + assert_eq!(certs.len(), 1, "trait host_certs must surface the HC row"); + assert_eq!(certs[0].certificate.len(), 92); + + std::fs::remove_dir_all(&dir).ok(); +} + +// Caching: the 62 MiB / 181k-entry keydb is parsed ONCE. A single AACS-cert rip drives this +// source at least three times; the mtime is pinned into the PAST first, since an entry is +// only trusted once settled. +#[cfg(unix)] +#[test] +fn repeated_lookups_parse_the_keydb_once() { + let dir = scratch("cache-hit"); + let path = dir.join("keydb.cfg"); + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(20), + "00".repeat(92) + ); + std::fs::write(&path, &line).unwrap(); + pin_mtime(&path); + + let src = KeydbSource::new(&path); + assert_eq!(src.host_certs().len(), 1); + assert_eq!(KeySource::host_certs(&src, Some(70)).len(), 1); + assert!( + src.get_unit_keys(&ctx(HASH, Vec::new(), None)) + .unwrap() + .is_empty() + ); + assert_eq!( + src.parse_count(), + 1, + "three lookups over an unchanged file must parse it once" + ); +} + +// The cache must not go stale: a keydb replaced UNDERNEATH the source has a different +// size/mtime stamp and must be re-read. +#[test] +fn a_changed_keydb_file_is_reparsed() { + let dir = scratch("cache-stamp"); + let path = dir.join("keydb.cfg"); + let hc = |b: &str, n: usize| { + format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(20), + b.repeat(n) + ) + }; + std::fs::write(&path, hc("00", 92)).unwrap(); + let src = KeydbSource::new(&path); + assert_eq!(src.host_certs()[0].certificate.len(), 92); + + // A different LENGTH guarantees a different stamp regardless of the + // filesystem's mtime resolution. + std::fs::write(&path, hc("11", 100)).unwrap(); + assert_eq!( + src.host_certs()[0].certificate.len(), + 100, + "a replaced keydb must be re-read, not served from a stale cache" + ); + assert_eq!(src.parse_count(), 2, "one parse per distinct file"); +} + +// Two stale-cache discriminators, tested ONE AT A TIME (each test disables the other via +// `mtime_granularity`). Discriminator 1, the inode: an ATOMIC RENAME must be re-read even +// with identical length and mtime. +#[cfg(unix)] +#[test] +fn a_same_length_same_mtime_rename_is_still_re_read() { + let dir = scratch("cache-inode"); + let path = dir.join("keydb.cfg"); + let replacement = dir.join("keydb.cfg.new"); + let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n"); + + std::fs::write(&path, body(&"01".repeat(16))).unwrap(); + pin_mtime(&path); + let src = KeydbSource::new(&path).with_mtime_granularity(std::time::Duration::ZERO); + assert_eq!( + src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key, + [0x01u8; 16] + ); + + // Same length, same mtime, different inode. + std::fs::write(&replacement, body(&"02".repeat(16))).unwrap(); + pin_mtime(&replacement); + std::fs::rename(&replacement, &path).unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().len(), + body(&"01".repeat(16)).len() as u64, + "the two keydbs must be the same size for this test to mean anything" + ); + + assert_eq!( + src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key, + [0x02u8; 16], + "a renamed-in keydb of the same size and mtime must not be served from the stale cache" + ); + assert_eq!(src.parse_count(), 2, "the replacement must be re-parsed"); +} + +// Discriminator 2, the settle window: a keydb rewritten IN PLACE (same inode/length, mtime +// forced back) must still be re-read while too young to trust. +#[cfg(unix)] +#[test] +fn an_in_place_rewrite_under_an_unsettled_stamp_is_still_re_read() { + use std::io::Write as _; + let dir = scratch("cache-settle"); + let path = dir.join("keydb.cfg"); + let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n"); + + std::fs::write(&path, body(&"01".repeat(16))).unwrap(); + pin_mtime(&path); + let ino_before = std::fs::metadata(&path) + .map(|m| std::os::unix::fs::MetadataExt::ino(&m)) + .unwrap(); + let src = KeydbSource::new(&path) + .with_mtime_granularity(std::time::Duration::from_secs(10 * 365 * 24 * 3600)); + assert_eq!( + src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key, + [0x01u8; 16] + ); + + // Overwrite the SAME inode in place (no truncate, no rename), same + // length, then force the mtime back: every field of the old stamp + // matches. + let mut f = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + f.write_all(body(&"02".repeat(16)).as_bytes()).unwrap(); + f.sync_all().unwrap(); + drop(f); + pin_mtime(&path); + assert_eq!( + std::fs::metadata(&path) + .map(|m| std::os::unix::fs::MetadataExt::ino(&m)) + .unwrap(), + ino_before, + "the rewrite must reuse the inode for this test to mean anything" + ); + + assert_eq!( + src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key, + [0x02u8; 16], + "an in-place rewrite under an indistinguishable stamp must not be served from cache" + ); +} + +// A corrupt-but-parseable keydb must keep saying so on EVERY hit, not just the parse that +// produced the summary (a cache hit skips `parse`). +#[cfg(unix)] +#[test] +fn a_corrupt_keydb_warns_on_every_lookup_not_only_on_the_parse() { + let dir = scratch("cache-warn"); + let path = dir.join("keydb.cfg"); + // Parseable (one good disc row) but damaged: two rows the parser must + // reject and count. + std::fs::write( + &path, + "0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n\ + | PK | 0xnothex\n\ + | DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n", + ) + .unwrap(); + pin_mtime(&path); + + let src = KeydbSource::new(&path); + for _ in 0..3 { + assert!( + !src.get_unit_keys(&ctx(HASH, Vec::new(), None)) + .unwrap() + .is_empty(), + "the healthy row must still resolve — corruption is not a source failure here" + ); + } + assert_eq!( + src.parse_count(), + 1, + "the cache must still do its job: one parse for three lookups" + ); + assert_eq!( + src.warning_count(), + 3, + "the corruption summary must be re-emitted on every cache hit, not once per process" + ); +} + +// A HEALTHY keydb must stay silent — the re-emission above must not turn into a per-lookup +// log line for every operator with an intact file. +#[cfg(unix)] +#[test] +fn a_healthy_keydb_warns_on_no_lookup() { + let dir = scratch("cache-quiet"); + let path = dir.join("keydb.cfg"); + std::fs::write( + &path, + "0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n", + ) + .unwrap(); + pin_mtime(&path); + + let src = KeydbSource::new(&path); + for _ in 0..3 { + let _ = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap(); + } + assert_eq!( + src.warning_count(), + 0, + "an intact keydb must produce no corruption summary at all" + ); +} + +// The cost side of the settle rule, asserted as a decision: a keydb written just now is +// re-read on every lookup until quiet for the granularity. +#[test] +fn a_freshly_written_keydb_is_not_trusted_from_cache() { + let dir = scratch("cache-fresh"); + let path = dir.join("keydb.cfg"); + std::fs::write( + &path, + "0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n", + ) + .unwrap(); + + let src = KeydbSource::new(&path); + assert!( + !src.get_unit_keys(&ctx(HASH, Vec::new(), None)) + .unwrap() + .is_empty() + ); + assert!( + !src.get_unit_keys(&ctx(HASH, Vec::new(), None)) + .unwrap() + .is_empty() + ); + assert_eq!( + src.parse_count(), + 2, + "a keydb whose mtime is younger than the granularity must be re-read, not trusted" + ); +} + +// `save()` replaces the very file this source reads, so it drops the cached parse ITSELF +// rather than trusting the (size, mtime) stamp — the one change the stamp cannot see. +#[cfg(unix)] +#[test] +fn save_invalidates_the_cache_even_when_the_file_stamp_is_unchanged() { + let dir = scratch("cache-save"); + let path = dir.join("keydb.cfg"); + let k1 = "01".repeat(16); + let k2 = "02".repeat(16); + let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n"); + + std::fs::write(&path, body(&k1)).unwrap(); + pin_mtime(&path); + + let src = KeydbSource::new(&path); + let first = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap(); + assert_eq!(first[0].key, [0x01u8; 16]); + + // Same byte length, written THROUGH this source, then stamped back so + // (size, mtime) is bit-identical to what the cache recorded. + src.save(body(&k2).as_bytes()).expect("save must succeed"); + pin_mtime(&path); + assert_eq!( + std::fs::metadata(&path).unwrap().len(), + body(&k1).len() as u64, + "the two keydbs must be the same size for this test to mean anything" + ); + + let second = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap(); + assert_eq!( + second[0].key, [0x02u8; 16], + "a save through this source must invalidate its own cache, not wait for the stamp to change" + ); +} + +// A corrupt keydb is a source FAILURE, not "this disc has no key": both used to collapse +// into Ok(Vec::new()) with ZERO tracing. Only a MISSING file is genuinely benign. +#[test] +fn a_corrupt_keydb_is_an_error_not_an_empty_answer() { + let dir = scratch("corrupt"); + let path = dir.join("keydb.cfg"); + // Invalid UTF-8 (a lone continuation byte) → InvalidData from load. + std::fs::write(&path, [0x30u8, 0x78, 0xFF, 0xFE, 0x0A]).unwrap(); + + let src = KeydbSource::new(&path); + let err = src + .get_unit_keys(&ctx(HASH, Vec::new(), None)) + .expect_err("a corrupt keydb must not look like a disc with no key"); + assert_eq!( + err.code(), + libfreemkv::error::E_KEYDB_INVALID, + "an unusable keydb must report itself, not E7022" + ); + assert_ne!( + err.code(), + libfreemkv::error::E_NO_DISC_KEY, + "a corrupt keydb says NOTHING about whether this disc has a key" + ); +} + +/// The contrast case, through the same code: a keydb that simply is not +/// there stays the documented benign miss (`Ok(empty)`), so the fix above +/// cannot have turned "no keydb configured" into a hard failure. +#[test] +fn a_missing_keydb_is_still_a_benign_empty() { + let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); + assert!( + src.get_unit_keys(&ctx(HASH, Vec::new(), None)) + .expect("a missing keydb is not an error") + .is_empty() + ); +} + +// ── save / update (moved from libfreemkv::keydb) ────────────────────────── + +// Per project convention, tests never touch /tmp (wiped on reboot). Anchor +// scratch under the crate's target/ (gitignored), not /tmp. +fn scratch(tag: &str) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static CTR: AtomicU64 = AtomicU64::new(0); + let n = CTR.fetch_add(1, Ordering::Relaxed); + let d = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/test-scratch") + .join(format!("keydb-save-{}-{}-{}", std::process::id(), tag, n)); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The headline behaviour of the move: `save` writes to the SOURCE'S OWN +/// path, never a hardcoded default. Creates the parent dir, reports the +/// destination, and round-trips the content. +#[test] +fn save_writes_to_the_sources_own_path() { + let dir = scratch("save-path"); + let target = dir.join("nested").join("mykeys.cfg"); + let src = KeydbSource::new(&target); + + let body = b"0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF = Test\n"; + let result = src.save(body).expect("save must succeed"); + + assert_eq!( + result.path, target, + "save must write to the source's own path, not a default" + ); + assert_eq!(result.entries, 1, "one 0x entry"); + assert!(target.exists(), "keydb file must exist at the source path"); + assert!( + std::fs::read_to_string(&target) + .unwrap() + .contains("0xDEADBEEF"), + "content must round-trip" + ); +} + +/// `update` runs the injected fetch, then saves the returned bytes to the +/// source's path — the transport is supplied by the caller, never built +/// here. +#[test] +fn update_uses_injected_fetch_then_saves_to_path() { + let dir = scratch("update-path"); + let target = dir.join("k.cfg"); + let src = KeydbSource::new(&target); + + let body = b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD = Test\n".to_vec(); + let result = src + .update(|_url| Ok(body.clone()), "http://example.test/keydb.zip") + .expect("update must succeed with a good fetch"); + + assert_eq!(result.path, target, "update must save to the source's path"); + assert_eq!(result.entries, 1); + assert!(target.exists()); +} + +/// A failing injected fetch propagates as-is; nothing is written. +#[test] +fn update_propagates_fetch_error_and_writes_nothing() { + let dir = scratch("update-err"); + let target = dir.join("k.cfg"); + let src = KeydbSource::new(&target); + + let result = src.update( + |_| { + Err(Error::KeydbConnect { + host: "x".to_string(), + }) + }, + "http://x/", + ); + assert!(matches!(result, Err(Error::KeydbConnect { .. }))); + assert!(!target.exists(), "a fetch failure must write no keydb"); +} + +/// `save` rejects bytes with no recognisable keydb entries. +#[test] +fn save_rejects_empty_text() { + let dir = scratch("save-empty"); + let src = KeydbSource::new(dir.join("k.cfg")); + let garbage = b"this is not a keydb\njust random text\n"; + assert!(matches!(src.save(garbage), Err(Error::KeydbInvalid))); +} + +/// DK/PK/HC rows with the RIGHT prefix but MALFORMED content (bad hex, a +/// short cert) parse to nothing, so `save` must count zero real entries and +/// reject — a prefix-only count let unparseable content persist past the +/// `entries > 0` guard. +#[test] +fn save_rejects_prefixed_but_unparseable_dk_pk_hc_rows() { + let dir = scratch("save-prefixed-junk"); + let src = KeydbSource::new(dir.join("k.cfg")); + // Each line has a valid keydb PREFIX but content no parser accepts: PK not + // 16 bytes of hex, DK's DEVICE_KEY not valid hex, HC cert far shorter than + // the 92-byte minimum. + let junk = b"| PK | 0xnothex\n\ + | DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n\ + | HC | HOST_PRIV_KEY 0x00 | HOST_CERT 0x0011\n"; + assert!( + matches!(src.save(junk), Err(Error::KeydbInvalid)), + "prefixed-but-unparseable rows must not count as entries" + ); + + // Contrast: the SAME shapes, but well-formed, ARE counted and saved. + let good = format!( + "| PK | 0x{}\n| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "11".repeat(16), + "00".repeat(20), + "00".repeat(92), + ); + let ok = src + .save(good.as_bytes()) + .expect("well-formed rows must save"); + assert_eq!( + ok.entries, 2, + "one PK + one HC row are the two real entries" + ); +} + +// A keydb.cfg over the size cap or not valid UTF-8 must surface as a +// SOURCE FAILURE (KeydbInvalid), never a silent empty result. +#[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] +fn save_recognises_gzip_magic() { + let dir = scratch("save-gz"); + let src = KeydbSource::new(dir.join("k.cfg")); + let bad_gz = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]; + match src.save(&bad_gz).unwrap_err() { + Error::KeydbParse | Error::KeydbInvalid => {} + e => panic!("wrong error kind for truncated gzip: {e:?}"), + } +} + +/// `save` recognises ZIP magic (PK\x03\x04) and routes to extract_zip; a +/// truncated zip is a parse/invalid error, never a plain-text UTF-8 error. +#[test] +fn save_recognises_zip_magic() { + let dir = scratch("save-zip"); + let src = KeydbSource::new(dir.join("k.cfg")); + let bad_zip = b"PK\x03\x04garbage that is not a real zip"; + match src.save(bad_zip).unwrap_err() { + Error::KeydbParse | Error::KeydbInvalid => {} + e => panic!("wrong error for bad zip: {e:?}"), + } +} + +// `extract_zip` on a WELL-FORMED zip with no `*.cfg`/`*.CFG` member must +// return `Error::KeydbInvalid` — never a parse error or a silent pick. +#[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 (unwritable +// parent) must clean up and surface `Error::KeydbWrite`. +#[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] +fn read_capped_to_string_enforces_size_cap() { + let too_big = vec![b'A'; (MAX_KEYDB_BYTES + 1) as usize]; + assert!(matches!( + read_capped_to_string(std::io::Cursor::new(too_big)), + Err(Error::KeydbInvalid) + )); + let at_cap = vec![b'A'; MAX_KEYDB_BYTES as usize]; + assert!(read_capped_to_string(std::io::Cursor::new(at_cap)).is_ok()); + // Non-UTF-8 is a parse error, not the size error. + assert!(matches!( + read_capped_to_string(std::io::Cursor::new(vec![0xFFu8, 0xFE])), + Err(Error::KeydbParse) + )); +} + +/// `write_atomic` replaces an existing file in place and leaves no stray +/// temp sibling. +#[test] +fn write_atomic_replaces_existing_and_leaves_no_temp() { + let dir = scratch("atomic"); + let path = dir.join("freemkv").join("keydb.cfg"); + + write_atomic(&path, "0xAAAA = old\n").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xAAAA = old\n"); + write_atomic(&path, "0xBBBB = new\n").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xBBBB = new\n"); + + let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap()) + .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:?}"); +} + +/// A failed write (parent path is a file → ENOTDIR on create_dir_all) leaves +/// the prior keydb intact and surfaces KeydbWrite. +#[test] +fn write_atomic_failure_preserves_prior_keydb() { + let dir = scratch("preserve"); + let good = dir.join("keydb.cfg"); + write_atomic(&good, "0xGOOD = keep\n").unwrap(); + + let doomed = good.join("freemkv").join("keydb.cfg"); + assert!(matches!( + write_atomic(&doomed, "0xBAD = partial\n"), + Err(Error::KeydbWrite { .. }) + )); + assert_eq!(std::fs::read_to_string(&good).unwrap(), "0xGOOD = keep\n"); +} + +/// Load-failure verdicts by IO kind — a keydb that can't be read must NEVER +/// masquerade as "this disc has no key". Permission-denied and corrupt/oversized +/// files surface as distinct loud errors; only a genuinely-absent file is the +/// documented benign case (online-only is a valid mode). +#[test] +fn load_failure_maps_io_kind_to_the_right_verdict() { + use std::io::{Error as IoErr, ErrorKind}; + let src = KeydbSource::new("/nonexistent/keydb.cfg"); + + // Missing file → benign None (no keydb present; not an error on its own). + assert!( + src.load_failure(&IoErr::from(ErrorKind::NotFound)) + .is_none(), + "a missing keydb is the benign online-only case" + ); + // Permission denied → LOUD KeydbLoad, never a silent no-key. + assert!( + matches!( + src.load_failure(&IoErr::from(ErrorKind::PermissionDenied)), + Some(Error::KeydbLoad { .. }) + ), + "no read permission must surface as KeydbLoad, not 'no key'" + ); + // Over the size cap / non-UTF8 → LOUD KeydbInvalid (corrupt/truncated). + assert!( + matches!( + src.load_failure(&IoErr::from(ErrorKind::InvalidData)), + Some(Error::KeydbInvalid) + ), + "an unusable keydb must surface as KeydbInvalid" + ); +} diff --git a/src/lib.rs b/src/lib.rs index be0cb89..cf347ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, - } - impl KeySource for CertOnly { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, libfreemkv::Error> { - Ok(Vec::new()) - } - fn host_certs(&self, _mkb: Option) -> Vec { - 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) -> Box { - Box::new(CertOnly { certs }) as Box - } - - /// `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 = 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, 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, - Box::new(FixedDependency(false)) as Box, - ]); - assert!(!all_independent.answer_depends_on_samples()); - - let keydb_then_online = MultiSource::new(vec![ - Box::new(FixedDependency(false)) as Box, - Box::new(FixedDependency(true)) as Box, - ]); - 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, 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, - Box::new(FixedVid(false)) as Box, - ]); - assert!(!neither.uses_vid()); - let keydb_then_online = MultiSource::new(vec![ - Box::new(FixedVid(false)) as Box, - Box::new(FixedVid(true)) as Box, - ]); - 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, 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, - Box::new(FixedTransport(false)) as Box, - ]); - assert!(!none_transport.last_failure_was_transport()); - - let one_transport = MultiSource::new(vec![ - Box::new(FixedTransport(false)) as Box, - Box::new(FixedTransport(true)) as Box, - ]); - 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, libfreemkv::Error> { - Ok(Vec::new()) - } - fn host_certs(&self, mkb: Option) -> Vec { - 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]); - 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; diff --git a/src/lib_tests.rs b/src/lib_tests.rs new file mode 100644 index 0000000..365fce6 --- /dev/null +++ b/src/lib_tests.rs @@ -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, +} +impl KeySource for CertOnly { + fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, libfreemkv::Error> { + Ok(Vec::new()) + } + fn host_certs(&self, _mkb: Option) -> Vec { + 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) -> Box { + Box::new(CertOnly { certs }) as Box +} + +/// `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 = 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, 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, + Box::new(FixedDependency(false)) as Box, + ]); + assert!(!all_independent.answer_depends_on_samples()); + + let keydb_then_online = MultiSource::new(vec![ + Box::new(FixedDependency(false)) as Box, + Box::new(FixedDependency(true)) as Box, + ]); + 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, 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, + Box::new(FixedVid(false)) as Box, + ]); + assert!(!neither.uses_vid()); + let keydb_then_online = MultiSource::new(vec![ + Box::new(FixedVid(false)) as Box, + Box::new(FixedVid(true)) as Box, + ]); + 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, 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, + Box::new(FixedTransport(false)) as Box, + ]); + assert!(!none_transport.last_failure_was_transport()); + + let one_transport = MultiSource::new(vec![ + Box::new(FixedTransport(false)) as Box, + Box::new(FixedTransport(true)) as Box, + ]); + 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, libfreemkv::Error> { + Ok(Vec::new()) + } + fn host_certs(&self, mkb: Option) -> Vec { + 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]); + 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" + ); +} diff --git a/src/online.rs b/src/online.rs index 62c3190..d180d78 100644 --- a/src/online.rs +++ b/src/online.rs @@ -1152,1945 +1152,5 @@ fn parse_uk(hex: &str) -> Option<[u8; 16]> { } #[cfg(test)] -mod tests { - use super::*; - use std::net::Ipv4Addr; - - // host_certs() must return empty WITHOUT touching the network. Uses a - // non-empty base URL to prove the empty result is the deliberate no-op - // stub, not merely "no service configured". - #[test] - fn host_certs_is_noop_empty_no_network() { - let src = OnlineSource::new("http://example.test/keys", "secret"); - assert!( - KeySource::host_certs(&src, None).is_empty(), - "online host_certs must be an empty no-op (no network)" - ); - assert!( - KeySource::host_certs(&src, Some(68)).is_empty(), - "still empty regardless of the MKB generation" - ); - } - - // ── resolve_and_guard ────────────────────────────────────────────────── - - #[test] - fn resolve_and_guard_allows_lan_literals_and_rejects_invalid_ones() { - // Numeric literals resolve without DNS. https:// so a rejection is the - // address guard, not the scheme check (http:// has its own test below). - let lan = format!("{}.{}.{}.{}:8080", 192, 168, 0, 1); - for ok in ["127.0.0.1", "169.254.169.254", "[::1]:9000", lan.as_str()] { - assert!( - resolve_and_guard(&format!("https://{ok}/keys")).is_ok(), - "{ok}" - ); - } - for bad in [ - "0.0.0.0", - "224.0.0.1", - "255.255.255.255", - "240.0.0.1", - "[::]", - "[ff02::1]", - ] { - assert!( - resolve_and_guard(&format!("https://{bad}/keys")).is_err(), - "{bad}" - ); - } - } - - #[test] - fn resolve_and_guard_rejects_bad_scheme() { - assert!(resolve_and_guard("ftp://example.com/keys").is_err()); - assert!(resolve_and_guard("file:///etc/passwd").is_err()); - assert!(resolve_and_guard("not a url").is_err()); - assert!(resolve_and_guard("").is_err()); - } - - // Malformed-authority edges rejected BEFORE touching DNS: empty host, - // unterminated IPv6 literal, 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("https://[::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 `.test`) must report - // `GuardFail::Unreachable` ("service is down"), not `Config`. - #[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}"); - } - - // An EMPTY pinned address set 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" - ); - } - - // KT9 (replaces the 180 s totals test). Per spec, stop-design-v5 §2.7: "There is - // **no total cap.**" Every phase bound is a stall bound owned by the idle connector. - // Guard: do not change without a spec citation proving otherwise. - #[test] - fn agent_timeouts_are_idle_not_totals() { - let agent = hardened_agent(Vec::new()); - let t = agent.config().timeouts(); - assert_eq!(t.global, None, "no whole-request total"); - assert_eq!(t.per_call, None, "no per-call total"); - assert_eq!(t.send_request, None, "request headers: idle only"); - assert_eq!(t.send_body, None, "request body: idle only"); - assert_eq!( - t.recv_response, None, - "first byte: the T17 budget, not a total" - ); - assert_eq!(t.recv_body, None, "reply body: idle only"); - // T15: "Connect | 10 s with no answer (existing)". - assert_eq!(t.connect, Some(Duration::from_secs(10))); - // T16: "**60 s (D3)**. This replaces the 180 s totals". - assert_eq!(IDLE_TIMEOUT, Duration::from_secs(60)); - } - - #[test] - fn resolve_and_guard_accepts_public_literal() { - // Public numeric hosts resolve without DNS — must be accepted. - let addrs = resolve_and_guard("https://8.8.8.8/keys").expect("public IP must be accepted"); - assert!(!addrs.is_empty()); - assert_eq!(addrs[0].port(), 443); - - let addrs = - resolve_and_guard("https://1.1.1.1:8080/keys").expect("public IP with port accepted"); - assert!(!addrs.is_empty()); - assert_eq!(addrs[0].port(), 8080); - } - - // The pin is actually consulted: pin to a loopback listener, then ask for a `.test` host - // that CANNOT resolve. - #[test] - fn hardened_agent_connects_to_the_pinned_address_not_dns() { - use std::io::Write as _; - use std::net::TcpListener; - use std::sync::mpsc; - - let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener"); - let pinned = listener.local_addr().expect("stub listener address"); - let (tx, rx) = mpsc::channel(); - - let server = std::thread::spawn(move || { - let (mut sock, _peer) = listener.accept().expect("stub listener accept failed"); - // Signal that a connection ARRIVED before doing anything else: that - // arrival, on this exact socket, is the fact under test. - let _ = tx.send(()); - // Drain the request head, then answer with the smallest valid reply. - let mut head = Vec::new(); - let mut byte = [0u8; 1]; - while !head.ends_with(b"\r\n\r\n") { - match sock.read(&mut byte) { - Ok(0) | Err(_) => break, - Ok(_) => head.push(byte[0]), - } - } - let _ = sock - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"); - let _ = sock.flush(); - head - }); - - let sent = hardened_agent(vec![pinned]) - .post("http://keyserver.test/keys") - .send("{}"); - - // 1. The connection reached the pinned socket at all. Checked FIRST so - // a mis-wired resolver reports the resolver, not a confusing - // downstream symptom. - rx.recv_timeout(Duration::from_secs(10)).expect( - "hardened_agent never connected to the pinned address — the custom \ - resolver is not being consulted, so a DNS rebind between the guard \ - and the POST can still redirect the key material", - ); - // 2. The whole round-trip completed through it. Had the agent fallen - // back to live DNS, an unresolvable host is an error, never a 200. - let resp = sent.expect("the pinned round-trip must complete"); - assert_eq!(resp.status(), 200, "the stub server's reply must come back"); - // 3. The pin redirected the CONNECTION without rewriting the request: - // the original host still travels in the Host header. - let head = server.join().expect("stub server panicked"); - let head = String::from_utf8_lossy(&head); - assert!( - head.contains("keyserver.test"), - "the pinned agent must still address the original host; got: {head}" - ); - } - - // ureq 3 defaults to `Proxy::try_from_env()`; PinnedResolver would send the proxy connection to - // the key service's address, failing every lookup. Checked in a child so no test mutates env. - #[test] - fn the_key_service_agent_never_uses_an_environment_proxy() { - const CHILD: &str = "FMKV_KS_PROXY_CHILD"; - const NAME: &str = "online::tests::the_key_service_agent_never_uses_an_environment_proxy"; - if std::env::var_os(CHILD).is_some() { - assert!( - hardened_agent(Vec::new()).config().proxy().is_none(), - "the pinned agent picked up a proxy from the environment" - ); - return; - } - let out = std::process::Command::new(std::env::current_exe().unwrap()) - .args([NAME, "--exact", "--test-threads=1"]) - .env(CHILD, "1") - .env("ALL_PROXY", "http://proxy.example:3128") - .env("HTTPS_PROXY", "http://proxy.example:3128") - .env("HTTP_PROXY", "http://proxy.example:3128") - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!( - out.status.success() && stdout.contains("1 passed"), - "child run failed:\n{stdout}\n{}", - String::from_utf8_lossy(&out.stderr) - ); - } - - // Stop rule (stall-based only, Stop can interrupt every wait): a factory build has no - // Halt, so its URL check does no DNS. It rejects what is wrong without a lookup and - // leaves the host lookup to the first query, which is Stop-aware (ST-K1b, J10). - #[test] - fn the_static_check_rejects_config_faults_without_a_lookup() { - for url in [ - "http://8.8.8.8/keys", - "ftp://example.com/keys", - "https:///keys", - "https://8.8.8.8:notaport/keys", - "https://[::1/keys", - "https://0.0.0.0/keys", - "https://224.0.0.1/keys", - "https://[ff02::1]:8443/keys", - ] { - let r = check_keyserver_url_static(url).expect_err(url); - assert_eq!(r.fault, KeyserverUrlFault::Permanent, "{url}"); - } - // A host name is not looked up: `.test` (RFC 2606) never resolves, yet it passes here. - assert_eq!( - check_keyserver_url_static("https://keys.ku-e1.test/keys"), - Ok(()) - ); - assert_eq!(check_keyserver_url_static("https://8.8.8.8/keys"), Ok(())); - let dns_before = DNS_LOOKUPS.load(Ordering::SeqCst); - let _ = check_keyserver_url_static("https://keys.example.com/keys"); - assert_eq!( - DNS_LOOKUPS.load(Ordering::SeqCst), - dns_before, - "no DNS lookup" - ); - } - - // The typed verdict autorip needs instead of matching message text: a standing fault - // (scheme, host, port, blocked address) is Permanent; only a failed lookup is Temporary. - #[test] - fn check_keyserver_url_types_permanent_rejections() { - for url in [ - "http://8.8.8.8/keys", - "ftp://example.com/keys", - "https:///keys", - "https://8.8.8.8:notaport/keys", - "https://[::1/keys", - "https://0.0.0.0/keys", - "https://240.0.0.1/latest/meta-data", - ] { - let r = check_keyserver_url(url).expect_err(url); - assert_eq!(r.fault, KeyserverUrlFault::Permanent, "{url}"); - assert!(!r.is_temporary(), "{url}"); - assert_eq!(Err(r.message.clone()), validate_keyserver_url(url), "{url}"); - assert_eq!(r.to_string(), r.message); - } - assert_eq!(check_keyserver_url("https://8.8.8.8/keys"), Ok(())); - } - - #[test] - fn a_failed_lookup_is_a_temporary_rejection() { - // Offline: every GuardFail::Unreachable reason resolve_and_guard produces. - for msg in [ - "DNS resolution timed out", - "could not resolve host: failed to lookup address information", - "host did not resolve to any address", - "too many concurrent DNS resolutions in flight for this host", - ] { - let r = KeyserverUrlRejection::from((GuardFail::Unreachable, msg.to_string())); - assert_eq!(r.fault, KeyserverUrlFault::Temporary, "{msg}"); - assert!(r.is_temporary()); - assert_eq!(r.message, msg); - } - let c = KeyserverUrlRejection::from((GuardFail::Config, "URL has no host".into())); - assert_eq!(c.fault, KeyserverUrlFault::Permanent); - } - - // Callers test their verdict mapping with the hook instead of a real DNS lookup. - #[test] - fn the_decode_reachability_hook_plants_what_take_reads() { - set_last_decode_reachability(Some(DecodeReachability::Status(422))); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Status(422)) - ); - assert_eq!(take_last_decode_reachability(), None); - set_last_decode_reachability(Some(DecodeReachability::Transport)); - set_last_decode_reachability(None); - assert_eq!(take_last_decode_reachability(), None); - } - - // ── bearer_header ────────────────────────────────────────────────────── - - #[test] - fn bearer_header_formats_token_and_omits_when_empty() { - // A configured token becomes a Bearer credential, sent verbatim. - assert_eq!( - bearer_header("s3cr3t-token"), - Some("Bearer s3cr3t-token".to_string()) - ); - // No token → no Authorization header (request goes out unauthenticated). - assert_eq!(bearer_header(""), None); - } - - // ── validate_keyserver_url ───────────────────────────────────────────── - - #[test] - fn validate_keyserver_url_allows_lan_and_rejects_invalid_and_bad_scheme() { - // A home app: loopback, RFC1918 and link-local key services are valid. - assert!(validate_keyserver_url("https://127.0.0.1/keys").is_ok()); - assert!(validate_keyserver_url("https://169.254.169.254/keys").is_ok()); - assert!(validate_keyserver_url(&format!("https://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_ok()); - assert!(validate_keyserver_url("https://[::1]:9000/keys").is_ok()); - assert!(validate_keyserver_url("https://0.0.0.0/keys").is_err()); - assert!(validate_keyserver_url("https://[ff02::1]/keys").is_err()); - assert!(validate_keyserver_url("ftp://example.com/keys").is_err()); - assert!(validate_keyserver_url("").is_err()); - // A public literal IP passes (no DNS needed, deterministic). - assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok()); - } - - // Cleartext http:// must be refused at CONFIG time (validate_keyserver_url), - // not per-rip — the body carries key material + a replayable token. A public - // host proves it's the SCHEME being rejected, not the address guard. - #[test] - fn non_https_scheme_is_rejected_at_config_time() { - // A perfectly reachable public host — only the http:// scheme is wrong. - assert!(validate_keyserver_url("http://8.8.8.8/keys").is_err()); - assert!(resolve_and_guard("http://8.8.8.8/keys").is_err()); - // The rejection is a standing Config fault, never a transient outage. - let (kind, _msg) = resolve_and_guard("http://8.8.8.8/keys") - .expect_err("cleartext http:// must be refused"); - assert_eq!(kind, GuardFail::Config); - // https:// for the same public host is accepted. - assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok()); - } - - // A non-empty, non-`:port` tail after a bracketed IPv6 authority (e.g. - // `[::1]extra`) must be rejected as a Config fault, not silently dropped - // with a fall back to the default port. - #[test] - fn resolve_and_guard_rejects_garbage_after_bracketed_ipv6() { - // `[::1]junk` — junk after the closing bracket. - let (kind, _) = resolve_and_guard("https://[::1]junk/keys") - .expect_err("garbage after ] must be rejected"); - assert_eq!(kind, GuardFail::Config); - // A public v6 literal with a trailing garbage tail is likewise rejected - // (so the garbage can't slip a request out on the default port). - assert!(resolve_and_guard("https://[2606:4700:4700::1111]extra/keys").is_err()); - // Sanity: the same public v6 literal WITHOUT the tail is accepted. - assert!(resolve_and_guard("https://[2606:4700:4700::1111]/keys").is_ok()); - // And an explicit :port after ] still parses. - let addrs = resolve_and_guard("https://[2606:4700:4700::1111]:8443/keys") - .expect("bracketed v6 with :port must parse"); - assert_eq!(addrs[0].port(), 8443); - } - - // ── the reply → verdict mapping (THE defect) ────────────────────────── - // Driven through `interpret_reply` directly: a stub HTTP server is NOT - // usable here since `resolve_and_guard` blocks loopback by design. - - /// A `ResolveCtx` that carries nothing — enough for the reply paths that do - /// not derive from a VUK. - struct BareCtx; - impl ResolveCtx for BareCtx { - 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(&[]) - } - fn samples(&self, _n: usize) -> Result>, Error> { - Ok(Vec::new()) - } - } - - /// A stub reply. ureq 3 has no `Response::new`; a response is just an - /// `http::Response` carrying a `ureq::Body`, which is constructible - /// directly — so these stay honest unit tests with no server involved. - fn reply(status: u16, body: &str) -> ureq::http::Response { - ureq::http::Response::builder() - .status(status) - .body(ureq::Body::builder().data(body)) - .expect("stub response") - } - - // THE regression: a 5xx for ~seven hours was reported as "no key" and - // sent operators hunting a VUK that was never missing. A 5xx must be a - // source FAILURE, distinguishable from a 200 with no entry. - #[test] - fn http_5xx_is_a_source_failure_not_a_missing_key() { - for status in [500u16, 502, 503, 504] { - let out = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 7); - assert_eq!( - out.expect_err("a 5xx must not look like an answer").code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, - "HTTP {status} must report the service as unavailable" - ); - } - - // The contrast case, through the SAME function: the service answered, - // and genuinely holds nothing for this disc. - let miss = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 7) - .expect("a 200 with no key is an ANSWER, not a failure"); - assert!(miss.is_empty(), "no key in the body → no keys out"); - - // And the two must not be the same outcome — the whole point. - let down = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 7); - assert!( - down.is_err(), - "502 and 200-with-no-entry must not collapse to the same result" - ); - } - - // Each status maps to a DIFFERENT operator action: fix the token - // (401/403), back off (429), wait (5xx). Definitive misses bypass this mapping. - #[test] - fn http_status_maps_to_the_operator_action() { - let cases: &[(u16, u16)] = &[ - (401, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED), - (403, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED), - (429, libfreemkv::error::E_KEY_SERVICE_RATE_LIMITED), - (500, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE), - (502, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE), - (400, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE), - ]; - for (status, want) in cases { - assert_eq!( - classify_http_status(*status).code(), - *want, - "HTTP {status} classified wrongly" - ); - assert_ne!( - classify_http_status(*status).code(), - libfreemkv::error::E_NO_DISC_KEY, - "service failures must not mean \"this disc has no key\"" - ); - } - } - - // A transport failure is the same verdict as a 5xx. Uses a refused - // connection to 127.0.0.1:1 for a REAL transport-class error; never - // reaches `query`, so the SSRF guard is not involved. - #[test] - fn transport_failure_is_a_source_failure() { - let config = Config::builder() - .timeout_connect(Some(Duration::from_secs(2))) - .build(); - let sent = ureq::Agent::new_with_config(config) - .post("http://127.0.0.1:1/") - .send("{}"); - // Which transport variant a refused connection produces is a - // platform detail; what matters (asserted below) is that it failed, - // and NOT with a status code — nothing on the other end answered. - assert!( - !matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))), - "a refused connection must fail as transport, never as an HTTP status" - ); - assert_eq!( - interpret_reply(sent, &BareCtx, 3) - .expect_err("an unreachable service must not look like an answer") - .code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, - ); - } - - // The decode POST records its reachability so the caller classifies a - // no-key from the REAL answer, not a second empty probe: an HTTP answer - // (200/404/422) → `Status(code)`, a transport failure → `Transport`. - #[test] - fn interpret_reply_records_the_decode_reachability() { - // A 200 with no entry — the service answered; record its status. - let _ = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 1); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Status(200)), - "a 200 answer must record Status(200)" - ); - // take clears the slot — a second take sees nothing. - assert_eq!( - take_last_decode_reachability(), - None, - "take must clear the slot" - ); - - // A definitive 422 ("licensed but unresolved") / 404 is still an ANSWER: - // record the status so the caller reads it as reachable (genuine no-key). - for status in [422u16, 404] { - let keys = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 1) - .expect("a definitive miss must allow the resolver to try the other FMTS phase"); - assert!(keys.is_empty()); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Status(status)), - "HTTP {status} must record Status({status}) — a reachable answer" - ); - } - - // A 5xx is an answer too — Status(502); the caller maps 5xx to down. - let _ = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 1); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Status(502)), - "a 5xx must record its status, not Transport" - ); - - // A transport failure (refused connection) records `Transport` — no - // HTTP answer, so the caller treats it as a transient outage. - let config = Config::builder() - .timeout_connect(Some(Duration::from_secs(2))) - .build(); - let sent = ureq::Agent::new_with_config(config) - .post("http://127.0.0.1:1/") - .send("{}"); - assert!( - !matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))), - "a refused connection must be a transport error" - ); - let _ = interpret_reply(sent, &BareCtx, 1); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Transport), - "a transport failure must record Transport, never a Status" - ); - } - - /// A reply the client cannot read is the service failing mid-answer, not an - /// answer of "no key". - #[test] - fn unparseable_reply_is_a_source_failure() { - assert_eq!( - interpret_reply(Ok(reply(200, "gateway error")), &BareCtx, 1) - .expect_err("non-JSON is not an answer") - .code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, - ); - // A malformed element in the UK set rejects the whole reply (a partial - // forensic set silently omits an index) — also a service fault. - assert_eq!( - interpret_reply( - Ok(reply( - 200, - r#"{"UK":["000102030405060708090a0b0c0d0e0f","zz"]}"# - )), - &BareCtx, - 1 - ) - .expect_err("a malformed key in the set is not an answer") - .code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, - ); - } - - /// Regression pin (passes before and after): a service that DOES hold the - /// key still resolves it, and array order is preserved as the forensic index - /// order. The Result-returning signature must not have changed the happy path. - #[test] - fn service_with_a_key_still_resolves_it_in_order() { - let keys = interpret_reply( - Ok(reply( - 200, - r#"{"UK":["000102030405060708090a0b0c0d0e0f","0f0e0d0c0b0a09080706050403020100"]}"#, - )), - &BareCtx, - 1, - ) - .expect("a 200 carrying keys resolves"); - assert_eq!(keys.len(), 2); - assert_eq!(keys[0].idx, 0); - assert_eq!(keys[1].idx, 1); - // Full 16 bytes of each key, not just byte 0 — a byte-transposition bug - // would pass a first-byte-only check. - assert_eq!( - keys[0].key, - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] - ); - assert_eq!( - keys[1].key, - [15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0] - ); - } - - /// 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); - // Full 16-byte key, not just byte 0. - assert_eq!( - keys[0].key, - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] - ); - } - - // A malformed `"UK"` STRING falls through to the genuine-miss path, - // distinct from the array form's "reject the whole reply" behaviour. - #[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); - // Full-byte KAT: the derived keys must equal the local VUK boil over - // the disc's encrypted title keys (the same primitive the code calls). - let vuk = parse_uk(vuk_hex).unwrap(); - let expected = crate::uks_from_vuk(&vuk, &[[0x11u8; 16], [0x22u8; 16]]); - assert_eq!(keys[0].key, expected[0].key); - assert_eq!(keys[1].key, expected[1].key); - } - - /// 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. - #[test] - fn address_guard_rejections_are_config_not_unreachable() { - for url in [ - "http://0.0.0.0/keys", - "http://240.0.0.1/latest/meta-data/", - "ftp://example.com/keys", - "not a url", - "", - ] { - assert_eq!( - resolve_and_guard(url).expect_err("must be rejected").0, - GuardFail::Config, - "{url} is a configuration fault, not an outage" - ); - } - } - - // ── The pre-flight guards in `query` (nothing leaves the process) ──────. - - /// A `ResolveCtx` whose MKB size and sample COUNT are dialled per guard. - #[derive(Default)] - struct GuardCtx { - mkb: Vec, - samples: usize, - halt: Option, - progress: Option, - } - impl ResolveCtx for GuardCtx { - fn disc_hash(&self) -> &str { - "0x422EB" - } - fn title(&self) -> Option<&str> { - None - } - fn vid(&self) -> Option { - None - } - fn mkb(&self) -> Result<&[u8], Error> { - Ok(&self.mkb) - } - fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { - Ok(&[]) - } - fn samples(&self, _n: usize) -> Result>, Error> { - Ok(vec![vec![0u8; 16]; self.samples]) - } - fn halt(&self) -> Option<&Halt> { - self.halt.as_ref() - } - fn progress(&self) -> Option<&Liveness> { - self.progress.as_ref() - } - } - - // An `http://` key-service URL must never be POSTed to; the source refuses with `Err`, not - // an empty that reads as "no key". - #[test] - fn cleartext_http_url_is_refused_before_anything_is_sent() { - let src = OnlineSource::new("http://keyserver.test/keys", "s3cr3t"); - let ctx = GuardCtx { - mkb: Vec::new(), - // Enough samples that ONLY the scheme guard can stop the request. - samples: MIN_SAMPLE_UNITS, - - ..Default::default() - }; - assert_eq!( - src.get_unit_keys(&ctx) - .expect_err("refusing to ask is a source failure, never a miss") - .code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, - "an http:// key-service URL must be refused, not sent — and reported" - ); - } - - /// An MKB larger than the forward cap cannot be sent; the source skips - /// rather than truncating the MKB (which would ask about a different disc). - #[test] - fn over_cap_mkb_skips_the_request() { - let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t"); - let ctx = GuardCtx { - mkb: vec![0u8; MAX_MKB_BYTES + 1], - samples: MIN_SAMPLE_UNITS, - - ..Default::default() - }; - assert!( - src.get_unit_keys(&ctx) - .expect("an un-forwardable MKB is a skip, not a service failure") - .is_empty(), - "an over-cap MKB must skip the online source" - ); - } - - /// Too few content samples make the service's answer ambiguous (it - /// identifies the key by which submitted unit decrypts), so the request is - /// never built. Proven at the boundary: `MIN_SAMPLE_UNITS - 1` skips. - #[test] - fn too_few_samples_skips_the_request() { - let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t"); - let ctx = GuardCtx { - mkb: Vec::new(), - samples: MIN_SAMPLE_UNITS - 1, - - ..Default::default() - }; - assert!( - src.get_unit_keys(&ctx) - .expect("under-sampling is a skip, not a service failure") - .is_empty(), - "fewer than MIN_SAMPLE_UNITS samples must skip the online source" - ); - } - - // ── MAX_RESPONSE_BYTES: the anti-OOM defence, both edges asserted so - // `+1` can't quietly truncate, nor an off-by-one reject a legal reply. - #[test] - fn over_cap_reply_is_rejected_and_an_at_cap_reply_still_parses() { - // The over-cap body is deliberately VALID, key-bearing JSON — junk - // would be rejected by the parser regardless of the cap. This one is - // only rejectable BY the cap. - let head = r#"{"UK":["000102030405060708090a0b0c0d0e0f"],"pad":""#; - let tail = r#""}"#; - let over = format!( - "{head}{}{tail}", - "p".repeat(MAX_RESPONSE_BYTES + 1 - head.len() - tail.len()) - ); - assert_eq!(over.len(), MAX_RESPONSE_BYTES + 1); - assert_eq!( - interpret_reply(Ok(reply(200, &over)), &BareCtx, 1) - .expect_err("an over-cap body must not be treated as an answer") - .code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, - ); - - // EXACTLY at the cap, and valid: still read and still answered. Padded - // with a JSON string so the length is exact and the key is real. - let pad = MAX_RESPONSE_BYTES - head.len() - tail.len(); - let at_cap = format!("{head}{}{tail}", "p".repeat(pad)); - assert_eq!(at_cap.len(), MAX_RESPONSE_BYTES); - let keys = interpret_reply(Ok(reply(200, &at_cap)), &BareCtx, 1) - .expect("a reply exactly at the cap is legal and must be read"); - assert_eq!(keys.len(), 1, "the key in an at-cap reply must survive"); - // The surviving key must be the planted UK bytes, not merely present. - assert_eq!( - keys[0].key, - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], - "the at-cap key must be the exact planted UK" - ); - } - - // ── A bad port is CONFIG, not an outage ──────────────────────────────── - - // A typo'd port must be `Config`, not `Unreachable` - #[test] - fn unparseable_port_is_a_config_fault_not_an_outage() { - for url in [ - "https://example.com:notaport/keys", - "http://example.com:99999/keys", // out of u16 range - "https://example.com:/keys", // empty port - ] { - assert_eq!( - resolve_and_guard(url).expect_err("must be rejected").0, - GuardFail::Config, - "{url} is a configuration typo, not a service outage" - ); - } - // A WELL-FORMED port is still split off the host and honoured. - let addrs = resolve_and_guard("https://8.8.8.8:8443/keys").expect("valid port accepted"); - assert_eq!(addrs[0].port(), 8443); - } - - // The caller-visible half: a mistyped port must get `Err`, exactly like an outage (only the - // log text differs) — catches the `Ok(Vec::new())` regression from `GuardFail::Config`. - #[test] - fn query_with_a_mistyped_port_reports_a_failure_not_a_miss() { - let src = OnlineSource::new("https://example.com:notaport/keys", "s3cr3t"); - let ctx = GuardCtx { - mkb: Vec::new(), - samples: MIN_SAMPLE_UNITS, - ..Default::default() - }; - assert_eq!( - src.get_unit_keys(&ctx) - .expect_err("a config fault means the service was never asked — never Ok(empty)") - .code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, - ); - } - - // A guard-BLOCKED address travels the same `GuardFail::Config` arm and - // must be just as loud — distinct from the mistyped port above because - // it fails AFTER resolution, on the address check. - #[test] - fn query_against_a_guard_blocked_address_reports_a_failure_not_a_miss() { - // 0.0.0.0 needs no DNS and is unconditionally rejected by is_blocked_ip. - let src = OnlineSource::new("https://0.0.0.0/keys", "s3cr3t"); - let ctx = GuardCtx { - mkb: Vec::new(), - samples: MIN_SAMPLE_UNITS, - ..Default::default() - }; - assert_eq!( - src.get_unit_keys(&ctx) - .expect_err("a blocked address means the service was never asked") - .code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, - ); - } - - // `get_fmts_indexes` shares `query` with `get_unit_keys` — same - // guard-blocked, no-network path proves it is wired up. - #[test] - fn get_fmts_indexes_shares_the_same_query_path() { - let src = OnlineSource::new("https://0.0.0.0/keys", "s3cr3t"); - let ctx = GuardCtx { - mkb: Vec::new(), - samples: MIN_SAMPLE_UNITS, - ..Default::default() - }; - 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 does not resolve travels `query`'s OWN `Unreachable` arm - // (distinct from `Config` above) — still `Err`, 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, - ..Default::default() - }; - 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, - ); - } - - // `vid_b64`/`title` are assembled BEFORE the address guard runs, so a - // VID+title ctx still exercises that assembly even when the guard then - // rejects the address (same deterministic guard-blocked path, 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]) - } - } - // 0.0.0.0 needs no DNS and is unconditionally rejected — the guard - // fires AFTER the body (incl. vid/title) is already built. - let src = OnlineSource::new("https://0.0.0.0/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://0.0.0.0/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 reorders the SAME addresses; still the same set, so it must reuse the agent. - #[test] - fn a_reordered_but_identical_address_set_reuses_the_agent() { - let src = OnlineSource::new("https://keyserver.test/keys", ""); - let forward: Vec = vec![ - "8.8.8.8:443".parse().unwrap(), - "8.8.4.4:443".parse().unwrap(), - ]; - let reversed: Vec = forward.iter().rev().copied().collect(); - assert_ne!(forward, reversed, "the two orders must really differ"); - - let first = src.agent_for(forward); - let again = src.agent_for(reversed); - assert!( - Arc::ptr_eq(&first, &again), - "a reordered but identical address SET must reuse the pinned agent" - ); - - // Same cardinality, one address swapped: a genuinely different set, so - // never the same agent. - let changed: Vec = vec![ - "8.8.8.8:443".parse().unwrap(), - "1.1.1.1:443".parse().unwrap(), - ]; - assert!( - !Arc::ptr_eq(&first, &src.agent_for(changed)), - "a different address set must never reuse an agent pinned elsewhere" - ); - } - - // An FMTS disc calls `query` twice per rip; the agent is reused only while the freshly - // guarded address set is IDENTICAL. - #[test] - fn the_agent_is_reused_per_address_set_only() { - let src = OnlineSource::new("https://keyserver.test/keys", ""); - let a: Vec = vec!["8.8.8.8:443".parse().unwrap()]; - let b: Vec = vec!["1.1.1.1:443".parse().unwrap()]; - - let first = src.agent_for(a.clone()); - let second = src.agent_for(a.clone()); - assert!( - Arc::ptr_eq(&first, &second), - "the same pinned address set must reuse the agent (and its pooled TLS connection)" - ); - - let other = src.agent_for(b); - assert!( - !Arc::ptr_eq(&first, &other), - "a DIFFERENT address set must never reuse an agent pinned elsewhere" - ); - // And the address set is re-pinned, so going back re-builds. - let back = src.agent_for(a); - assert!(!Arc::ptr_eq(&first, &back)); - } - - /// Finding #9 regression: parse_uk must reject any non-hex byte up front so - /// sign prefixes / whitespace can't slip through the windowed 2-char parse - /// (`u8::from_str_radix` accepts "+5", "-A", etc.). - #[test] - fn parse_uk_rejects_non_hex_bytes() { - // Valid 32-char hex parses. - assert_eq!( - parse_uk("000102030405060708090a0b0c0d0e0f"), - Some([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) - ); - // Sign-prefixed window: "+5" / "-A" would parse via from_str_radix. - assert!(parse_uk("+5000102030405060708090a0b0c0d0e").is_none()); - assert!(parse_uk("-A000102030405060708090a0b0c0d0e").is_none()); - // Embedded whitespace. - assert!(parse_uk("00 0102030405060708090a0b0c0d0e0f").is_none()); - // Wrong length is still rejected. - assert!(parse_uk("00").is_none()); - } - - // ── ST-K1a: stall-only key-service timeouts, mid-flight Stop (stop-design-v5 §2.7, §5.3) - - /// Scaled T16 idle bound for the loopback tests (60 s in production). - const T_IDLE: Duration = Duration::from_secs(1); - /// Idle bound for a Stop test against a silent stub: well past the Stop, so only the Stop - /// can end the query, and short enough for the abandoned worker to drain within the test. - const T_STOP_IDLE: Duration = Duration::from_secs(2); - /// How long a stub holds a stalled connection; far past every bound under test. - const STUB_HOLD: Duration = Duration::from_secs(20); - - fn source_via( - host: &str, - idle: Duration, - resolve: impl Fn() -> Result, (GuardFail, String)> + Send + Sync + 'static, - ) -> OnlineSource { - let mut src = OnlineSource::new(format!("https://{host}/keys"), "s3cr3t"); - src.test_net = Some(TestNet { - resolve: Arc::new(resolve), - post_url: format!("http://{host}/keys"), - idle, - }); - src - } - - fn ctx_with_mkb(len: usize) -> GuardCtx { - GuardCtx { - mkb: vec![0x5a; len], - samples: MIN_SAMPLE_UNITS, - ..Default::default() - } - } - - /// What the loopback stub does with its one connection. - #[derive(Clone, Copy)] - enum Stub { - /// Read the whole request, then answer with this status and `{}`. - Answer(u16), - /// Read the whole request, then never answer. - NeverAnswer, - /// Read the request head only, then stop reading. - StallUpload, - /// Answer 200 with a 100-byte body, send 5 bytes of it, then stall. - StallBody, - /// Answer 200, then send an `n`-byte JSON body one byte per `gap`. - TrickleBody { n: usize, gap: Duration }, - /// Read the body `chunk` bytes per `gap`, then answer 200 with `{}`. - SlowReader { chunk: usize, gap: Duration }, - } - - fn read_head(sock: &mut std::net::TcpStream) -> usize { - let mut head = Vec::new(); - let mut byte = [0u8; 1]; - while !head.ends_with(b"\r\n\r\n") { - match sock.read(&mut byte) { - Ok(0) | Err(_) => return 0, - Ok(_) => head.push(byte[0]), - } - } - let head = String::from_utf8_lossy(&head).to_ascii_lowercase(); - head.lines() - .find_map(|l| l.strip_prefix("content-length:")) - .and_then(|v| v.trim().parse().ok()) - .unwrap_or(0) - } - - fn read_body(sock: &mut std::net::TcpStream, mut left: usize, chunk: usize, gap: Duration) { - let mut buf = vec![0u8; chunk.max(1)]; - while left > 0 { - let want = left.min(buf.len()); - match sock.read(&mut buf[..want]) { - Ok(0) | Err(_) => return, - Ok(n) => left -= n, - } - if !gap.is_zero() { - std::thread::sleep(gap); - } - } - } - - /// A one-connection HTTP/1.1 stub on loopback, playing the key service. - fn stub_server(kind: Stub) -> SocketAddr { - use std::io::Write as _; - let listener = - std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener"); - let addr = listener.local_addr().expect("stub address"); - std::thread::spawn(move || { - let Ok((mut sock, _)) = listener.accept() else { - return; - }; - let len = read_head(&mut sock); - let ok = |code: u16, body_len: usize| { - format!( - "HTTP/1.1 {code} X\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n" - ) - }; - match kind { - Stub::Answer(code) => { - read_body(&mut sock, len, 64 * 1024, Duration::ZERO); - let _ = sock.write_all(format!("{}{{}}", ok(code, 2)).as_bytes()); - } - Stub::NeverAnswer => { - read_body(&mut sock, len, 64 * 1024, Duration::ZERO); - std::thread::sleep(STUB_HOLD); - } - Stub::StallUpload => std::thread::sleep(STUB_HOLD), - Stub::StallBody => { - read_body(&mut sock, len, 64 * 1024, Duration::ZERO); - let _ = sock.write_all(format!("{}{{\"UK\"", ok(200, 100)).as_bytes()); - std::thread::sleep(STUB_HOLD); - } - Stub::TrickleBody { n, gap } => { - read_body(&mut sock, len, 64 * 1024, Duration::ZERO); - let _ = sock.write_all(ok(200, n).as_bytes()); - let body = format!("{{{}}}", " ".repeat(n - 2)); - for b in body.bytes() { - std::thread::sleep(gap); - if sock.write_all(&[b]).is_err() { - return; - } - } - } - Stub::SlowReader { chunk, gap } => { - read_body(&mut sock, len, chunk, gap); - let _ = sock.write_all(format!("{}{{}}", ok(200, 2)).as_bytes()); - } - } - let _ = sock.flush(); - }); - addr - } - - /// Polls `cond` every 10 ms until it holds or `within` passes. - fn eventually(within: Duration, cond: impl Fn() -> bool) -> bool { - let t0 = std::time::Instant::now(); - while t0.elapsed() < within { - if cond() { - return true; - } - std::thread::sleep(Duration::from_millis(10)); - } - cond() - } - - /// Runs `query_with` on this thread and cancels `halt` after `after`; returns the - /// result and how long after the cancel it came back. - fn query_cancelled_after( - src: &OnlineSource, - ctx: &GuardCtx, - after: Duration, - ) -> (Result, Error>, Duration) { - let halt = Halt::new(); - let canceller = { - let halt = halt.clone(); - std::thread::spawn(move || { - std::thread::sleep(after); - halt.cancel(); - std::time::Instant::now() - }) - }; - let out = src.query_with(ctx, &halt); - let returned = std::time::Instant::now(); - let cancelled = canceller.join().expect("canceller"); - (out, returned.saturating_duration_since(cancelled)) - } - - // KT1. Per spec, stop-design-v5 §2.7: "On Stop, the call returns `Halted` and - // records nothing." A hung host lookup must not hold the caller. - #[test] - fn query_halted_during_dns() { - let host = "kt1.test"; - let src = source_via(host, T_IDLE, || { - std::thread::sleep(Duration::from_secs(3)); - Err((GuardFail::Unreachable, "DNS resolution timed out".into())) - }); - let (out, after_cancel) = - query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(100)); - assert_eq!( - out.expect_err("a Stop is never an answer").code(), - libfreemkv::error::E_HALTED - ); - assert!( - after_cancel <= Duration::from_secs(1), - "Halted took {after_cancel:?}" - ); - assert_eq!( - take_last_decode_reachability(), - None, - "a Stop records nothing" - ); - assert!( - eventually(Duration::from_secs(8), || query_slots_in_flight(host) == 0), - "the abandoned worker frees its slot when its lookup returns" - ); - } - - // KT2. Per spec, stop-design-v5 §2.7: "The worker is abandoned. It ends on its own - // at the next T15–T17 bound, and its slot is freed then." - #[test] - fn query_halted_during_post() { - let host = "kt2.test"; - let addr = stub_server(Stub::NeverAnswer); - let src = source_via(host, T_STOP_IDLE, move || Ok(vec![addr])); - let (out, after_cancel) = - query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(150)); - assert_eq!( - out.expect_err("a Stop is never an answer").code(), - libfreemkv::error::E_HALTED - ); - assert!( - after_cancel <= Duration::from_secs(1), - "Halted took {after_cancel:?}" - ); - assert_eq!( - take_last_decode_reachability(), - None, - "a Stop records nothing" - ); - assert!( - eventually(Duration::from_secs(10), || query_slots_in_flight(host) == 0), - "the worker ends at its own first-byte bound and frees its slot" - ); - } - - // KT3. Per spec, stop-design-v5 §2.7 (D4): "When the cap is full, the caller waits - // halt-aware for a slot." The cap is "4 per host". - #[test] - fn slot_cap_waits_halt_aware() { - use std::sync::atomic::{AtomicUsize, Ordering}; - let host = "kt3.test"; - let entered = Arc::new(AtomicUsize::new(0)); - let (release, gate) = std::sync::mpsc::channel::<()>(); - let gate = Arc::new(Mutex::new(gate)); - let src = Arc::new({ - let entered = entered.clone(); - source_via(host, T_IDLE, move || { - entered.fetch_add(1, Ordering::SeqCst); - let _ = gate.lock().unwrap().recv_timeout(Duration::from_secs(10)); - Err((GuardFail::Unreachable, "released".into())) - }) - }); - let spawn_query = |halt: Halt| { - let src = src.clone(); - std::thread::spawn(move || src.query_with(&ctx_with_mkb(0), &halt)) - }; - let first_four: Vec<_> = (0..4).map(|_| spawn_query(Halt::new())).collect(); - assert!(eventually(Duration::from_secs(2), || entered - .load(Ordering::SeqCst) - == 4)); - - let fifth = spawn_query(Halt::new()); - std::thread::sleep(Duration::from_millis(200)); - assert_eq!( - entered.load(Ordering::SeqCst), - 4, - "the 5th query must wait for a slot" - ); - assert_eq!(query_slots_in_flight(host), MAX_QUERY_WORKERS_PER_HOST); - - // A cancel while waiting for a slot returns Halted and starts nothing. - let waiting = Halt::new(); - let sixth = spawn_query(waiting.clone()); - std::thread::sleep(Duration::from_millis(100)); - waiting.cancel(); - let sixth = sixth.join().expect("sixth query"); - assert_eq!( - sixth.expect_err("cancelled while waiting").code(), - libfreemkv::error::E_HALTED - ); - - // One slot frees, so exactly the waiting 5th proceeds. - release.send(()).unwrap(); - assert!(eventually(Duration::from_secs(2), || entered - .load(Ordering::SeqCst) - == 5)); - for _ in 0..4 { - release.send(()).unwrap(); - } - for q in first_four.into_iter().chain([fifth]) { - assert_eq!( - q.join().expect("query").expect_err("unreachable").code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE - ); - } - assert_eq!( - entered.load(Ordering::SeqCst), - 5, - "the cancelled 6th never started" - ); - } - - // KT4 (T16 a). Per spec, stop-design-v5 §2.7: "Receive headers and body | **60 s with - // no bytes read** | every read that returns bytes". A slow reply that keeps moving lives. - #[test] - fn slow_trickle_response_not_timed_out() { - let n = 8; - let gap = T_IDLE / 2; - assert!(gap * n as u32 > 3 * T_IDLE, "total must exceed 3 × idle"); - let addr = stub_server(Stub::TrickleBody { n, gap }); - let src = source_via("kt4.test", T_IDLE, move || Ok(vec![addr])); - let out = src.query_with(&ctx_with_mkb(0), &Halt::new()); - assert_eq!(out.expect("a moving reply is never timed out"), Vec::new()); - } - - // KT5 (T16 b). Per spec, stop-design-v5 T16: "no bytes moved" for 60 s → "`Transport` - // reachability". A reply that stops mid-body fails at idle, as a transport failure. - #[test] - fn stalled_response_body_times_out_on_idle() { - let addr = stub_server(Stub::StallBody); - let src = source_via("kt5.test", T_IDLE, move || Ok(vec![addr])); - let t0 = std::time::Instant::now(); - let out = src.query_with(&ctx_with_mkb(0), &Halt::new()); - let took = t0.elapsed(); - assert_eq!( - out.expect_err("a stalled reply is no answer").code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE - ); - assert!( - took <= T_IDLE + Duration::from_secs(1), - "stall cut after {took:?}" - ); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Transport) - ); - } - - // KT6 (T16 a, send side). Per spec, stop-design-v5 §2.7: "Send request and body | - // **60 s with no bytes written** | every write that moves bytes". - #[test] - fn slow_upload_not_timed_out() { - let gap = T_IDLE / 2; - let chunk = 1024 * 1024; - let mkb = 8 * 1024 * 1024; - let addr = stub_server(Stub::SlowReader { chunk, gap }); - let src = source_via("kt6.test", T_IDLE, move || Ok(vec![addr])); - let t0 = std::time::Instant::now(); - let out = src.query_with(&ctx_with_mkb(mkb), &Halt::new()); - assert!( - t0.elapsed() > 3 * T_IDLE, - "the upload must outlast 3 × idle to prove anything" - ); - assert_eq!(out.expect("a moving upload is never timed out"), Vec::new()); - } - - // KT7a. Per spec, stop-design-v5 T17: "**60 s + body / 256 KiB/s (D3)**" — the one - // phase with no byte signal while the server processes the upload. - #[test] - fn await_first_byte_budget_scales_with_body() { - let idle = Duration::from_secs(60); - assert_eq!(FIRST_BYTE_RATE, 256 * 1024); - assert_eq!(first_byte_budget(idle, 0), idle); - assert_eq!(first_byte_budget(idle, 256 * 1024), Duration::from_secs(61)); - assert_eq!( - first_byte_budget(idle, 128 * 1024), - Duration::from_millis(60_500) - ); - // The largest forwardable MKB (64 MiB) buys 256 s of server processing. - assert_eq!( - first_byte_budget(idle, 64 * 1024 * 1024), - Duration::from_secs(316) - ); - // The largest request saturates instead of overflowing, and never undercuts idle. - let huge = first_byte_budget(idle, u64::MAX); - assert!(huge >= idle && huge > first_byte_budget(idle, 64 * 1024 * 1024)); - } - - // KT7b. Per spec, stop-design-v5 T17: a server that never answers after the body fails - // at "60 s + body / 256 KiB/s" as `Transport` — not at the bare idle bound. - #[test] - fn await_first_byte_budget_expires_without_first_byte() { - let mkb = 96 * 1024; - // A lower bound on the bytes written: the base64 MKB alone. - let floor = first_byte_budget(T_IDLE, (mkb as u64).div_ceil(3) * 4); - assert!( - floor >= T_IDLE + Duration::from_millis(400), - "the body must matter" - ); - let addr = stub_server(Stub::NeverAnswer); - let src = source_via("kt7.test", T_IDLE, move || Ok(vec![addr])); - let t0 = std::time::Instant::now(); - let out = src.query_with(&ctx_with_mkb(mkb), &Halt::new()); - let took = t0.elapsed(); - assert_eq!( - out.expect_err("no first byte is no answer").code(), - libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE - ); - assert!( - took >= floor, - "fired at {took:?}, before the {floor:?} first-byte budget" - ); - assert!( - took <= floor + Duration::from_secs(1), - "fired late: {took:?}" - ); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Transport) - ); - } - - // KT8. Per spec, stop-design-v5 §2.7: "Reachability is recorded on the caller's thread, - // and only when the worker's result arrives." RFC 9110 §15.6 (SS-20): "The 5xx (Server - // Error) class of status code indicates that the server is aware that it has erred". - #[test] - fn reachability_recorded_on_caller_thread_only_on_result() { - for (host, code) in [("kt8a.test", 200u16), ("kt8b.test", 503)] { - let addr = stub_server(Stub::Answer(code)); - let src = source_via(host, T_IDLE, move || Ok(vec![addr])); - let _ = src.query_with(&ctx_with_mkb(0), &Halt::new()); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Status(code)), - "an answer ({code}) is recorded on the calling thread" - ); - } - // A Stop records nothing, then or later: the late worker result reaches no caller. - let host = "kt8c.test"; - let addr = stub_server(Stub::NeverAnswer); - let src = source_via(host, T_STOP_IDLE, move || Ok(vec![addr])); - let (out, _) = query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(100)); - assert_eq!( - out.expect_err("stopped").code(), - libfreemkv::error::E_HALTED - ); - assert!(eventually( - Duration::from_secs(10), - || query_slots_in_flight(host) == 0 - )); - assert_eq!( - take_last_decode_reachability(), - None, - "a stopped query records nothing" - ); - } - - // KT10. Per spec, stop-design-v5 §2.7: "a Stop … lands within one slice even while DNS, - // connect, upload or download is in progress." Each stall is cancelled at its stage. - #[test] - fn stop_mid_flight_returns_within_a_slice() { - let idle = Duration::from_secs(2); - for (host, stub, mkb) in [ - ("kt10a.test", Stub::NeverAnswer, 0), - ("kt10b.test", Stub::StallUpload, 8 * 1024 * 1024), - ("kt10c.test", Stub::StallBody, 0), - ] { - let addr = stub_server(stub); - let src = source_via(host, idle, move || Ok(vec![addr])); - let (out, after_cancel) = - query_cancelled_after(&src, &ctx_with_mkb(mkb), Duration::from_millis(300)); - assert_eq!( - out.expect_err(host).code(), - libfreemkv::error::E_HALTED, - "{host}" - ); - assert!( - after_cancel <= Duration::from_secs(1), - "{host}: Halted took {after_cancel:?}" - ); - assert_eq!( - take_last_decode_reachability(), - None, - "{host}: a Stop records nothing" - ); - // The abandoned worker ends at its own T16/T17 bound, which frees its slot. - let bound = first_byte_budget(idle, (mkb as u64).div_ceil(3) * 4 + 4096); - assert!( - eventually(bound + Duration::from_secs(2), || query_slots_in_flight( - host - ) == 0), - "{host}: the worker's slot was never freed" - ); - } - } - - // G12. Per spec, RFC 9110 §15.4 (SS-20): "The 3xx (Redirection) class of status code - // indicates that further action needs to be taken by the user agent". Never followed. - // Guard: do not change without a spec citation proving otherwise. - #[test] - fn the_agent_keeps_zero_redirects_and_no_proxy_after_the_timeout_change() { - for agent in [ - hardened_agent(Vec::new()), - hardened_agent_with(Vec::new(), T_IDLE), - ] { - assert_eq!(agent.config().max_redirects(), 0); - assert!(agent.config().proxy().is_none()); - } - } - - // G13. Per stop-design-v5 §5.9 G13: "the byte caps are unchanged: keysources - // `MAX_RESPONSE_BYTES`". Guard: do not change without a spec citation proving otherwise. - #[test] - fn the_reply_byte_cap_is_unchanged() { - assert_eq!(MAX_RESPONSE_BYTES, 1024 * 1024); - } - - // Per keys-upfront-design §2.1 invariant 4 ("No lookups after K exists") and the user - // rule "never call the key service twice": a Stop during the host lookup must send - // nothing, so a reopen's query is the only one the service ever sees. - #[test] - fn a_stop_during_the_lookup_never_reaches_the_service() { - use std::sync::atomic::{AtomicUsize, Ordering}; - let host = "kt1b.test"; - let listener = - std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener"); - let addr = listener.local_addr().expect("stub address"); - listener.set_nonblocking(true).expect("nonblocking stub"); - let connections = Arc::new(AtomicUsize::new(0)); - { - let connections = connections.clone(); - std::thread::spawn(move || { - let t0 = std::time::Instant::now(); - while t0.elapsed() < Duration::from_secs(5) { - if listener.accept().is_ok() { - connections.fetch_add(1, Ordering::SeqCst); - } - std::thread::sleep(Duration::from_millis(5)); - } - }); - } - let src = source_via(host, T_IDLE, move || { - std::thread::sleep(Duration::from_millis(500)); - Ok(vec![addr]) - }); - let (out, _) = query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(100)); - assert_eq!( - out.expect_err("stopped").code(), - libfreemkv::error::E_HALTED - ); - assert!(eventually( - Duration::from_secs(3), - || query_slots_in_flight(host) == 0 - )); - std::thread::sleep(Duration::from_millis(300)); - assert_eq!( - connections.load(Ordering::SeqCst), - 0, - "a stopped query must send nothing" - ); - } - - // A Stop that is already pending costs nothing: no samples gathered, no MKB encoded. - #[test] - fn a_pending_stop_returns_before_the_body_is_built() { - use std::sync::atomic::{AtomicUsize, Ordering}; - struct CountingCtx(AtomicUsize); - impl ResolveCtx for CountingCtx { - fn disc_hash(&self) -> &str { - "0x422EB" - } - fn title(&self) -> Option<&str> { - None - } - fn vid(&self) -> Option { - None - } - fn mkb(&self) -> Result<&[u8], Error> { - self.0.fetch_add(1, Ordering::SeqCst); - Ok(&[]) - } - fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { - Ok(&[]) - } - fn samples(&self, _n: usize) -> Result>, Error> { - self.0.fetch_add(1, Ordering::SeqCst); - Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS]) - } - } - let src = source_via("kt1c.test", T_IDLE, || Ok(Vec::new())); - let halt = Halt::new(); - halt.cancel(); - let ctx = CountingCtx(AtomicUsize::new(0)); - let out = src.query_with(&ctx, &halt); - assert_eq!( - out.expect_err("stopped").code(), - libfreemkv::error::E_HALTED - ); - assert_eq!( - ctx.0.load(Ordering::SeqCst), - 0, - "no disc material read after a Stop" - ); - assert_eq!(take_last_decode_reachability(), None); - } - - // ── KU-K1: `last_failure_was_transport` (J13, J15) ────────────────────── - - // KU J23: the service derives keys from the VID it is sent (`vid_b64`; KS-16 "Kvu = - // AES-G(Km, IDv)"), so a Missing piece might open with the disc's VID in hand (E7034). - #[test] - fn online_source_uses_the_vid() { - assert!(OnlineSource::new("https://keyserver.test/keys", "s3cr3t").uses_vid()); - } - - #[test] - fn last_failure_was_transport_is_false_before_any_query() { - let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t"); - assert!(!src.last_failure_was_transport()); - } - - // Per J15/J13: a DNS failure never answers, so it is transport-class and gets retried. - #[test] - fn last_failure_was_transport_true_after_dns_failure() { - let src = source_via("kuk1a.test", T_IDLE, || { - Err((GuardFail::Unreachable, "did not resolve".into())) - }); - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); - assert!( - src.last_failure_was_transport(), - "a DNS failure is transport-class" - ); - } - - // A refused connection never answers either — transport-class (J13's "connect"). - #[test] - fn last_failure_was_transport_true_after_connect_refused() { - let refused: SocketAddr = ([127, 0, 0, 1], 1).into(); - let src = source_via("kuk1b.test", T_IDLE, move || Ok(vec![refused])); - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); - assert!( - src.last_failure_was_transport(), - "a refused connection is transport-class" - ); - } - - // Stop-design-v5 T16: "no bytes moved" for the idle bound is a transport-class timeout. - #[test] - fn last_failure_was_transport_true_after_idle_timeout() { - let addr = stub_server(Stub::NeverAnswer); - let src = source_via("kuk1c.test", T_IDLE, move || Ok(vec![addr])); - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); - assert!( - src.last_failure_was_transport(), - "an idle stall is transport-class" - ); - } - - // J15: a 5xx (or any decode reply) IS an answer, so it must never look transport-class — - // that would re-ask a source the service already answered ("never call it twice"). - #[test] - fn last_failure_was_transport_false_after_5xx() { - let addr = stub_server(Stub::Answer(503)); - let src = source_via("kuk1d.test", T_IDLE, move || Ok(vec![addr])); - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); - assert!( - !src.last_failure_was_transport(), - "the service answered (5xx) — never re-asked" - ); - } - - // J15: "reset it on success" — a later answer must clear a prior transport verdict. - #[test] - fn last_failure_was_transport_resets_on_success() { - let ok_addr = stub_server(Stub::Answer(200)); - let refused: SocketAddr = ([127, 0, 0, 1], 1).into(); - let target = Arc::new(Mutex::new(refused)); - let for_resolve = target.clone(); - let src = source_via("kuk1e.test", T_IDLE, move || { - Ok(vec![*for_resolve.lock().unwrap()]) - }); - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); - assert!(src.last_failure_was_transport()); - *target.lock().unwrap() = ok_addr; - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_ok()); - assert!( - !src.last_failure_was_transport(), - "a success must reset the flag" - ); - } - - // Regression: freemkv-library's server takes the reachability slot itself - // after `resolve`, to classify a no-key without a second probe. - // `last_failure_was_transport` must PEEK it, not TAKE it (never ask twice). - #[test] - fn recording_last_failure_does_not_erase_the_reachability_slot() { - let addr = stub_server(Stub::Answer(422)); - let src = source_via("kuk1f.test", T_IDLE, move || Ok(vec![addr])); - assert!(src.get_unit_keys(&ctx_with_mkb(0)).unwrap().is_empty()); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Status(422)), - "get_unit_keys must leave the slot for a later caller (e.g. the server) to read" - ); - - let addr = stub_server(Stub::Answer(422)); - let src = source_via("kuk1g.test", T_IDLE, move || Ok(vec![addr])); - assert!(src.get_fmts_indexes(&ctx_with_mkb(0)).unwrap().is_empty()); - assert_eq!( - take_last_decode_reachability(), - Some(DecodeReachability::Status(422)), - "get_fmts_indexes must leave the slot too" - ); - } - - // The base and forensic paths share `query_with`, so the flag must update - // through `get_fmts_indexes` exactly as it does through `get_unit_keys`. - #[test] - fn last_failure_was_transport_true_through_get_fmts_indexes() { - let addr = stub_server(Stub::NeverAnswer); - let src = source_via("kuk1h.test", T_IDLE, move || Ok(vec![addr])); - assert!(src.get_fmts_indexes(&ctx_with_mkb(0)).is_err()); - assert!( - src.last_failure_was_transport(), - "get_fmts_indexes must classify the failure too" - ); - } - - // Stop-design-v5 T16: a reply that stalls mid-BODY (not just before the - // first byte) is still "no bytes moved" past idle — transport-class. - #[test] - fn last_failure_was_transport_true_after_mid_body_stall() { - let addr = stub_server(Stub::StallBody); - let src = source_via("kuk1i.test", T_IDLE, move || Ok(vec![addr])); - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); - assert!( - src.last_failure_was_transport(), - "a mid-body stall is transport-class" - ); - } - - // J15: a definite answer after a transport failure must clear the flag — the - // NEXT verdict always wins, so a stale `true` never survives past one query. - #[test] - fn last_failure_was_transport_resets_from_true_on_5xx() { - let refused: SocketAddr = ([127, 0, 0, 1], 1).into(); - let answering = stub_server(Stub::Answer(503)); - let target = Arc::new(Mutex::new(refused)); - let for_resolve = target.clone(); - let src = source_via("kuk1j.test", T_IDLE, move || { - Ok(vec![*for_resolve.lock().unwrap()]) - }); - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); - assert!(src.last_failure_was_transport()); - *target.lock().unwrap() = answering; - assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); - assert!( - !src.last_failure_was_transport(), - "a 5xx answer must clear a prior transport verdict" - ); - } - // ── ST-K1b: `ctx.halt()` / `ctx.progress()` wired to KU's ctx (stop-design-v5 §2.7) ── - - // `get_unit_keys` must wait on `ctx.halt()`, not a fresh `Halt::new()` — a Stop - // reaching only the KU ctx must still land within one slice. - #[test] - fn stop_during_stalled_post_returns_halted_via_ctx_halt() { - let addr = stub_server(Stub::NeverAnswer); - let src = source_via("stk1b1.test", T_IDLE, move || Ok(vec![addr])); - let halt = Halt::new(); - let ctx = GuardCtx { - mkb: Vec::new(), - samples: MIN_SAMPLE_UNITS, - halt: Some(halt.clone()), - progress: None, - }; - let canceller = { - let halt = halt.clone(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(50)); - halt.cancel(); - }) - }; - let out = src.get_unit_keys(&ctx); - canceller.join().expect("canceller"); - assert_eq!( - out.expect_err("a ctx.halt() Stop is never an answer") - .code(), - libfreemkv::error::E_HALTED - ); - } - - // T29: `ctx.progress()` must bump on body bytes moved, WHILE the call is - // still running — not only once, after it ends. - #[test] - fn progress_bumps_during_a_slow_body() { - let n = 6; - let gap = T_IDLE / 2; - let addr = stub_server(Stub::TrickleBody { n, gap }); - let src = Arc::new(source_via("stk1b2.test", T_IDLE, move || Ok(vec![addr]))); - let progress = Liveness::new(); - let ctx = GuardCtx { - mkb: Vec::new(), - samples: MIN_SAMPLE_UNITS, - halt: None, - progress: Some(progress.clone()), - }; - let handle = { - let src = src.clone(); - std::thread::spawn(move || src.get_unit_keys(&ctx)) - }; - let seen_mid_flight = eventually(gap * n as u32 + T_IDLE, || progress.get() > 0); - let out = handle.join().expect("query thread"); - assert!( - seen_mid_flight, - "progress must bump before the trickle finishes" - ); - assert_eq!( - out.expect("a trickled {} body is a genuine miss"), - Vec::new() - ); - assert!( - progress.get() > 1, - "a multi-byte trickle must bump more than once, got {}", - progress.get() - ); - } - - // §2.1 bullet 3: "The K1 worker holds `busy()` while a key-service call is in - // flight." An `idle_only` `StallTimer` on the same `Liveness` must never see - // `Expired` while the call runs, even though a `NeverAnswer` stub sends nothing. - #[test] - fn busy_is_held_during_the_call() { - let addr = stub_server(Stub::NeverAnswer); - let src = Arc::new(source_via("stk1b3.test", T_IDLE, move || Ok(vec![addr]))); - let progress = Liveness::new(); - let ctx = GuardCtx { - mkb: Vec::new(), - samples: MIN_SAMPLE_UNITS, - halt: None, - progress: Some(progress.clone()), - }; - let mut timer = libfreemkv::halt::StallTimer::idle_only(T_IDLE / 4, &progress); - let handle = { - let src = src.clone(); - std::thread::spawn(move || src.get_unit_keys(&ctx)) - }; - std::thread::sleep(T_IDLE / 2); - let stall = timer.poll(&progress); - let _ = handle.join(); - assert!( - !matches!(stall, libfreemkv::halt::Stall::Expired), - "busy() must hold off the idle timer while the call is in flight: got {stall:?}" - ); - } -} +#[path = "online_tests.rs"] +mod tests; diff --git a/src/online_tests.rs b/src/online_tests.rs new file mode 100644 index 0000000..4681c8c --- /dev/null +++ b/src/online_tests.rs @@ -0,0 +1,1936 @@ +use super::*; +use std::net::Ipv4Addr; + +// host_certs() must return empty WITHOUT touching the network. Uses a +// non-empty base URL to prove the empty result is the deliberate no-op +// stub, not merely "no service configured". +#[test] +fn host_certs_is_noop_empty_no_network() { + let src = OnlineSource::new("http://example.test/keys", "secret"); + assert!( + KeySource::host_certs(&src, None).is_empty(), + "online host_certs must be an empty no-op (no network)" + ); + assert!( + KeySource::host_certs(&src, Some(68)).is_empty(), + "still empty regardless of the MKB generation" + ); +} + +// ── resolve_and_guard ────────────────────────────────────────────────── + +#[test] +fn resolve_and_guard_allows_lan_literals_and_rejects_invalid_ones() { + // Numeric literals resolve without DNS. https:// so a rejection is the + // address guard, not the scheme check (http:// has its own test below). + let lan = format!("{}.{}.{}.{}:8080", 192, 168, 0, 1); + for ok in ["127.0.0.1", "169.254.169.254", "[::1]:9000", lan.as_str()] { + assert!( + resolve_and_guard(&format!("https://{ok}/keys")).is_ok(), + "{ok}" + ); + } + for bad in [ + "0.0.0.0", + "224.0.0.1", + "255.255.255.255", + "240.0.0.1", + "[::]", + "[ff02::1]", + ] { + assert!( + resolve_and_guard(&format!("https://{bad}/keys")).is_err(), + "{bad}" + ); + } +} + +#[test] +fn resolve_and_guard_rejects_bad_scheme() { + assert!(resolve_and_guard("ftp://example.com/keys").is_err()); + assert!(resolve_and_guard("file:///etc/passwd").is_err()); + assert!(resolve_and_guard("not a url").is_err()); + assert!(resolve_and_guard("").is_err()); +} + +// Malformed-authority edges rejected BEFORE touching DNS: empty host, +// unterminated IPv6 literal, 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("https://[::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 `.test`) must report +// `GuardFail::Unreachable` ("service is down"), not `Config`. +#[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}"); +} + +// An EMPTY pinned address set 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" + ); +} + +// KT9 (replaces the 180 s totals test). Per spec, stop-design-v5 §2.7: "There is +// **no total cap.**" Every phase bound is a stall bound owned by the idle connector. +// Guard: do not change without a spec citation proving otherwise. +#[test] +fn agent_timeouts_are_idle_not_totals() { + let agent = hardened_agent(Vec::new()); + let t = agent.config().timeouts(); + assert_eq!(t.global, None, "no whole-request total"); + assert_eq!(t.per_call, None, "no per-call total"); + assert_eq!(t.send_request, None, "request headers: idle only"); + assert_eq!(t.send_body, None, "request body: idle only"); + assert_eq!( + t.recv_response, None, + "first byte: the T17 budget, not a total" + ); + assert_eq!(t.recv_body, None, "reply body: idle only"); + // T15: "Connect | 10 s with no answer (existing)". + assert_eq!(t.connect, Some(Duration::from_secs(10))); + // T16: "**60 s (D3)**. This replaces the 180 s totals". + assert_eq!(IDLE_TIMEOUT, Duration::from_secs(60)); +} + +#[test] +fn resolve_and_guard_accepts_public_literal() { + // Public numeric hosts resolve without DNS — must be accepted. + let addrs = resolve_and_guard("https://8.8.8.8/keys").expect("public IP must be accepted"); + assert!(!addrs.is_empty()); + assert_eq!(addrs[0].port(), 443); + + let addrs = + resolve_and_guard("https://1.1.1.1:8080/keys").expect("public IP with port accepted"); + assert!(!addrs.is_empty()); + assert_eq!(addrs[0].port(), 8080); +} + +// The pin is actually consulted: pin to a loopback listener, then ask for a `.test` host +// that CANNOT resolve. +#[test] +fn hardened_agent_connects_to_the_pinned_address_not_dns() { + use std::io::Write as _; + use std::net::TcpListener; + use std::sync::mpsc; + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener"); + let pinned = listener.local_addr().expect("stub listener address"); + let (tx, rx) = mpsc::channel(); + + let server = std::thread::spawn(move || { + let (mut sock, _peer) = listener.accept().expect("stub listener accept failed"); + // Signal that a connection ARRIVED before doing anything else: that + // arrival, on this exact socket, is the fact under test. + let _ = tx.send(()); + // Drain the request head, then answer with the smallest valid reply. + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + match sock.read(&mut byte) { + Ok(0) | Err(_) => break, + Ok(_) => head.push(byte[0]), + } + } + let _ = + sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"); + let _ = sock.flush(); + head + }); + + let sent = hardened_agent(vec![pinned]) + .post("http://keyserver.test/keys") + .send("{}"); + + // 1. The connection reached the pinned socket at all. Checked FIRST so + // a mis-wired resolver reports the resolver, not a confusing + // downstream symptom. + rx.recv_timeout(Duration::from_secs(10)).expect( + "hardened_agent never connected to the pinned address — the custom \ + resolver is not being consulted, so a DNS rebind between the guard \ + and the POST can still redirect the key material", + ); + // 2. The whole round-trip completed through it. Had the agent fallen + // back to live DNS, an unresolvable host is an error, never a 200. + let resp = sent.expect("the pinned round-trip must complete"); + assert_eq!(resp.status(), 200, "the stub server's reply must come back"); + // 3. The pin redirected the CONNECTION without rewriting the request: + // the original host still travels in the Host header. + let head = server.join().expect("stub server panicked"); + let head = String::from_utf8_lossy(&head); + assert!( + head.contains("keyserver.test"), + "the pinned agent must still address the original host; got: {head}" + ); +} + +// ureq 3 defaults to `Proxy::try_from_env()`; PinnedResolver would send the proxy connection to +// the key service's address, failing every lookup. Checked in a child so no test mutates env. +#[test] +fn the_key_service_agent_never_uses_an_environment_proxy() { + const CHILD: &str = "FMKV_KS_PROXY_CHILD"; + const NAME: &str = "online::tests::the_key_service_agent_never_uses_an_environment_proxy"; + if std::env::var_os(CHILD).is_some() { + assert!( + hardened_agent(Vec::new()).config().proxy().is_none(), + "the pinned agent picked up a proxy from the environment" + ); + return; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args([NAME, "--exact", "--test-threads=1"]) + .env(CHILD, "1") + .env("ALL_PROXY", "http://proxy.example:3128") + .env("HTTPS_PROXY", "http://proxy.example:3128") + .env("HTTP_PROXY", "http://proxy.example:3128") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success() && stdout.contains("1 passed"), + "child run failed:\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +// Stop rule (stall-based only, Stop can interrupt every wait): a factory build has no +// Halt, so its URL check does no DNS. It rejects what is wrong without a lookup and +// leaves the host lookup to the first query, which is Stop-aware (ST-K1b, J10). +#[test] +fn the_static_check_rejects_config_faults_without_a_lookup() { + for url in [ + "http://8.8.8.8/keys", + "ftp://example.com/keys", + "https:///keys", + "https://8.8.8.8:notaport/keys", + "https://[::1/keys", + "https://0.0.0.0/keys", + "https://224.0.0.1/keys", + "https://[ff02::1]:8443/keys", + ] { + let r = check_keyserver_url_static(url).expect_err(url); + assert_eq!(r.fault, KeyserverUrlFault::Permanent, "{url}"); + } + // A host name is not looked up: `.test` (RFC 2606) never resolves, yet it passes here. + assert_eq!( + check_keyserver_url_static("https://keys.ku-e1.test/keys"), + Ok(()) + ); + assert_eq!(check_keyserver_url_static("https://8.8.8.8/keys"), Ok(())); + let dns_before = DNS_LOOKUPS.load(Ordering::SeqCst); + let _ = check_keyserver_url_static("https://keys.example.com/keys"); + assert_eq!( + DNS_LOOKUPS.load(Ordering::SeqCst), + dns_before, + "no DNS lookup" + ); +} + +// The typed verdict autorip needs instead of matching message text: a standing fault +// (scheme, host, port, blocked address) is Permanent; only a failed lookup is Temporary. +#[test] +fn check_keyserver_url_types_permanent_rejections() { + for url in [ + "http://8.8.8.8/keys", + "ftp://example.com/keys", + "https:///keys", + "https://8.8.8.8:notaport/keys", + "https://[::1/keys", + "https://0.0.0.0/keys", + "https://240.0.0.1/latest/meta-data", + ] { + let r = check_keyserver_url(url).expect_err(url); + assert_eq!(r.fault, KeyserverUrlFault::Permanent, "{url}"); + assert!(!r.is_temporary(), "{url}"); + assert_eq!(Err(r.message.clone()), validate_keyserver_url(url), "{url}"); + assert_eq!(r.to_string(), r.message); + } + assert_eq!(check_keyserver_url("https://8.8.8.8/keys"), Ok(())); +} + +#[test] +fn a_failed_lookup_is_a_temporary_rejection() { + // Offline: every GuardFail::Unreachable reason resolve_and_guard produces. + for msg in [ + "DNS resolution timed out", + "could not resolve host: failed to lookup address information", + "host did not resolve to any address", + "too many concurrent DNS resolutions in flight for this host", + ] { + let r = KeyserverUrlRejection::from((GuardFail::Unreachable, msg.to_string())); + assert_eq!(r.fault, KeyserverUrlFault::Temporary, "{msg}"); + assert!(r.is_temporary()); + assert_eq!(r.message, msg); + } + let c = KeyserverUrlRejection::from((GuardFail::Config, "URL has no host".into())); + assert_eq!(c.fault, KeyserverUrlFault::Permanent); +} + +// Callers test their verdict mapping with the hook instead of a real DNS lookup. +#[test] +fn the_decode_reachability_hook_plants_what_take_reads() { + set_last_decode_reachability(Some(DecodeReachability::Status(422))); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Status(422)) + ); + assert_eq!(take_last_decode_reachability(), None); + set_last_decode_reachability(Some(DecodeReachability::Transport)); + set_last_decode_reachability(None); + assert_eq!(take_last_decode_reachability(), None); +} + +// ── bearer_header ────────────────────────────────────────────────────── + +#[test] +fn bearer_header_formats_token_and_omits_when_empty() { + // A configured token becomes a Bearer credential, sent verbatim. + assert_eq!( + bearer_header("s3cr3t-token"), + Some("Bearer s3cr3t-token".to_string()) + ); + // No token → no Authorization header (request goes out unauthenticated). + assert_eq!(bearer_header(""), None); +} + +// ── validate_keyserver_url ───────────────────────────────────────────── + +#[test] +fn validate_keyserver_url_allows_lan_and_rejects_invalid_and_bad_scheme() { + // A home app: loopback, RFC1918 and link-local key services are valid. + assert!(validate_keyserver_url("https://127.0.0.1/keys").is_ok()); + assert!(validate_keyserver_url("https://169.254.169.254/keys").is_ok()); + assert!(validate_keyserver_url(&format!("https://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_ok()); + assert!(validate_keyserver_url("https://[::1]:9000/keys").is_ok()); + assert!(validate_keyserver_url("https://0.0.0.0/keys").is_err()); + assert!(validate_keyserver_url("https://[ff02::1]/keys").is_err()); + assert!(validate_keyserver_url("ftp://example.com/keys").is_err()); + assert!(validate_keyserver_url("").is_err()); + // A public literal IP passes (no DNS needed, deterministic). + assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok()); +} + +// Cleartext http:// must be refused at CONFIG time (validate_keyserver_url), +// not per-rip — the body carries key material + a replayable token. A public +// host proves it's the SCHEME being rejected, not the address guard. +#[test] +fn non_https_scheme_is_rejected_at_config_time() { + // A perfectly reachable public host — only the http:// scheme is wrong. + assert!(validate_keyserver_url("http://8.8.8.8/keys").is_err()); + assert!(resolve_and_guard("http://8.8.8.8/keys").is_err()); + // The rejection is a standing Config fault, never a transient outage. + let (kind, _msg) = + resolve_and_guard("http://8.8.8.8/keys").expect_err("cleartext http:// must be refused"); + assert_eq!(kind, GuardFail::Config); + // https:// for the same public host is accepted. + assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok()); +} + +// A non-empty, non-`:port` tail after a bracketed IPv6 authority (e.g. +// `[::1]extra`) must be rejected as a Config fault, not silently dropped +// with a fall back to the default port. +#[test] +fn resolve_and_guard_rejects_garbage_after_bracketed_ipv6() { + // `[::1]junk` — junk after the closing bracket. + let (kind, _) = + resolve_and_guard("https://[::1]junk/keys").expect_err("garbage after ] must be rejected"); + assert_eq!(kind, GuardFail::Config); + // A public v6 literal with a trailing garbage tail is likewise rejected + // (so the garbage can't slip a request out on the default port). + assert!(resolve_and_guard("https://[2606:4700:4700::1111]extra/keys").is_err()); + // Sanity: the same public v6 literal WITHOUT the tail is accepted. + assert!(resolve_and_guard("https://[2606:4700:4700::1111]/keys").is_ok()); + // And an explicit :port after ] still parses. + let addrs = resolve_and_guard("https://[2606:4700:4700::1111]:8443/keys") + .expect("bracketed v6 with :port must parse"); + assert_eq!(addrs[0].port(), 8443); +} + +// ── the reply → verdict mapping (THE defect) ────────────────────────── +// Driven through `interpret_reply` directly: a stub HTTP server is NOT +// usable here since `resolve_and_guard` blocks loopback by design. + +/// A `ResolveCtx` that carries nothing — enough for the reply paths that do +/// not derive from a VUK. +struct BareCtx; +impl ResolveCtx for BareCtx { + 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(&[]) + } + fn samples(&self, _n: usize) -> Result>, Error> { + Ok(Vec::new()) + } +} + +/// A stub reply. ureq 3 has no `Response::new`; a response is just an +/// `http::Response` carrying a `ureq::Body`, which is constructible +/// directly — so these stay honest unit tests with no server involved. +fn reply(status: u16, body: &str) -> ureq::http::Response { + ureq::http::Response::builder() + .status(status) + .body(ureq::Body::builder().data(body)) + .expect("stub response") +} + +// THE regression: a 5xx for ~seven hours was reported as "no key" and +// sent operators hunting a VUK that was never missing. A 5xx must be a +// source FAILURE, distinguishable from a 200 with no entry. +#[test] +fn http_5xx_is_a_source_failure_not_a_missing_key() { + for status in [500u16, 502, 503, 504] { + let out = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 7); + assert_eq!( + out.expect_err("a 5xx must not look like an answer").code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, + "HTTP {status} must report the service as unavailable" + ); + } + + // The contrast case, through the SAME function: the service answered, + // and genuinely holds nothing for this disc. + let miss = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 7) + .expect("a 200 with no key is an ANSWER, not a failure"); + assert!(miss.is_empty(), "no key in the body → no keys out"); + + // And the two must not be the same outcome — the whole point. + let down = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 7); + assert!( + down.is_err(), + "502 and 200-with-no-entry must not collapse to the same result" + ); +} + +// Each status maps to a DIFFERENT operator action: fix the token +// (401/403), back off (429), wait (5xx). Definitive misses bypass this mapping. +#[test] +fn http_status_maps_to_the_operator_action() { + let cases: &[(u16, u16)] = &[ + (401, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED), + (403, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED), + (429, libfreemkv::error::E_KEY_SERVICE_RATE_LIMITED), + (500, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE), + (502, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE), + (400, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE), + ]; + for (status, want) in cases { + assert_eq!( + classify_http_status(*status).code(), + *want, + "HTTP {status} classified wrongly" + ); + assert_ne!( + classify_http_status(*status).code(), + libfreemkv::error::E_NO_DISC_KEY, + "service failures must not mean \"this disc has no key\"" + ); + } +} + +// A transport failure is the same verdict as a 5xx. Uses a refused +// connection to 127.0.0.1:1 for a REAL transport-class error; never +// reaches `query`, so the SSRF guard is not involved. +#[test] +fn transport_failure_is_a_source_failure() { + let config = Config::builder() + .timeout_connect(Some(Duration::from_secs(2))) + .build(); + let sent = ureq::Agent::new_with_config(config) + .post("http://127.0.0.1:1/") + .send("{}"); + // Which transport variant a refused connection produces is a + // platform detail; what matters (asserted below) is that it failed, + // and NOT with a status code — nothing on the other end answered. + assert!( + !matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))), + "a refused connection must fail as transport, never as an HTTP status" + ); + assert_eq!( + interpret_reply(sent, &BareCtx, 3) + .expect_err("an unreachable service must not look like an answer") + .code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, + ); +} + +// The decode POST records its reachability so the caller classifies a +// no-key from the REAL answer, not a second empty probe: an HTTP answer +// (200/404/422) → `Status(code)`, a transport failure → `Transport`. +#[test] +fn interpret_reply_records_the_decode_reachability() { + // A 200 with no entry — the service answered; record its status. + let _ = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 1); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Status(200)), + "a 200 answer must record Status(200)" + ); + // take clears the slot — a second take sees nothing. + assert_eq!( + take_last_decode_reachability(), + None, + "take must clear the slot" + ); + + // A definitive 422 ("licensed but unresolved") / 404 is still an ANSWER: + // record the status so the caller reads it as reachable (genuine no-key). + for status in [422u16, 404] { + let keys = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 1) + .expect("a definitive miss must allow the resolver to try the other FMTS phase"); + assert!(keys.is_empty()); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Status(status)), + "HTTP {status} must record Status({status}) — a reachable answer" + ); + } + + // A 5xx is an answer too — Status(502); the caller maps 5xx to down. + let _ = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 1); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Status(502)), + "a 5xx must record its status, not Transport" + ); + + // A transport failure (refused connection) records `Transport` — no + // HTTP answer, so the caller treats it as a transient outage. + let config = Config::builder() + .timeout_connect(Some(Duration::from_secs(2))) + .build(); + let sent = ureq::Agent::new_with_config(config) + .post("http://127.0.0.1:1/") + .send("{}"); + assert!( + !matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))), + "a refused connection must be a transport error" + ); + let _ = interpret_reply(sent, &BareCtx, 1); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Transport), + "a transport failure must record Transport, never a Status" + ); +} + +/// A reply the client cannot read is the service failing mid-answer, not an +/// answer of "no key". +#[test] +fn unparseable_reply_is_a_source_failure() { + assert_eq!( + interpret_reply(Ok(reply(200, "gateway error")), &BareCtx, 1) + .expect_err("non-JSON is not an answer") + .code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, + ); + // A malformed element in the UK set rejects the whole reply (a partial + // forensic set silently omits an index) — also a service fault. + assert_eq!( + interpret_reply( + Ok(reply( + 200, + r#"{"UK":["000102030405060708090a0b0c0d0e0f","zz"]}"# + )), + &BareCtx, + 1 + ) + .expect_err("a malformed key in the set is not an answer") + .code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, + ); +} + +/// Regression pin (passes before and after): a service that DOES hold the +/// key still resolves it, and array order is preserved as the forensic index +/// order. The Result-returning signature must not have changed the happy path. +#[test] +fn service_with_a_key_still_resolves_it_in_order() { + let keys = interpret_reply( + Ok(reply( + 200, + r#"{"UK":["000102030405060708090a0b0c0d0e0f","0f0e0d0c0b0a09080706050403020100"]}"#, + )), + &BareCtx, + 1, + ) + .expect("a 200 carrying keys resolves"); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].idx, 0); + assert_eq!(keys[1].idx, 1); + // Full 16 bytes of each key, not just byte 0 — a byte-transposition bug + // would pass a first-byte-only check. + assert_eq!( + keys[0].key, + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + ); + assert_eq!( + keys[1].key, + [15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + ); +} + +/// 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); + // Full 16-byte key, not just byte 0. + assert_eq!( + keys[0].key, + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + ); +} + +// A malformed `"UK"` STRING falls through to the genuine-miss path, +// distinct from the array form's "reject the whole reply" behaviour. +#[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); + // Full-byte KAT: the derived keys must equal the local VUK boil over + // the disc's encrypted title keys (the same primitive the code calls). + let vuk = parse_uk(vuk_hex).unwrap(); + let expected = crate::uks_from_vuk(&vuk, &[[0x11u8; 16], [0x22u8; 16]]); + assert_eq!(keys[0].key, expected[0].key); + assert_eq!(keys[1].key, expected[1].key); +} + +/// 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. +#[test] +fn address_guard_rejections_are_config_not_unreachable() { + for url in [ + "http://0.0.0.0/keys", + "http://240.0.0.1/latest/meta-data/", + "ftp://example.com/keys", + "not a url", + "", + ] { + assert_eq!( + resolve_and_guard(url).expect_err("must be rejected").0, + GuardFail::Config, + "{url} is a configuration fault, not an outage" + ); + } +} + +// ── The pre-flight guards in `query` (nothing leaves the process) ──────. + +/// A `ResolveCtx` whose MKB size and sample COUNT are dialled per guard. +#[derive(Default)] +struct GuardCtx { + mkb: Vec, + samples: usize, + halt: Option, + progress: Option, +} +impl ResolveCtx for GuardCtx { + fn disc_hash(&self) -> &str { + "0x422EB" + } + fn title(&self) -> Option<&str> { + None + } + fn vid(&self) -> Option { + None + } + fn mkb(&self) -> Result<&[u8], Error> { + Ok(&self.mkb) + } + fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { + Ok(&[]) + } + fn samples(&self, _n: usize) -> Result>, Error> { + Ok(vec![vec![0u8; 16]; self.samples]) + } + fn halt(&self) -> Option<&Halt> { + self.halt.as_ref() + } + fn progress(&self) -> Option<&Liveness> { + self.progress.as_ref() + } +} + +// An `http://` key-service URL must never be POSTed to; the source refuses with `Err`, not +// an empty that reads as "no key". +#[test] +fn cleartext_http_url_is_refused_before_anything_is_sent() { + let src = OnlineSource::new("http://keyserver.test/keys", "s3cr3t"); + let ctx = GuardCtx { + mkb: Vec::new(), + // Enough samples that ONLY the scheme guard can stop the request. + samples: MIN_SAMPLE_UNITS, + + ..Default::default() + }; + assert_eq!( + src.get_unit_keys(&ctx) + .expect_err("refusing to ask is a source failure, never a miss") + .code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, + "an http:// key-service URL must be refused, not sent — and reported" + ); +} + +/// An MKB larger than the forward cap cannot be sent; the source skips +/// rather than truncating the MKB (which would ask about a different disc). +#[test] +fn over_cap_mkb_skips_the_request() { + let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t"); + let ctx = GuardCtx { + mkb: vec![0u8; MAX_MKB_BYTES + 1], + samples: MIN_SAMPLE_UNITS, + + ..Default::default() + }; + assert!( + src.get_unit_keys(&ctx) + .expect("an un-forwardable MKB is a skip, not a service failure") + .is_empty(), + "an over-cap MKB must skip the online source" + ); +} + +/// Too few content samples make the service's answer ambiguous (it +/// identifies the key by which submitted unit decrypts), so the request is +/// never built. Proven at the boundary: `MIN_SAMPLE_UNITS - 1` skips. +#[test] +fn too_few_samples_skips_the_request() { + let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t"); + let ctx = GuardCtx { + mkb: Vec::new(), + samples: MIN_SAMPLE_UNITS - 1, + + ..Default::default() + }; + assert!( + src.get_unit_keys(&ctx) + .expect("under-sampling is a skip, not a service failure") + .is_empty(), + "fewer than MIN_SAMPLE_UNITS samples must skip the online source" + ); +} + +// ── MAX_RESPONSE_BYTES: the anti-OOM defence, both edges asserted so +// `+1` can't quietly truncate, nor an off-by-one reject a legal reply. +#[test] +fn over_cap_reply_is_rejected_and_an_at_cap_reply_still_parses() { + // The over-cap body is deliberately VALID, key-bearing JSON — junk + // would be rejected by the parser regardless of the cap. This one is + // only rejectable BY the cap. + let head = r#"{"UK":["000102030405060708090a0b0c0d0e0f"],"pad":""#; + let tail = r#""}"#; + let over = format!( + "{head}{}{tail}", + "p".repeat(MAX_RESPONSE_BYTES + 1 - head.len() - tail.len()) + ); + assert_eq!(over.len(), MAX_RESPONSE_BYTES + 1); + assert_eq!( + interpret_reply(Ok(reply(200, &over)), &BareCtx, 1) + .expect_err("an over-cap body must not be treated as an answer") + .code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, + ); + + // EXACTLY at the cap, and valid: still read and still answered. Padded + // with a JSON string so the length is exact and the key is real. + let pad = MAX_RESPONSE_BYTES - head.len() - tail.len(); + let at_cap = format!("{head}{}{tail}", "p".repeat(pad)); + assert_eq!(at_cap.len(), MAX_RESPONSE_BYTES); + let keys = interpret_reply(Ok(reply(200, &at_cap)), &BareCtx, 1) + .expect("a reply exactly at the cap is legal and must be read"); + assert_eq!(keys.len(), 1, "the key in an at-cap reply must survive"); + // The surviving key must be the planted UK bytes, not merely present. + assert_eq!( + keys[0].key, + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "the at-cap key must be the exact planted UK" + ); +} + +// ── A bad port is CONFIG, not an outage ──────────────────────────────── + +// A typo'd port must be `Config`, not `Unreachable` +#[test] +fn unparseable_port_is_a_config_fault_not_an_outage() { + for url in [ + "https://example.com:notaport/keys", + "http://example.com:99999/keys", // out of u16 range + "https://example.com:/keys", // empty port + ] { + assert_eq!( + resolve_and_guard(url).expect_err("must be rejected").0, + GuardFail::Config, + "{url} is a configuration typo, not a service outage" + ); + } + // A WELL-FORMED port is still split off the host and honoured. + let addrs = resolve_and_guard("https://8.8.8.8:8443/keys").expect("valid port accepted"); + assert_eq!(addrs[0].port(), 8443); +} + +// The caller-visible half: a mistyped port must get `Err`, exactly like an outage (only the +// log text differs) — catches the `Ok(Vec::new())` regression from `GuardFail::Config`. +#[test] +fn query_with_a_mistyped_port_reports_a_failure_not_a_miss() { + let src = OnlineSource::new("https://example.com:notaport/keys", "s3cr3t"); + let ctx = GuardCtx { + mkb: Vec::new(), + samples: MIN_SAMPLE_UNITS, + ..Default::default() + }; + assert_eq!( + src.get_unit_keys(&ctx) + .expect_err("a config fault means the service was never asked — never Ok(empty)") + .code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, + ); +} + +// A guard-BLOCKED address travels the same `GuardFail::Config` arm and +// must be just as loud — distinct from the mistyped port above because +// it fails AFTER resolution, on the address check. +#[test] +fn query_against_a_guard_blocked_address_reports_a_failure_not_a_miss() { + // 0.0.0.0 needs no DNS and is unconditionally rejected by is_blocked_ip. + let src = OnlineSource::new("https://0.0.0.0/keys", "s3cr3t"); + let ctx = GuardCtx { + mkb: Vec::new(), + samples: MIN_SAMPLE_UNITS, + ..Default::default() + }; + assert_eq!( + src.get_unit_keys(&ctx) + .expect_err("a blocked address means the service was never asked") + .code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE, + ); +} + +// `get_fmts_indexes` shares `query` with `get_unit_keys` — same +// guard-blocked, no-network path proves it is wired up. +#[test] +fn get_fmts_indexes_shares_the_same_query_path() { + let src = OnlineSource::new("https://0.0.0.0/keys", "s3cr3t"); + let ctx = GuardCtx { + mkb: Vec::new(), + samples: MIN_SAMPLE_UNITS, + ..Default::default() + }; + 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 does not resolve travels `query`'s OWN `Unreachable` arm +// (distinct from `Config` above) — still `Err`, 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, + ..Default::default() + }; + 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, + ); +} + +// `vid_b64`/`title` are assembled BEFORE the address guard runs, so a +// VID+title ctx still exercises that assembly even when the guard then +// rejects the address (same deterministic guard-blocked path, 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]) + } + } + // 0.0.0.0 needs no DNS and is unconditionally rejected — the guard + // fires AFTER the body (incl. vid/title) is already built. + let src = OnlineSource::new("https://0.0.0.0/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://0.0.0.0/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 reorders the SAME addresses; still the same set, so it must reuse the agent. +#[test] +fn a_reordered_but_identical_address_set_reuses_the_agent() { + let src = OnlineSource::new("https://keyserver.test/keys", ""); + let forward: Vec = vec![ + "8.8.8.8:443".parse().unwrap(), + "8.8.4.4:443".parse().unwrap(), + ]; + let reversed: Vec = forward.iter().rev().copied().collect(); + assert_ne!(forward, reversed, "the two orders must really differ"); + + let first = src.agent_for(forward); + let again = src.agent_for(reversed); + assert!( + Arc::ptr_eq(&first, &again), + "a reordered but identical address SET must reuse the pinned agent" + ); + + // Same cardinality, one address swapped: a genuinely different set, so + // never the same agent. + let changed: Vec = vec![ + "8.8.8.8:443".parse().unwrap(), + "1.1.1.1:443".parse().unwrap(), + ]; + assert!( + !Arc::ptr_eq(&first, &src.agent_for(changed)), + "a different address set must never reuse an agent pinned elsewhere" + ); +} + +// An FMTS disc calls `query` twice per rip; the agent is reused only while the freshly +// guarded address set is IDENTICAL. +#[test] +fn the_agent_is_reused_per_address_set_only() { + let src = OnlineSource::new("https://keyserver.test/keys", ""); + let a: Vec = vec!["8.8.8.8:443".parse().unwrap()]; + let b: Vec = vec!["1.1.1.1:443".parse().unwrap()]; + + let first = src.agent_for(a.clone()); + let second = src.agent_for(a.clone()); + assert!( + Arc::ptr_eq(&first, &second), + "the same pinned address set must reuse the agent (and its pooled TLS connection)" + ); + + let other = src.agent_for(b); + assert!( + !Arc::ptr_eq(&first, &other), + "a DIFFERENT address set must never reuse an agent pinned elsewhere" + ); + // And the address set is re-pinned, so going back re-builds. + let back = src.agent_for(a); + assert!(!Arc::ptr_eq(&first, &back)); +} + +/// Finding #9 regression: parse_uk must reject any non-hex byte up front so +/// sign prefixes / whitespace can't slip through the windowed 2-char parse +/// (`u8::from_str_radix` accepts "+5", "-A", etc.). +#[test] +fn parse_uk_rejects_non_hex_bytes() { + // Valid 32-char hex parses. + assert_eq!( + parse_uk("000102030405060708090a0b0c0d0e0f"), + Some([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + ); + // Sign-prefixed window: "+5" / "-A" would parse via from_str_radix. + assert!(parse_uk("+5000102030405060708090a0b0c0d0e").is_none()); + assert!(parse_uk("-A000102030405060708090a0b0c0d0e").is_none()); + // Embedded whitespace. + assert!(parse_uk("00 0102030405060708090a0b0c0d0e0f").is_none()); + // Wrong length is still rejected. + assert!(parse_uk("00").is_none()); +} + +// ── ST-K1a: stall-only key-service timeouts, mid-flight Stop (stop-design-v5 §2.7, §5.3) + +/// Scaled T16 idle bound for the loopback tests (60 s in production). +const T_IDLE: Duration = Duration::from_secs(1); +/// Idle bound for a Stop test against a silent stub: well past the Stop, so only the Stop +/// can end the query, and short enough for the abandoned worker to drain within the test. +const T_STOP_IDLE: Duration = Duration::from_secs(2); +/// How long a stub holds a stalled connection; far past every bound under test. +const STUB_HOLD: Duration = Duration::from_secs(20); + +fn source_via( + host: &str, + idle: Duration, + resolve: impl Fn() -> Result, (GuardFail, String)> + Send + Sync + 'static, +) -> OnlineSource { + let mut src = OnlineSource::new(format!("https://{host}/keys"), "s3cr3t"); + src.test_net = Some(TestNet { + resolve: Arc::new(resolve), + post_url: format!("http://{host}/keys"), + idle, + }); + src +} + +fn ctx_with_mkb(len: usize) -> GuardCtx { + GuardCtx { + mkb: vec![0x5a; len], + samples: MIN_SAMPLE_UNITS, + ..Default::default() + } +} + +/// What the loopback stub does with its one connection. +#[derive(Clone, Copy)] +enum Stub { + /// Read the whole request, then answer with this status and `{}`. + Answer(u16), + /// Read the whole request, then never answer. + NeverAnswer, + /// Read the request head only, then stop reading. + StallUpload, + /// Answer 200 with a 100-byte body, send 5 bytes of it, then stall. + StallBody, + /// Answer 200, then send an `n`-byte JSON body one byte per `gap`. + TrickleBody { n: usize, gap: Duration }, + /// Read the body `chunk` bytes per `gap`, then answer 200 with `{}`. + SlowReader { chunk: usize, gap: Duration }, +} + +fn read_head(sock: &mut std::net::TcpStream) -> usize { + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + match sock.read(&mut byte) { + Ok(0) | Err(_) => return 0, + Ok(_) => head.push(byte[0]), + } + } + let head = String::from_utf8_lossy(&head).to_ascii_lowercase(); + head.lines() + .find_map(|l| l.strip_prefix("content-length:")) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0) +} + +fn read_body(sock: &mut std::net::TcpStream, mut left: usize, chunk: usize, gap: Duration) { + let mut buf = vec![0u8; chunk.max(1)]; + while left > 0 { + let want = left.min(buf.len()); + match sock.read(&mut buf[..want]) { + Ok(0) | Err(_) => return, + Ok(n) => left -= n, + } + if !gap.is_zero() { + std::thread::sleep(gap); + } + } +} + +/// A one-connection HTTP/1.1 stub on loopback, playing the key service. +fn stub_server(kind: Stub) -> SocketAddr { + use std::io::Write as _; + let listener = + std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener"); + let addr = listener.local_addr().expect("stub address"); + std::thread::spawn(move || { + let Ok((mut sock, _)) = listener.accept() else { + return; + }; + let len = read_head(&mut sock); + let ok = |code: u16, body_len: usize| { + format!("HTTP/1.1 {code} X\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n") + }; + match kind { + Stub::Answer(code) => { + read_body(&mut sock, len, 64 * 1024, Duration::ZERO); + let _ = sock.write_all(format!("{}{{}}", ok(code, 2)).as_bytes()); + } + Stub::NeverAnswer => { + read_body(&mut sock, len, 64 * 1024, Duration::ZERO); + std::thread::sleep(STUB_HOLD); + } + Stub::StallUpload => std::thread::sleep(STUB_HOLD), + Stub::StallBody => { + read_body(&mut sock, len, 64 * 1024, Duration::ZERO); + let _ = sock.write_all(format!("{}{{\"UK\"", ok(200, 100)).as_bytes()); + std::thread::sleep(STUB_HOLD); + } + Stub::TrickleBody { n, gap } => { + read_body(&mut sock, len, 64 * 1024, Duration::ZERO); + let _ = sock.write_all(ok(200, n).as_bytes()); + let body = format!("{{{}}}", " ".repeat(n - 2)); + for b in body.bytes() { + std::thread::sleep(gap); + if sock.write_all(&[b]).is_err() { + return; + } + } + } + Stub::SlowReader { chunk, gap } => { + read_body(&mut sock, len, chunk, gap); + let _ = sock.write_all(format!("{}{{}}", ok(200, 2)).as_bytes()); + } + } + let _ = sock.flush(); + }); + addr +} + +/// Polls `cond` every 10 ms until it holds or `within` passes. +fn eventually(within: Duration, cond: impl Fn() -> bool) -> bool { + let t0 = std::time::Instant::now(); + while t0.elapsed() < within { + if cond() { + return true; + } + std::thread::sleep(Duration::from_millis(10)); + } + cond() +} + +/// Runs `query_with` on this thread and cancels `halt` after `after`; returns the +/// result and how long after the cancel it came back. +fn query_cancelled_after( + src: &OnlineSource, + ctx: &GuardCtx, + after: Duration, +) -> (Result, Error>, Duration) { + let halt = Halt::new(); + let canceller = { + let halt = halt.clone(); + std::thread::spawn(move || { + std::thread::sleep(after); + halt.cancel(); + std::time::Instant::now() + }) + }; + let out = src.query_with(ctx, &halt); + let returned = std::time::Instant::now(); + let cancelled = canceller.join().expect("canceller"); + (out, returned.saturating_duration_since(cancelled)) +} + +// KT1. Per spec, stop-design-v5 §2.7: "On Stop, the call returns `Halted` and +// records nothing." A hung host lookup must not hold the caller. +#[test] +fn query_halted_during_dns() { + let host = "kt1.test"; + let src = source_via(host, T_IDLE, || { + std::thread::sleep(Duration::from_secs(3)); + Err((GuardFail::Unreachable, "DNS resolution timed out".into())) + }); + let (out, after_cancel) = + query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(100)); + assert_eq!( + out.expect_err("a Stop is never an answer").code(), + libfreemkv::error::E_HALTED + ); + assert!( + after_cancel <= Duration::from_secs(1), + "Halted took {after_cancel:?}" + ); + assert_eq!( + take_last_decode_reachability(), + None, + "a Stop records nothing" + ); + assert!( + eventually(Duration::from_secs(8), || query_slots_in_flight(host) == 0), + "the abandoned worker frees its slot when its lookup returns" + ); +} + +// KT2. Per spec, stop-design-v5 §2.7: "The worker is abandoned. It ends on its own +// at the next T15–T17 bound, and its slot is freed then." +#[test] +fn query_halted_during_post() { + let host = "kt2.test"; + let addr = stub_server(Stub::NeverAnswer); + let src = source_via(host, T_STOP_IDLE, move || Ok(vec![addr])); + let (out, after_cancel) = + query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(150)); + assert_eq!( + out.expect_err("a Stop is never an answer").code(), + libfreemkv::error::E_HALTED + ); + assert!( + after_cancel <= Duration::from_secs(1), + "Halted took {after_cancel:?}" + ); + assert_eq!( + take_last_decode_reachability(), + None, + "a Stop records nothing" + ); + assert!( + eventually(Duration::from_secs(10), || query_slots_in_flight(host) == 0), + "the worker ends at its own first-byte bound and frees its slot" + ); +} + +// KT3. Per spec, stop-design-v5 §2.7 (D4): "When the cap is full, the caller waits +// halt-aware for a slot." The cap is "4 per host". +#[test] +fn slot_cap_waits_halt_aware() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let host = "kt3.test"; + let entered = Arc::new(AtomicUsize::new(0)); + let (release, gate) = std::sync::mpsc::channel::<()>(); + let gate = Arc::new(Mutex::new(gate)); + let src = Arc::new({ + let entered = entered.clone(); + source_via(host, T_IDLE, move || { + entered.fetch_add(1, Ordering::SeqCst); + let _ = gate.lock().unwrap().recv_timeout(Duration::from_secs(10)); + Err((GuardFail::Unreachable, "released".into())) + }) + }); + let spawn_query = |halt: Halt| { + let src = src.clone(); + std::thread::spawn(move || src.query_with(&ctx_with_mkb(0), &halt)) + }; + let first_four: Vec<_> = (0..4).map(|_| spawn_query(Halt::new())).collect(); + assert!(eventually(Duration::from_secs(2), || entered + .load(Ordering::SeqCst) + == 4)); + + let fifth = spawn_query(Halt::new()); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!( + entered.load(Ordering::SeqCst), + 4, + "the 5th query must wait for a slot" + ); + assert_eq!(query_slots_in_flight(host), MAX_QUERY_WORKERS_PER_HOST); + + // A cancel while waiting for a slot returns Halted and starts nothing. + let waiting = Halt::new(); + let sixth = spawn_query(waiting.clone()); + std::thread::sleep(Duration::from_millis(100)); + waiting.cancel(); + let sixth = sixth.join().expect("sixth query"); + assert_eq!( + sixth.expect_err("cancelled while waiting").code(), + libfreemkv::error::E_HALTED + ); + + // One slot frees, so exactly the waiting 5th proceeds. + release.send(()).unwrap(); + assert!(eventually(Duration::from_secs(2), || entered + .load(Ordering::SeqCst) + == 5)); + for _ in 0..4 { + release.send(()).unwrap(); + } + for q in first_four.into_iter().chain([fifth]) { + assert_eq!( + q.join().expect("query").expect_err("unreachable").code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE + ); + } + assert_eq!( + entered.load(Ordering::SeqCst), + 5, + "the cancelled 6th never started" + ); +} + +// KT4 (T16 a). Per spec, stop-design-v5 §2.7: "Receive headers and body | **60 s with +// no bytes read** | every read that returns bytes". A slow reply that keeps moving lives. +#[test] +fn slow_trickle_response_not_timed_out() { + let n = 8; + let gap = T_IDLE / 2; + assert!(gap * n as u32 > 3 * T_IDLE, "total must exceed 3 × idle"); + let addr = stub_server(Stub::TrickleBody { n, gap }); + let src = source_via("kt4.test", T_IDLE, move || Ok(vec![addr])); + let out = src.query_with(&ctx_with_mkb(0), &Halt::new()); + assert_eq!(out.expect("a moving reply is never timed out"), Vec::new()); +} + +// KT5 (T16 b). Per spec, stop-design-v5 T16: "no bytes moved" for 60 s → "`Transport` +// reachability". A reply that stops mid-body fails at idle, as a transport failure. +#[test] +fn stalled_response_body_times_out_on_idle() { + let addr = stub_server(Stub::StallBody); + let src = source_via("kt5.test", T_IDLE, move || Ok(vec![addr])); + let t0 = std::time::Instant::now(); + let out = src.query_with(&ctx_with_mkb(0), &Halt::new()); + let took = t0.elapsed(); + assert_eq!( + out.expect_err("a stalled reply is no answer").code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE + ); + assert!( + took <= T_IDLE + Duration::from_secs(1), + "stall cut after {took:?}" + ); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Transport) + ); +} + +// KT6 (T16 a, send side). Per spec, stop-design-v5 §2.7: "Send request and body | +// **60 s with no bytes written** | every write that moves bytes". +#[test] +fn slow_upload_not_timed_out() { + let gap = T_IDLE / 2; + let chunk = 1024 * 1024; + let mkb = 8 * 1024 * 1024; + let addr = stub_server(Stub::SlowReader { chunk, gap }); + let src = source_via("kt6.test", T_IDLE, move || Ok(vec![addr])); + let t0 = std::time::Instant::now(); + let out = src.query_with(&ctx_with_mkb(mkb), &Halt::new()); + assert!( + t0.elapsed() > 3 * T_IDLE, + "the upload must outlast 3 × idle to prove anything" + ); + assert_eq!(out.expect("a moving upload is never timed out"), Vec::new()); +} + +// KT7a. Per spec, stop-design-v5 T17: "**60 s + body / 256 KiB/s (D3)**" — the one +// phase with no byte signal while the server processes the upload. +#[test] +fn await_first_byte_budget_scales_with_body() { + let idle = Duration::from_secs(60); + assert_eq!(FIRST_BYTE_RATE, 256 * 1024); + assert_eq!(first_byte_budget(idle, 0), idle); + assert_eq!(first_byte_budget(idle, 256 * 1024), Duration::from_secs(61)); + assert_eq!( + first_byte_budget(idle, 128 * 1024), + Duration::from_millis(60_500) + ); + // The largest forwardable MKB (64 MiB) buys 256 s of server processing. + assert_eq!( + first_byte_budget(idle, 64 * 1024 * 1024), + Duration::from_secs(316) + ); + // The largest request saturates instead of overflowing, and never undercuts idle. + let huge = first_byte_budget(idle, u64::MAX); + assert!(huge >= idle && huge > first_byte_budget(idle, 64 * 1024 * 1024)); +} + +// KT7b. Per spec, stop-design-v5 T17: a server that never answers after the body fails +// at "60 s + body / 256 KiB/s" as `Transport` — not at the bare idle bound. +#[test] +fn await_first_byte_budget_expires_without_first_byte() { + let mkb = 96 * 1024; + // A lower bound on the bytes written: the base64 MKB alone. + let floor = first_byte_budget(T_IDLE, (mkb as u64).div_ceil(3) * 4); + assert!( + floor >= T_IDLE + Duration::from_millis(400), + "the body must matter" + ); + let addr = stub_server(Stub::NeverAnswer); + let src = source_via("kt7.test", T_IDLE, move || Ok(vec![addr])); + let t0 = std::time::Instant::now(); + let out = src.query_with(&ctx_with_mkb(mkb), &Halt::new()); + let took = t0.elapsed(); + assert_eq!( + out.expect_err("no first byte is no answer").code(), + libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE + ); + assert!( + took >= floor, + "fired at {took:?}, before the {floor:?} first-byte budget" + ); + assert!( + took <= floor + Duration::from_secs(1), + "fired late: {took:?}" + ); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Transport) + ); +} + +// KT8. Per spec, stop-design-v5 §2.7: "Reachability is recorded on the caller's thread, +// and only when the worker's result arrives." RFC 9110 §15.6 (SS-20): "The 5xx (Server +// Error) class of status code indicates that the server is aware that it has erred". +#[test] +fn reachability_recorded_on_caller_thread_only_on_result() { + for (host, code) in [("kt8a.test", 200u16), ("kt8b.test", 503)] { + let addr = stub_server(Stub::Answer(code)); + let src = source_via(host, T_IDLE, move || Ok(vec![addr])); + let _ = src.query_with(&ctx_with_mkb(0), &Halt::new()); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Status(code)), + "an answer ({code}) is recorded on the calling thread" + ); + } + // A Stop records nothing, then or later: the late worker result reaches no caller. + let host = "kt8c.test"; + let addr = stub_server(Stub::NeverAnswer); + let src = source_via(host, T_STOP_IDLE, move || Ok(vec![addr])); + let (out, _) = query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(100)); + assert_eq!( + out.expect_err("stopped").code(), + libfreemkv::error::E_HALTED + ); + assert!(eventually( + Duration::from_secs(10), + || query_slots_in_flight(host) == 0 + )); + assert_eq!( + take_last_decode_reachability(), + None, + "a stopped query records nothing" + ); +} + +// KT10. Per spec, stop-design-v5 §2.7: "a Stop … lands within one slice even while DNS, +// connect, upload or download is in progress." Each stall is cancelled at its stage. +#[test] +fn stop_mid_flight_returns_within_a_slice() { + let idle = Duration::from_secs(2); + for (host, stub, mkb) in [ + ("kt10a.test", Stub::NeverAnswer, 0), + ("kt10b.test", Stub::StallUpload, 8 * 1024 * 1024), + ("kt10c.test", Stub::StallBody, 0), + ] { + let addr = stub_server(stub); + let src = source_via(host, idle, move || Ok(vec![addr])); + let (out, after_cancel) = + query_cancelled_after(&src, &ctx_with_mkb(mkb), Duration::from_millis(300)); + assert_eq!( + out.expect_err(host).code(), + libfreemkv::error::E_HALTED, + "{host}" + ); + assert!( + after_cancel <= Duration::from_secs(1), + "{host}: Halted took {after_cancel:?}" + ); + assert_eq!( + take_last_decode_reachability(), + None, + "{host}: a Stop records nothing" + ); + // The abandoned worker ends at its own T16/T17 bound, which frees its slot. + let bound = first_byte_budget(idle, (mkb as u64).div_ceil(3) * 4 + 4096); + assert!( + eventually(bound + Duration::from_secs(2), || query_slots_in_flight( + host + ) == 0), + "{host}: the worker's slot was never freed" + ); + } +} + +// G12. Per spec, RFC 9110 §15.4 (SS-20): "The 3xx (Redirection) class of status code +// indicates that further action needs to be taken by the user agent". Never followed. +// Guard: do not change without a spec citation proving otherwise. +#[test] +fn the_agent_keeps_zero_redirects_and_no_proxy_after_the_timeout_change() { + for agent in [ + hardened_agent(Vec::new()), + hardened_agent_with(Vec::new(), T_IDLE), + ] { + assert_eq!(agent.config().max_redirects(), 0); + assert!(agent.config().proxy().is_none()); + } +} + +// G13. Per stop-design-v5 §5.9 G13: "the byte caps are unchanged: keysources +// `MAX_RESPONSE_BYTES`". Guard: do not change without a spec citation proving otherwise. +#[test] +fn the_reply_byte_cap_is_unchanged() { + assert_eq!(MAX_RESPONSE_BYTES, 1024 * 1024); +} + +// Per keys-upfront-design §2.1 invariant 4 ("No lookups after K exists") and the user +// rule "never call the key service twice": a Stop during the host lookup must send +// nothing, so a reopen's query is the only one the service ever sees. +#[test] +fn a_stop_during_the_lookup_never_reaches_the_service() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let host = "kt1b.test"; + let listener = + std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener"); + let addr = listener.local_addr().expect("stub address"); + listener.set_nonblocking(true).expect("nonblocking stub"); + let connections = Arc::new(AtomicUsize::new(0)); + { + let connections = connections.clone(); + std::thread::spawn(move || { + let t0 = std::time::Instant::now(); + while t0.elapsed() < Duration::from_secs(5) { + if listener.accept().is_ok() { + connections.fetch_add(1, Ordering::SeqCst); + } + std::thread::sleep(Duration::from_millis(5)); + } + }); + } + let src = source_via(host, T_IDLE, move || { + std::thread::sleep(Duration::from_millis(500)); + Ok(vec![addr]) + }); + let (out, _) = query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(100)); + assert_eq!( + out.expect_err("stopped").code(), + libfreemkv::error::E_HALTED + ); + assert!(eventually( + Duration::from_secs(3), + || query_slots_in_flight(host) == 0 + )); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!( + connections.load(Ordering::SeqCst), + 0, + "a stopped query must send nothing" + ); +} + +// A Stop that is already pending costs nothing: no samples gathered, no MKB encoded. +#[test] +fn a_pending_stop_returns_before_the_body_is_built() { + use std::sync::atomic::{AtomicUsize, Ordering}; + struct CountingCtx(AtomicUsize); + impl ResolveCtx for CountingCtx { + fn disc_hash(&self) -> &str { + "0x422EB" + } + fn title(&self) -> Option<&str> { + None + } + fn vid(&self) -> Option { + None + } + fn mkb(&self) -> Result<&[u8], Error> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(&[]) + } + fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { + Ok(&[]) + } + fn samples(&self, _n: usize) -> Result>, Error> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS]) + } + } + let src = source_via("kt1c.test", T_IDLE, || Ok(Vec::new())); + let halt = Halt::new(); + halt.cancel(); + let ctx = CountingCtx(AtomicUsize::new(0)); + let out = src.query_with(&ctx, &halt); + assert_eq!( + out.expect_err("stopped").code(), + libfreemkv::error::E_HALTED + ); + assert_eq!( + ctx.0.load(Ordering::SeqCst), + 0, + "no disc material read after a Stop" + ); + assert_eq!(take_last_decode_reachability(), None); +} + +// ── KU-K1: `last_failure_was_transport` (J13, J15) ────────────────────── + +// KU J23: the service derives keys from the VID it is sent (`vid_b64`; KS-16 "Kvu = +// AES-G(Km, IDv)"), so a Missing piece might open with the disc's VID in hand (E7034). +#[test] +fn online_source_uses_the_vid() { + assert!(OnlineSource::new("https://keyserver.test/keys", "s3cr3t").uses_vid()); +} + +#[test] +fn last_failure_was_transport_is_false_before_any_query() { + let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t"); + assert!(!src.last_failure_was_transport()); +} + +// Per J15/J13: a DNS failure never answers, so it is transport-class and gets retried. +#[test] +fn last_failure_was_transport_true_after_dns_failure() { + let src = source_via("kuk1a.test", T_IDLE, || { + Err((GuardFail::Unreachable, "did not resolve".into())) + }); + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); + assert!( + src.last_failure_was_transport(), + "a DNS failure is transport-class" + ); +} + +// A refused connection never answers either — transport-class (J13's "connect"). +#[test] +fn last_failure_was_transport_true_after_connect_refused() { + let refused: SocketAddr = ([127, 0, 0, 1], 1).into(); + let src = source_via("kuk1b.test", T_IDLE, move || Ok(vec![refused])); + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); + assert!( + src.last_failure_was_transport(), + "a refused connection is transport-class" + ); +} + +// Stop-design-v5 T16: "no bytes moved" for the idle bound is a transport-class timeout. +#[test] +fn last_failure_was_transport_true_after_idle_timeout() { + let addr = stub_server(Stub::NeverAnswer); + let src = source_via("kuk1c.test", T_IDLE, move || Ok(vec![addr])); + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); + assert!( + src.last_failure_was_transport(), + "an idle stall is transport-class" + ); +} + +// J15: a 5xx (or any decode reply) IS an answer, so it must never look transport-class — +// that would re-ask a source the service already answered ("never call it twice"). +#[test] +fn last_failure_was_transport_false_after_5xx() { + let addr = stub_server(Stub::Answer(503)); + let src = source_via("kuk1d.test", T_IDLE, move || Ok(vec![addr])); + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); + assert!( + !src.last_failure_was_transport(), + "the service answered (5xx) — never re-asked" + ); +} + +// J15: "reset it on success" — a later answer must clear a prior transport verdict. +#[test] +fn last_failure_was_transport_resets_on_success() { + let ok_addr = stub_server(Stub::Answer(200)); + let refused: SocketAddr = ([127, 0, 0, 1], 1).into(); + let target = Arc::new(Mutex::new(refused)); + let for_resolve = target.clone(); + let src = source_via("kuk1e.test", T_IDLE, move || { + Ok(vec![*for_resolve.lock().unwrap()]) + }); + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); + assert!(src.last_failure_was_transport()); + *target.lock().unwrap() = ok_addr; + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_ok()); + assert!( + !src.last_failure_was_transport(), + "a success must reset the flag" + ); +} + +// Regression: freemkv-library's server takes the reachability slot itself +// after `resolve`, to classify a no-key without a second probe. +// `last_failure_was_transport` must PEEK it, not TAKE it (never ask twice). +#[test] +fn recording_last_failure_does_not_erase_the_reachability_slot() { + let addr = stub_server(Stub::Answer(422)); + let src = source_via("kuk1f.test", T_IDLE, move || Ok(vec![addr])); + assert!(src.get_unit_keys(&ctx_with_mkb(0)).unwrap().is_empty()); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Status(422)), + "get_unit_keys must leave the slot for a later caller (e.g. the server) to read" + ); + + let addr = stub_server(Stub::Answer(422)); + let src = source_via("kuk1g.test", T_IDLE, move || Ok(vec![addr])); + assert!(src.get_fmts_indexes(&ctx_with_mkb(0)).unwrap().is_empty()); + assert_eq!( + take_last_decode_reachability(), + Some(DecodeReachability::Status(422)), + "get_fmts_indexes must leave the slot too" + ); +} + +// The base and forensic paths share `query_with`, so the flag must update +// through `get_fmts_indexes` exactly as it does through `get_unit_keys`. +#[test] +fn last_failure_was_transport_true_through_get_fmts_indexes() { + let addr = stub_server(Stub::NeverAnswer); + let src = source_via("kuk1h.test", T_IDLE, move || Ok(vec![addr])); + assert!(src.get_fmts_indexes(&ctx_with_mkb(0)).is_err()); + assert!( + src.last_failure_was_transport(), + "get_fmts_indexes must classify the failure too" + ); +} + +// Stop-design-v5 T16: a reply that stalls mid-BODY (not just before the +// first byte) is still "no bytes moved" past idle — transport-class. +#[test] +fn last_failure_was_transport_true_after_mid_body_stall() { + let addr = stub_server(Stub::StallBody); + let src = source_via("kuk1i.test", T_IDLE, move || Ok(vec![addr])); + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); + assert!( + src.last_failure_was_transport(), + "a mid-body stall is transport-class" + ); +} + +// J15: a definite answer after a transport failure must clear the flag — the +// NEXT verdict always wins, so a stale `true` never survives past one query. +#[test] +fn last_failure_was_transport_resets_from_true_on_5xx() { + let refused: SocketAddr = ([127, 0, 0, 1], 1).into(); + let answering = stub_server(Stub::Answer(503)); + let target = Arc::new(Mutex::new(refused)); + let for_resolve = target.clone(); + let src = source_via("kuk1j.test", T_IDLE, move || { + Ok(vec![*for_resolve.lock().unwrap()]) + }); + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); + assert!(src.last_failure_was_transport()); + *target.lock().unwrap() = answering; + assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err()); + assert!( + !src.last_failure_was_transport(), + "a 5xx answer must clear a prior transport verdict" + ); +} +// ── ST-K1b: `ctx.halt()` / `ctx.progress()` wired to KU's ctx (stop-design-v5 §2.7) ── + +// `get_unit_keys` must wait on `ctx.halt()`, not a fresh `Halt::new()` — a Stop +// reaching only the KU ctx must still land within one slice. +#[test] +fn stop_during_stalled_post_returns_halted_via_ctx_halt() { + let addr = stub_server(Stub::NeverAnswer); + let src = source_via("stk1b1.test", T_IDLE, move || Ok(vec![addr])); + let halt = Halt::new(); + let ctx = GuardCtx { + mkb: Vec::new(), + samples: MIN_SAMPLE_UNITS, + halt: Some(halt.clone()), + progress: None, + }; + let canceller = { + let halt = halt.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + halt.cancel(); + }) + }; + let out = src.get_unit_keys(&ctx); + canceller.join().expect("canceller"); + assert_eq!( + out.expect_err("a ctx.halt() Stop is never an answer") + .code(), + libfreemkv::error::E_HALTED + ); +} + +// T29: `ctx.progress()` must bump on body bytes moved, WHILE the call is +// still running — not only once, after it ends. +#[test] +fn progress_bumps_during_a_slow_body() { + let n = 6; + let gap = T_IDLE / 2; + let addr = stub_server(Stub::TrickleBody { n, gap }); + let src = Arc::new(source_via("stk1b2.test", T_IDLE, move || Ok(vec![addr]))); + let progress = Liveness::new(); + let ctx = GuardCtx { + mkb: Vec::new(), + samples: MIN_SAMPLE_UNITS, + halt: None, + progress: Some(progress.clone()), + }; + let handle = { + let src = src.clone(); + std::thread::spawn(move || src.get_unit_keys(&ctx)) + }; + let seen_mid_flight = eventually(gap * n as u32 + T_IDLE, || progress.get() > 0); + let out = handle.join().expect("query thread"); + assert!( + seen_mid_flight, + "progress must bump before the trickle finishes" + ); + assert_eq!( + out.expect("a trickled {} body is a genuine miss"), + Vec::new() + ); + assert!( + progress.get() > 1, + "a multi-byte trickle must bump more than once, got {}", + progress.get() + ); +} + +// §2.1 bullet 3: "The K1 worker holds `busy()` while a key-service call is in +// flight." An `idle_only` `StallTimer` on the same `Liveness` must never see +// `Expired` while the call runs, even though a `NeverAnswer` stub sends nothing. +#[test] +fn busy_is_held_during_the_call() { + let addr = stub_server(Stub::NeverAnswer); + let src = Arc::new(source_via("stk1b3.test", T_IDLE, move || Ok(vec![addr]))); + let progress = Liveness::new(); + let ctx = GuardCtx { + mkb: Vec::new(), + samples: MIN_SAMPLE_UNITS, + halt: None, + progress: Some(progress.clone()), + }; + let mut timer = libfreemkv::halt::StallTimer::idle_only(T_IDLE / 4, &progress); + let handle = { + let src = src.clone(); + std::thread::spawn(move || src.get_unit_keys(&ctx)) + }; + std::thread::sleep(T_IDLE / 2); + let stall = timer.poll(&progress); + let _ = handle.join(); + assert!( + !matches!(stall, libfreemkv::halt::Stall::Expired), + "busy() must hold off the idle timer while the call is in flight: got {stall:?}" + ); +} diff --git a/src/paths.rs b/src/paths.rs index 82ce263..1b7e53f 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -46,45 +46,5 @@ pub fn default_keydb_path() -> Option { } #[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 { - 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 [/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; diff --git a/src/paths_tests.rs b/src/paths_tests.rs new file mode 100644 index 0000000..8b06f50 --- /dev/null +++ b/src/paths_tests.rs @@ -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 { + 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 [/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() + ); +}