From 0ec14fc338ea34726cda7a2949d650111ac6ad7d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:18:27 -0700 Subject: [PATCH] keysources: sources report VID use, sample dependence and transport failures keydb and MultiSource say whether an answer depends on samples; online failures are classed as transport or not without erasing the reachability slot; OnlineSource and MultiSource report uses_vid, and partial stored keys plus a Km still report NoVid. Factory builds get a DNS-free key-service URL check. Tests decrypt through libfreemkv's test_util. --- Cargo.lock | 69 ++++++++++++- Cargo.toml | 5 + src/keydb.rs | 36 ++++++- src/keydb_format.rs | 2 +- src/lib.rs | 106 ++++++++++++++++++- src/online.rs | 246 +++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 455 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4cf184c..5b8fa99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "block-buffer" version = "0.12.1" @@ -240,6 +246,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -497,13 +513,22 @@ dependencies = [ "memchr", "rayon", "roxmltree", + "rustix", "serde", "serde_json", "sha1", + "sha2", + "socket2", "tracing", "zip", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -677,7 +702,7 @@ dependencies = [ "getrandom 0.2.17", "libc", "untrusted", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -686,6 +711,19 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.45" @@ -804,6 +842,16 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1021,6 +1069,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -1030,6 +1084,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1242,3 +1305,7 @@ version = "1.7.7" [[patch.unused]] name = "freemkv-i18n" version = "1.7.7" + +[[patch.unused]] +name = "freemkv-keysources" +version = "1.7.7" diff --git a/Cargo.toml b/Cargo.toml index 13a9b99..cdbfc31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,11 @@ tracing = "0.1" # `set_last_decode_reachability`, for consumers' tests only (enable in dev-dependencies). test-hooks = [] +[dev-dependencies] +# `test_util` fixtures (`decrypt_unit`, …) for this crate's tests (keys-upfront +# KU-P1). CI resolves it through the sibling path patch. +libfreemkv = { version = "1.7", features = ["test-util"] } + [profile.release] lto = "thin" codegen-units = 1 diff --git a/src/keydb.rs b/src/keydb.rs index dc5bba7..8dd4468 100644 --- a/src/keydb.rs +++ b/src/keydb.rs @@ -456,10 +456,10 @@ impl KeydbSource { // On a match with no key: carry the specific reason if we have one, else // leave it empty for the library to render a bare `NoDerivableKey`. - let miss_path = if keys.is_empty() { - miss_reason.map(|n| vec![n]).unwrap_or_default() - } else { - Vec::new() + // KU J23: `NoVid` stays beside partial keys (the VID would derive the rest). + let miss_path = match miss_reason { + Some(n) if keys.is_empty() || n == KeyNode::NoVid => vec![n], + _ => Vec::new(), }; KeydbResolution { @@ -641,6 +641,12 @@ impl KeySource for KeydbSource { } } + // Keyed by disc hash, not by samples: asked once per resolve, not per + // piece (KU §2.3 step 8, N-KU10; KSK1). + fn answer_depends_on_samples(&self) -> bool { + false + } + fn label(&self) -> &'static str { "keydb" } @@ -786,6 +792,19 @@ mod tests { 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] @@ -1142,6 +1161,15 @@ 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()); + // 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. diff --git a/src/keydb_format.rs b/src/keydb_format.rs index 0224a11..72e34a5 100644 --- a/src/keydb_format.rs +++ b/src/keydb_format.rs @@ -2423,7 +2423,7 @@ mod tests { for entry in &candidate_entries { for (_, key) in &entry.unit_keys { let mut unit = original.clone(); - libfreemkv::aacs::content::decrypt_unit(&mut unit, key); + 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) diff --git a/src/lib.rs b/src/lib.rs index da15f00..be0cb89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,8 @@ pub use keydb_format::{DiscEntry, KeyDb}; pub use online::set_last_decode_reachability; pub use online::{ DecodeReachability, KeyserverUrlFault, KeyserverUrlRejection, MIN_SAMPLE_UNITS, OnlineSource, - check_keyserver_url, take_last_decode_reachability, validate_keyserver_url, + check_keyserver_url, check_keyserver_url_static, take_last_decode_reachability, + validate_keyserver_url, }; pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths}; @@ -149,6 +150,23 @@ impl KeySource for MultiSource { fn label(&self) -> &'static str { "multi" } + + // KU3-6: depends on samples if ANY inner source does, so a nested + // sample-dependent source keeps getting the per-piece ask. + fn answer_depends_on_samples(&self) -> bool { + self.sources.iter().any(|s| s.answer_depends_on_samples()) + } + + // Same `any(inner)` rule (KU-K1 review): `resolve` retries the composition + // whenever any inner source's last failure was transport-class. + fn last_failure_was_transport(&self) -> bool { + self.sources.iter().any(|s| s.last_failure_was_transport()) + } + + // KU J23: any inner source that consumes the VID makes the composition one. + fn uses_vid(&self) -> bool { + self.sources.iter().any(|s| s.uses_vid()) + } } #[cfg(test)] @@ -214,6 +232,92 @@ 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()); + } + + // 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. diff --git a/src/online.rs b/src/online.rs index ce94ffd..d529ed5 100644 --- a/src/online.rs +++ b/src/online.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use std::io::Read; use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; @@ -176,6 +177,10 @@ fn split_authority(url: &str) -> Result<(String, u16), (GuardFail, String)> { Ok((host, port)) } +// Host lookups started, for the tests that prove a check made none. +#[cfg(test)] +static DNS_LOOKUPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + // Resolve `url`'s host, validating every address against the SSRF guard; // returns pinned socket addrs or a rejection reason + message. SECURITY: the // message names the address — log only at config time, never in `query`. @@ -206,6 +211,8 @@ fn resolve_and_guard(url: &str) -> Result, (GuardFail, String)> } let host = host.clone(); let (tx, rx) = mpsc::channel(); + #[cfg(test)] + DNS_LOOKUPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); std::thread::spawn(move || { let res = (host.as_str(), port) .to_socket_addrs() @@ -307,6 +314,23 @@ impl From<(GuardFail, String)> for KeyserverUrlRejection { } } +/// The key-service URL checks that need no DNS lookup: `https`, a host, a valid port, and +/// no literal non-public address (SSRF guard). Every rejection is +/// [`KeyserverUrlFault::Permanent`]. For a factory build, which no Stop can reach: the host +/// lookup (and its guard) runs at the first query, on the source's worker. That query is +/// not yet Stop-aware: it runs under its own `Halt` until ST-K1b wires `ctx.halt()` (J10). +pub fn check_keyserver_url_static(url: &str) -> Result<(), KeyserverUrlRejection> { + let (host, _) = split_authority(url).map_err(KeyserverUrlRejection::from)?; + let literal = host.trim_start_matches('[').trim_end_matches(']'); + match literal.parse::() { + Ok(ip) if is_blocked_ip(&ip) => Err(KeyserverUrlRejection::from(( + GuardFail::Config, + format!("refusing to connect to non-public address {ip} (SSRF guard)"), + ))), + _ => Ok(()), + } +} + /// [`validate_keyserver_url`] with a typed verdict: [`KeyserverUrlFault::Temporary`] only when /// the host lookup failed, so a caller can keep the source and retry at request time. pub fn check_keyserver_url(url: &str) -> Result<(), KeyserverUrlRejection> { @@ -608,6 +632,9 @@ pub struct OnlineSource { /// the identical address set, so the anti-rebinding guarantee is untouched: only the pooled /// TLS connection is reused, never a stale, un-reguarded address. agent: AgentCache, + /// KU-K1 (J15): whether the most recent `query` failure was transport-class + /// (`DecodeReachability::Transport`). Reset on every success. + last_failure_transport: AtomicBool, #[cfg(test)] test_net: Option, } @@ -641,6 +668,7 @@ impl OnlineSource { base_url: base_url.into(), secret: secret.into(), agent: Arc::new(Mutex::new(None)), + last_failure_transport: AtomicBool::new(false), #[cfg(test)] test_net: None, } @@ -747,6 +775,16 @@ impl OnlineSource { .unwrap_or_else(|_| self.base_url.clone()); run_on_worker(&host, halt, move || job.run()) } + + // KU-K1 (J15): classify the query just finished, from a PEEK (never a + // `take`) at the slot — a caller downstream of `resolve` (freemkv-library's + // server) still needs to read the same verdict once (`query_with` clears it). + fn record_last_failure_transport(&self, result: &Result, Error>) { + let transport = result.is_err() + && LAST_DECODE_REACHABILITY.with(|c| c.get()) == Some(DecodeReachability::Transport); + self.last_failure_transport + .store(transport, Ordering::Relaxed); + } } /// One key-service round-trip, owned so it can run on a worker thread. @@ -1065,14 +1103,18 @@ impl KeySource for OnlineSource { // Base per-CPS-unit Unit Keys via `query`: `Ok(empty)` means the service answered with no // key, `Err` means it could not answer. fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result, Error> { - self.query_with(ctx, &Halt::new()) + let result = self.query_with(ctx, &Halt::new()); + self.record_last_failure_transport(&result); + result } // AACS 2.1 forensic index set: same `query` round-trip as // `get_unit_keys`, but the mux's samples are a single-phase anchor batch // and the service's array position tags each forensic index. fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result, Error> { - self.query_with(ctx, &Halt::new()) + let result = self.query_with(ctx, &Halt::new()); + self.record_last_failure_transport(&result); + result } fn label(&self) -> &'static str { @@ -1081,6 +1123,17 @@ impl KeySource for OnlineSource { // host_certs: no-op default. No online cert fetch/endpoint today, so // OEM certs fall back to another source (e.g. keydb); no network touched. + + // KU-K1 (J15): only a transport-class failure (no answer at all) is retried by `resolve`. + fn last_failure_was_transport(&self) -> bool { + self.last_failure_transport.load(Ordering::Relaxed) + } + + // KU J23: the service derives keys from the VID it is sent (`vid_b64`), so a Missing + // piece might open with the disc's VID in hand. + fn uses_vid(&self) -> bool { + true + } } // The `Authorization` header value, or `None` when no secret is configured @@ -1421,6 +1474,39 @@ mod tests { ); } + // 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 (not Stop-aware until 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://127.0.0.1/keys", + "https://169.254.169.254/latest/meta-data", + "https://[::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] @@ -2838,4 +2924,160 @@ mod tests { ); 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)).is_err()); + 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)).is_err()); + 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" + ); + } }