From 8f923fd5f9b5a01108978f98d4c6ef456d2fa39a Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:12:12 -0700 Subject: [PATCH] KU-K1: tests for answer_depends_on_samples and last_failure_was_transport KeydbSource::answer_depends_on_samples (KSK1) and MultiSource::answer_depends_on_samples (KSK2, KU3-6 "any(inner)") both default to `true` today, so a keydb-then-online composition would be re-asked per piece instead of once. OnlineSource::last_failure_was_transport (J15) defaults to `false`, so a transient DNS/connect/idle failure would never be retried by `resolve` (J13). Red: these six assertions fail against today's defaults. --- src/keydb.rs | 7 +++++ src/lib.rs | 30 ++++++++++++++++++++ src/online.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/src/keydb.rs b/src/keydb.rs index dc5bba7..c20bb1b 100644 --- a/src/keydb.rs +++ b/src/keydb.rs @@ -1142,6 +1142,13 @@ mod tests { 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()); + } + // 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. diff --git a/src/lib.rs b/src/lib.rs index da15f00..df54b54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -214,6 +214,36 @@ mod tests { 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()); + } + /// 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. diff --git a/src/online.rs b/src/online.rs index ce94ffd..11bd016 100644 --- a/src/online.rs +++ b/src/online.rs @@ -2838,4 +2838,82 @@ mod tests { ); assert_eq!(take_last_decode_reachability(), None); } + + // ── KU-K1: `last_failure_was_transport` (J13, J15) ────────────────────── + + #[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" + ); + } }