Files
freemkv-keysources/src/online.rs
T
Matthew Jackson fc8b6e3a05 bump ureq 3.4.2 + preserve rolling-idle body timeout
ureq 3.4.1 (#1194, "Fix timeout budgets restarting and applying to later
phases") made timeout_recv_response bound only the response HEADERS and
turned timeout_recv_body into an ABSOLUTE total-body deadline. The online
key-service POST set recv_response only, so under 3.4.2 the reply body read
had NO deadline at all — a server that sent headers then stalled mid-reply
would hang the key request forever.

The /decode reply is a small JSON key answer that arrives in one read (a
few hundred bytes; the 1 MiB cap is a defensive anti-OOM ceiling, not an
expected size), so this is a fine total-cap case, not a large-body slow-link
download: no rolling-idle connector is warranted. Set timeout_recv_body to
the same TIMEOUT_SECS total ceiling so the POST can never hang forever.

Regression guard the_reply_body_read_is_bounded_not_only_the_headers asserts
recv_body is now set. Full suite + precommit (fmt/clippy/deny/tests) green.
2026-09-20 20:03:54 -07:00

1802 lines
79 KiB
Rust

//! Online key-service source.
use std::io::Read;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::uks_from_vuk;
use base64::Engine;
use libfreemkv::aacs::types::UnitKey;
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
use libfreemkv::{Error, KeySource};
use ureq::config::Config;
use ureq::http::Uri;
use ureq::unversioned::resolver::{ResolvedSocketAddrs, Resolver};
use ureq::unversioned::transport::{DefaultConnector, NextTimeout};
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
// libfreemkv's `read_mkb_content` MAX_BYTES (64 MiB), so a capturable MKB is
// never silently un-forwardable here (headroom, not an expected size).
const MAX_MKB_BYTES: usize = 64 * 1024 * 1024;
const TIMEOUT_SECS: u64 = 180;
/// Minimum encrypted-content samples the online source will send in one key
/// request — re-exported from the base crate
/// ([`libfreemkv::keysource::MIN_SAMPLE_UNITS`]) so this crate and
/// libfreemkv's own FMTS forensic query share ONE value.
///
/// A request carrying fewer samples is refused (empty result, never sent)
/// since the service identifies the key by which submitted unit it decrypts
/// and too few risks a false-positive match. Kept public so gathering
/// callers (the CLI, autorip) sample at least this many.
pub use libfreemkv::keysource::MIN_SAMPLE_UNITS;
/// Hard cap on the key-service response body. A real unit-key reply is a few
/// hundred bytes; bound the read so a malicious/compromised server can't drive
/// the client to OOM with an unbounded body.
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
// ── SSRF guard — see docs/online-ssrf-guard.md ───────────────────────────────
// is_blocked_ip: true when `ip` must never be an outbound key-service POST
// target (loopback, link-local incl. cloud metadata, RFC1918, IPv4-mapped).
fn is_blocked_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local() // 169.254.0.0/16, incl. 169.254.169.254
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified()
|| v4.is_multicast()
// Carrier-grade NAT 100.64.0.0/10.
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40)
// "This network" 0.0.0.0/8.
|| v4.octets()[0] == 0
// Benchmarking 198.18.0.0/15 (RFC 2544) — 198.18.x and 198.19.x
// (the /15 second octet is 18 with the low bit free, i.e. 18|19).
|| (v4.octets()[0] == 198 && (v4.octets()[1] & 0xfe) == 18)
// IETF protocol assignments 192.0.0.0/24 (RFC 6890), which
// includes 192.0.0.170/171 (NAT64/DNS64 discovery). Distinct
// from 192.0.2.0/24 TEST-NET-1, already caught by is_documentation.
|| (v4.octets()[0] == 192 && v4.octets()[1] == 0 && v4.octets()[2] == 0)
// Class E reserved 240.0.0.0/4.
|| v4.octets()[0] >= 240
}
IpAddr::V6(v6) => {
let seg = v6.segments();
// 6to4 (2002::/16) embeds an IPv4 in segments[1..3]; Teredo
// (2001:0000::/32) embeds the client IPv4 in the last two segments,
// each XOR 0xffff. Both must be re-checked as their embedded IPv4.
let sixtofour = (seg[0] == 0x2002)
.then(|| std::net::Ipv4Addr::from(((seg[1] as u32) << 16) | (seg[2] as u32)));
let teredo = (seg[0] == 0x2001 && seg[1] == 0x0000).then(|| {
std::net::Ipv4Addr::from(
(((seg[6] ^ 0xffff) as u32) << 16) | ((seg[7] ^ 0xffff) as u32),
)
});
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) embeds the IPv4 in
// the last 32 bits (segments[6..8]); re-check it too so an internal
// target does not slip through a NAT64 translator.
let nat64 = (seg[0] == 0x0064 && seg[1] == 0xff9b)
.then(|| std::net::Ipv4Addr::from(((seg[6] as u32) << 16) | (seg[7] as u32)));
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
// Unique-local fc00::/7.
|| (seg[0] & 0xfe00) == 0xfc00
// Link-local fe80::/10.
|| (seg[0] & 0xffc0) == 0xfe80
// IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x,
// deprecated by RFC 4291 §2.5.5.1) — to_ipv4() returns Some for
// both forms; re-check the embedded address as IPv4.
|| v6
.to_ipv4()
.map(|m| is_blocked_ip(&IpAddr::V4(m)))
== Some(true)
|| sixtofour.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|| teredo.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|| nat64.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
}
}
}
// Why resolve_and_guard rejected a URL, split by the operator action each
// demands: Config is a standing misconfiguration (never self-heals),
// Unreachable is the service down now. Both are Err from query, never Ok(empty).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GuardFail {
// Malformed URL, bad scheme, or a host that resolves to a non-public
// address. Operator configuration; retrying changes nothing.
Config,
// Host did not resolve — DNS failure or timeout. Service unreachable;
// nothing known about this disc's key. Transient.
Unreachable,
}
// 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`.
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)> {
// ONLY https: the POST body carries base64 key material and a replayable
// bearer token, so cleartext `http://` is refused here in the shared guard —
// a standing operator fault (`Config`) caught once at config time, not per rip.
let Some(authority) = url.strip_prefix("https://") else {
return Err((
GuardFail::Config,
"URL scheme must be https:// (cleartext http:// is refused)".into(),
));
};
let default_port = 443u16;
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
let authority = authority.rsplit('@').next().unwrap_or(authority);
if authority.is_empty() {
return Err((GuardFail::Config, "URL has no host".into()));
}
let (host, port): (String, u16) = if let Some(stripped) = authority.strip_prefix('[') {
match stripped.split_once(']') {
Some((h, after)) => {
// The only thing allowed after `]` is an optional `:port`. A non-empty
// tail that isn't `:port` (e.g. `[::1]extra`) is garbage — reject as
// `Config` rather than silently drop it and connect on the default port.
let p = if after.is_empty() {
default_port
} else if let Some(port_str) = after.strip_prefix(':') {
port_str
.parse::<u16>()
.map_err(|_| (GuardFail::Config, "invalid port".to_string()))?
} else {
return Err((GuardFail::Config, "malformed IPv6 authority".into()));
};
(h.to_string(), p)
}
None => return Err((GuardFail::Config, "malformed IPv6 host".into())),
}
} else if let Some((h, p)) = authority.rsplit_once(':') {
match p.parse::<u16>() {
Ok(p) => (h.to_string(), p),
// A malformed port (e.g. `:notaport`) is an operator config typo,
// not a service outage — reject as `Config`, same as the
// bracketed-IPv6 branch above. See docs/online-guardfail.md.
Err(_) => return Err((GuardFail::Config, "invalid port".into())),
}
} else {
(authority.to_string(), default_port)
};
if host.is_empty() {
return Err((GuardFail::Config, "URL has no host".into()));
}
// `to_socket_addrs` is a BLOCKING DNS lookup that can hang for the OS
// resolver timeout and freeze the calling rip thread, so run it on a
// spawned thread with a bounded deadline (mirrors autorip/libfreemkv).
let addrs: Vec<SocketAddr> = {
use std::sync::mpsc;
const DNS_TIMEOUT: Duration = Duration::from_secs(10);
// Resolver threads can hang for the OS timeout and are never joined, leaking
// a thread+stack per attempt — so cap outstanding ones PER HOST (a global cap
// lets one dead keyserver starve a healthy different one). Const-init map.
const MAX_DNS_THREADS_PER_HOST: usize = 4;
static DNS_INFLIGHT: Mutex<std::collections::BTreeMap<String, usize>> =
Mutex::new(std::collections::BTreeMap::new());
{
let mut inflight = DNS_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
let n = inflight.entry(host.clone()).or_insert(0);
if *n >= MAX_DNS_THREADS_PER_HOST {
return Err((
GuardFail::Unreachable,
"too many concurrent DNS resolutions in flight for this host".into(),
));
}
*n += 1;
}
let host = host.clone();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let res = (host.as_str(), port)
.to_socket_addrs()
.map(|it| it.collect::<Vec<SocketAddr>>());
// Receiver may be gone after the timeout — ignore the send error.
let _ = tx.send(res);
// Release this host's slot only when the (possibly long-hung) lookup
// actually returns, so the cap reflects lookups truly in flight.
let mut inflight = DNS_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
if let Some(n) = inflight.get_mut(&host) {
*n -= 1;
if *n == 0 {
inflight.remove(&host);
}
}
});
match rx.recv_timeout(DNS_TIMEOUT) {
Ok(Ok(addrs)) => addrs,
Ok(Err(e)) => {
return Err((
GuardFail::Unreachable,
format!("could not resolve host: {e}"),
));
}
Err(_) => return Err((GuardFail::Unreachable, "DNS resolution timed out".into())),
}
};
if addrs.is_empty() {
return Err((
GuardFail::Unreachable,
"host did not resolve to any address".into(),
));
}
for a in &addrs {
if is_blocked_ip(&a.ip()) {
return Err((
GuardFail::Config,
format!(
"refusing to connect to non-public address {} (SSRF guard)",
a.ip()
),
));
}
}
Ok(addrs)
}
/// Validate a key-service base URL before it is handed to [`OnlineSource`].
/// Requires `https` (cleartext `http` is rejected as a `Config` fault — see
/// `resolve_and_guard`), extracts the host, and rejects any host that is — or
/// resolves to — loopback / link-local (incl. 169.254.169.254 cloud metadata)
/// / RFC1918 / ULA / other non-public address (SSRF guard). Returns `Ok(())`
/// so a caller can gate `OnlineSource`; the error string says why.
///
/// The *config-time* check; [`OnlineSource`] re-guards before each POST, closing the DNS-rebind window.
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
}
// ureq's `ResolvedSocketAddrs` is a fixed 16-slot array; `push`ing a 17th
// address panics (out of bounds) on a host with many A records. Keep the
// first 16 — each already validated by `resolve_and_guard`.
const MAX_PINNED_ADDRS: usize = 16;
// The pinned-address resolver behind `hardened_agent`. Must be wired via
// `Agent::with_parts` — `new_with_config` silently keeps live DNS and
// reopens the rebind window. See docs/online-ssrf-guard.md.
#[derive(Debug)]
struct PinnedResolver(Vec<SocketAddr>);
impl Resolver for PinnedResolver {
fn resolve(
&self,
_uri: &Uri,
_config: &Config,
_timeout: NextTimeout,
) -> Result<ResolvedSocketAddrs, ureq::Error> {
let mut out = self.empty();
for addr in self.0.iter().take(MAX_PINNED_ADDRS) {
out.push(*addr);
}
if out.is_empty() {
// The trait's contract: at least one address, or this error.
return Err(ureq::Error::HostNotFound);
}
Ok(out)
}
}
/// Build a ureq agent that follows zero redirects (so a public URL can't
/// 30x-redirect to an internal host) and pins DNS resolution to `pinned`
/// (the addresses already validated by [`resolve_and_guard`]).
fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
let config = Config::builder()
.max_redirects(0)
.timeout_connect(Some(Duration::from_secs(10)))
.timeout_recv_response(Some(Duration::from_secs(TIMEOUT_SECS)))
// Since ureq 3.4.1 (#1194) recv_response bounds only the HEADERS; without
// a body deadline a stalled reply hangs the POST forever. The JSON key
// answer arrives in one read, so a TOTAL cap is the right shape here.
.timeout_recv_body(Some(Duration::from_secs(TIMEOUT_SECS)))
.build();
// `with_parts`, never `new_with_config` — see [`PinnedResolver`].
ureq::Agent::with_parts(config, DefaultConnector::new(), PinnedResolver(pinned))
}
pub struct OnlineSource {
base_url: String,
secret: String,
/// The last agent built, with the address set (sorted, deduped — an
/// order-insensitive SET KEY) it was pinned to. Reused only when a fresh
/// resolve + SSRF-guard of the host yields the identical address set, so
/// the anti-rebinding guarantee is untouched: only the pooled TLS
/// connection is reused, never a stale, un-reguarded address. See
/// docs/online-agent-cache.md for why this exists and why the key is a
/// set rather than an ordered sequence.
agent: Mutex<Option<(Vec<SocketAddr>, Arc<ureq::Agent>)>>,
}
impl OnlineSource {
pub fn new(base_url: impl Into<String>, secret: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
secret: secret.into(),
agent: Mutex::new(None),
}
}
// The agent pinned to `pinned` — the cached one when the address set is
// unchanged, a fresh one otherwise. Poisoning is recovered from: a panic
// elsewhere must not make every later key request panic.
fn agent_for(&self, pinned: Vec<SocketAddr>) -> Arc<ureq::Agent> {
// Compare SETS, not sequences — see the `agent` field's doc.
let key = {
let mut k = pinned.clone();
k.sort();
k.dedup();
k
};
let mut guard = self.agent.lock().unwrap_or_else(|e| e.into_inner());
if let Some((cached_key, agent)) = guard.as_ref()
&& *cached_key == key
{
return agent.clone();
}
let agent = Arc::new(hardened_agent(pinned));
*guard = Some((key, agent.clone()));
agent
}
// The server-resolved Unit Keys for this disc: one round-trip, returning
// a terminal `UK` or a `VUK` derived locally. `Ok`/`Err` draw the
// miss-vs-outage distinction — see docs/online-query-contract.md.
fn query(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
// Reset the per-thread decode-reachability slot so it reflects ONLY this
// query afterwards: `Some(..)` once a POST answers/transport-fails, `None`
// if we short-circuit before the network. See `take_last_decode_reachability`.
clear_decode_reachability();
// No configured service: nothing to resolve.
if self.base_url.is_empty() {
return Ok(Vec::new());
}
// Refuse to transmit over plaintext: the body carries base64 key
// material and, when configured, a replayable bearer token. `Err`,
// not `Ok(empty)` — see docs/online-query-guards.md.
if !self.base_url.starts_with("https://") {
tracing::error!(
target: "freemkv::keysource",
"key-service URL is not https:// — refusing to send key material \
and credentials in cleartext; the service was NOT asked about this disc \
(fix the URL scheme), which is not the same as this disc having no key"
);
return Err(Error::KeyServiceUnavailable);
}
let mkb = ctx.mkb().unwrap_or(&[]);
// No-URL / bad-URL / over-cap-or-under-sampled draw three different
// verdicts — see docs/online-query-guards.md. Logged: a silent empty
// here reads as "no key", so the real cause (64 MiB cap) is surfaced.
if mkb.len() > MAX_MKB_BYTES {
tracing::warn!(
target: "freemkv::keysource",
mkb_len = mkb.len(),
cap = MAX_MKB_BYTES,
"MKB exceeds the key-service forward cap; skipping the online source for this disc (no key from online)"
);
return Ok(Vec::new());
}
// Prove the minimum by TYPE: `DecodeSampleSet` only exists with >=
// MIN_SAMPLE_UNITS units, so the request can't be built under-sized
// (too few risks the service matching an incidental unit — FMTS).
let gathered = ctx.samples(64).unwrap_or_default();
let n = gathered.len();
let Some(samples) = DecodeSampleSet::new(gathered) else {
tracing::info!(
target: "freemkv::keysource",
samples = n,
min = MIN_SAMPLE_UNITS,
"too few content samples for a reliable online key request; skipping the online source"
);
return Ok(Vec::new());
};
let b64 = base64::engine::general_purpose::STANDARD;
let mut body = serde_json::json!({
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
// derivation, so it needs the unparsed blob (not enc_title_keys).
"inf_b64": b64.encode(ctx.unit_key_ro()),
"mkb_b64": b64.encode(mkb),
});
if let Some(vid) = ctx.vid() {
body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0));
}
// Encrypted-content samples for server-side ciphertext validation (already
// gathered + minimum-checked above).
body["units_b64"] = serde_json::Value::Array(
samples
.units()
.iter()
.map(|u| serde_json::Value::String(b64.encode(u)))
.collect(),
);
// The disc's own title (UDF/ISO volume id), plain text. The key service
// catalogs it by disc_hash (its disc-titles.json) — independent of keydb.
if let Some(label) = ctx.title().map(str::trim)
&& !label.is_empty()
{
body["title"] = serde_json::Value::String(label.to_string());
}
// Resolve + SSRF-guard the host just before the POST; pin the
// validated addresses so a DNS rebind between config time and fetch
// time can't redirect the request to an internal/metadata host.
let pinned = match resolve_and_guard(&self.base_url) {
Ok(addrs) => addrs,
// Did-not-RESOLVE is the service unreachable, not a bad URL — see
// docs/online-guardfail.md.
Err((GuardFail::Unreachable, _)) => {
// The host did not resolve — the service never answered, so this
// is a transport-class outcome for the decode-reachability slot
// (transient/down), the same verdict a refused connection gets.
record_decode_reachability(DecodeReachability::Transport);
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
"key-service host did not resolve (DNS failure or timeout); \
the service is unreachable, not out of keys"
);
return Err(Error::KeyServiceUnavailable);
}
Err((GuardFail::Config, _)) => {
// Log THAT the URL was rejected, never WHY (the message names
// the address). `error!` + `Err`, not `Ok(empty)` — see
// docs/online-guardfail.md for the full rationale.
tracing::error!(
target: "freemkv::keysource",
phase = "keyserver_post",
"key-service URL failed the address guard — the service was NOT asked about this disc; \
this is a standing misconfiguration (fix the URL), not a disc without a key"
);
// Borrows transient E7028 pending a 70xx config code upstream.
return Err(Error::KeyServiceUnavailable);
}
};
let agent = self.agent_for(pinned);
let mut req = agent.post(&self.base_url);
if let Some(value) = bearer_header(&self.secret) {
req = req.header("Authorization", &value);
}
// Begin/end around the keyserver round-trip: bounded by
// `hardened_agent`'s connect/read timeouts, so this can never block
// forever. SECURITY: never log `body` — it carries base64 key material.
tracing::info!(target: "freemkv::keysource", phase = "keyserver_post", "begin");
let post_t0 = std::time::Instant::now();
let sent = req.send_json(body);
interpret_reply(sent, ctx, post_t0.elapsed().as_millis() as u64)
}
}
/// The reachability outcome of a single online `/decode` POST — the raw signal
/// a caller needs to tell a *genuine no-key* (the service answered, e.g. a 200
/// with no entry or a definitive 422/404) from a *transient outage* (it did not
/// — transport failure or a 5xx) WITHOUT firing a second probe. Recorded per
/// thread by [`OnlineSource`] on every decode attempt; read (and cleared) with
/// [`take_last_decode_reachability`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecodeReachability {
/// The service answered with this HTTP status — any 2xx/3xx/4xx/5xx,
/// including a 200 no-key, a 404/422 ("licensed but unresolved"), a 429, or
/// a 502. The caller maps the code to a verdict.
Status(u16),
/// No HTTP answer at all — connection refused, timeout, DNS/TLS failure, so
/// the request was sent (or attempted) but nothing on the other end replied.
Transport,
}
thread_local! {
// The reachability of the most recent decode POST on THIS thread. A
// thread-local (not a struct field) because resolution runs synchronously on
// the caller's thread while `OnlineSource` is boxed behind `dyn KeySource`.
static LAST_DECODE_REACHABILITY: std::cell::Cell<Option<DecodeReachability>> =
const { std::cell::Cell::new(None) };
}
fn clear_decode_reachability() {
LAST_DECODE_REACHABILITY.with(|c| c.set(None));
}
fn record_decode_reachability(outcome: DecodeReachability) {
LAST_DECODE_REACHABILITY.with(|c| c.set(Some(outcome)));
}
/// Take — read and clear — the reachability outcome of the most recent online
/// `/decode` POST made ON THIS THREAD, or `None` if no decode reached the
/// network since the last take (the online source was not attempted, or a
/// short-circuit path that never POSTed). Lets a caller classify a no-key result
/// from the REAL decode's HTTP outcome instead of a second, redundant probe.
pub fn take_last_decode_reachability() -> Option<DecodeReachability> {
LAST_DECODE_REACHABILITY.with(std::cell::Cell::take)
}
// Map a key-service HTTP status into the operator action it implies: 401/403
// fix credentials, 429 back off, 5xx wait — none of them is "no key" (the
// genuine miss is a 200 with an empty body), the original bug this fixes.
fn classify_http_status(code: u16) -> Error {
match code {
401 | 403 => Error::KeyServiceUnauthorized,
429 => Error::KeyServiceRateLimited,
_ => Error::KeyServiceUnavailable,
}
}
// Turn the raw key-service POST outcome into this source's answer. Split
// out of `query` so the reply -> verdict mapping is testable WITHOUT a
// network. SECURITY: logs status/byte-count/labels only — never `body`.
fn interpret_reply(
sent: Result<ureq::http::Response<ureq::Body>, ureq::Error>,
ctx: &dyn ResolveCtx,
elapsed_ms: u64,
) -> Result<Vec<UnitKey>, Error> {
let mut resp = match sent {
Ok(r) => {
// The service answered — record its status for the reachability slot
// (a 200/404/422 is "up"; a 5xx is "down"), read by the caller so a
// no-key needs no second probe.
record_decode_reachability(DecodeReachability::Status(r.status().as_u16()));
r
}
Err(e) => {
// A 4xx/5xx is still an ANSWER (record its status); a transport
// error is not (record `Transport`). Kept separate from the
// error-mapping below, which is unchanged.
record_decode_reachability(match &e {
ureq::Error::StatusCode(code) => DecodeReachability::Status(*code),
_ => DecodeReachability::Transport,
});
// 401/403/429/5xx demand different operator actions; collapsing
// them is why a 502 was read as "no key". Each arm RETURNS the
// classified error, never an empty vec, so it survives past here.
return Err(match e {
ureq::Error::StatusCode(code) => {
let err = classify_http_status(code);
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
http_status = code,
error_code = err.code(),
elapsed_ms,
"key service returned an HTTP error; no ANSWER from online (not a missing key)"
);
err
}
// ureq 3's non_exhaustive transport-error enum (`Io`, `Timeout`,
// `Tls`, ...) all mean the same thing here — nothing answered —
// so a catch-all stays correct as the enum grows.
_ => {
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
elapsed_ms,
"key service unreachable (connect/timeout/TLS); no ANSWER from online (not a missing key)"
);
Error::KeyServiceUnavailable
}
});
}
};
tracing::info!(
target: "freemkv::keysource",
phase = "keyserver_post",
elapsed_ms,
"end"
);
// Bounded read: cap the body so a hostile server can't OOM the client.
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
let mut buf = Vec::new();
if resp
.body_mut()
.as_reader()
.take(MAX_RESPONSE_BYTES as u64 + 1)
.read_to_end(&mut buf)
.is_err()
|| buf.len() > MAX_RESPONSE_BYTES
{
// Length only — never the body, which carries base64 key material.
// A truncated / over-cap body is the service failing mid-answer: the
// question went unanswered, so this is a source failure, not a miss.
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
cap = MAX_RESPONSE_BYTES,
"key-service reply was unreadable or over the size cap; no ANSWER from online"
);
return Err(Error::KeyServiceUnavailable);
}
let json: serde_json::Value = match serde_json::from_slice(&buf) {
Ok(j) => j,
Err(_) => {
// Never log the parse error or payload (a serde message quotes
// the offending input, i.e. key material). Unparseable is a
// service bug, not "no key".
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
"key-service reply was not valid JSON; no ANSWER from online"
);
return Err(Error::KeyServiceUnavailable);
}
};
// `UK` is an ARRAY of hex keys — one for the base Unit Key, or an
// ordered forensic-index set. Array position tags each key (i -> index
// i+1). A bare string is still accepted for backward compatibility.
if let Some(uk) = json.get("UK") {
let mut out = Vec::new();
if let Some(s) = uk.as_str() {
if let Some(k) = parse_uk(s) {
out.push(UnitKey::new(0, k));
}
} else if let Some(arr) = uk.as_array() {
// A forensic set is only usable COMPLETE (the mux trusts any
// non-empty result as the whole set). Skipping a bad element
// would silently omit an index, so reject the whole reply.
let mut bad = false;
for (i, v) in arr.iter().enumerate() {
match v.as_str().and_then(parse_uk) {
Some(k) => out.push(UnitKey::new(i as u32, k)),
None => {
bad = true;
break;
}
}
}
if bad {
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
keys = arr.len(),
"key-service returned a malformed key in the UK set; rejecting the whole reply"
);
return Err(Error::KeyServiceUnavailable);
}
}
if !out.is_empty() {
return Ok(out);
}
}
// A VUK is derived to the terminal keys locally, via the disc's
// encrypted title keys from the context — the library owns the crypto.
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
match ctx.enc_title_keys() {
Ok(enc) => return Ok(uks_from_vuk(&vuk, enc)),
Err(_) => {
// The SERVICE answered; the DISC's encrypted title keys are
// what could not be read — a disc-side reason, not `Err` here.
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
"key-service returned a VUK but the disc's encrypted title keys \
are unreadable; cannot derive unit keys"
);
return Ok(Vec::new());
}
}
}
// The genuine miss — the ONLY path returning `Ok(empty)` from a completed
// round-trip, logged distinctly from every failure above so a 502 can
// never again look like a missing key. `E7022` is the truth here.
tracing::info!(
target: "freemkv::keysource",
phase = "keyserver_post",
"key service has no key for this disc"
);
Ok(Vec::new())
}
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 — see
// docs/online-query-contract.md.
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
self.query(ctx)
}
// 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<Vec<UnitKey>, Error> {
self.query(ctx)
}
fn label(&self) -> &'static str {
"online"
}
// 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.
}
// The `Authorization` header value, or `None` when no secret is configured
// (request goes out unauthenticated). Sent verbatim as an HTTP Bearer
// credential — the token comes from `--key-auth` (CLI) / `keyserver_secret`.
fn bearer_header(secret: &str) -> Option<String> {
if secret.is_empty() {
None
} else {
Some(format!("Bearer {secret}"))
}
}
fn parse_uk(hex: &str) -> Option<[u8; 16]> {
// The one workspace hex parser: byte-based (rejects sign chars / multi-byte),
// 32 hex digits → [u8; 16], with an optional 0x/0X prefix tolerated.
libfreemkv::hex::parse_hex_fixed::<16>(hex)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
// ── is_blocked_ip ──────────────────────────────────────────────────────
#[test]
fn ssrf_guard_blocks_loopback_private_and_metadata() {
// Loopback.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
// RFC1918 private ranges.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
// Cloud-metadata anycast (link-local 169.254.0.0/16).
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(
169, 254, 169, 254
))));
// Carrier-grade NAT 100.64.0.0/10 and "this network" 0.0.0.0/8.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
// IPv6 loopback, ULA fc00::/7, link-local fe80::/10.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0xfd00, 0, 0, 0, 0, 0, 0, 1
))));
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0, 0, 1
))));
// IPv4-mapped loopback ::ffff:127.0.0.1 must also be blocked.
assert!(is_blocked_ip(&IpAddr::V6(
Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()
)));
// IPv4-compatible loopback ::127.0.0.1 (= ::7f00:1, deprecated RFC
// 4291 §2.5.5.1) — to_ipv4_mapped() misses this form; to_ipv4() catches
// both mapped and compatible.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0, 0x7f00, 0x0001
))));
// Class E reserved 240.0.0.0/4.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(
255, 255, 255, 254
))));
}
// Benchmarking 198.18.0.0/15 (RFC 2544) and IETF protocol assignments
// 192.0.0.0/24 (RFC 6890, incl. the 192.0.0.170/171 NAT64/DNS64 anycast)
// are non-public and must be blocked outbound.
#[test]
fn ssrf_guard_blocks_benchmarking_and_protocol_assignment_ranges() {
// 198.18.0.0/15 spans 198.18.x AND 198.19.x — both octets blocked.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 255, 255))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 200, 5))));
// 198.17.x and 198.20.x are OUTSIDE the /15 — must stay allowed.
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 17, 0, 1))));
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 20, 0, 1))));
// 192.0.0.0/24, including 192.0.0.170 / 192.0.0.171.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 0))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 170))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 171))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 255))));
// The adjacent 192.0.1.0 is a different block — not covered here.
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 1, 1))));
}
// 6to4 (2002::/16) and Teredo (2001:0000::/32) tunnel an IPv4 inside an
// IPv6 address; the guard must decode and re-check that embedded IPv4 or an
// internal target slips through the tunnel.
#[test]
fn ssrf_guard_blocks_embedded_ipv4_via_6to4_and_teredo() {
// 6to4 for 127.0.0.1: 2002:7f00:0001:: (embedded in segments[1..3]).
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0x7f00, 0x0001, 0, 0, 0, 0, 0
))));
// 6to4 for 169.254.169.254 (cloud metadata): 2002:a9fe:a9fe::.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0xa9fe, 0xa9fe, 0, 0, 0, 0, 0
))));
// Teredo for 127.0.0.1: client IPv4 lives in the last two segments XOR
// 0xffff, so 0x7f00^0xffff=0x80ff and 0x0001^0xffff=0xfffe.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2001, 0x0000, 0, 0, 0, 0, 0x80ff, 0xfffe
))));
// A 6to4 wrapping a PUBLIC IPv4 (8.8.8.8 → 2002:0808:0808::) is allowed.
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0x0808, 0x0808, 0, 0, 0, 0, 0
))));
}
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) translates IPv4 targets
// into IPv6; the guard must decode the trailing IPv4 and re-check it or an
// internal address slips through the translator.
#[test]
fn ssrf_guard_blocks_nat64_wellknown_prefix() {
// NAT64 for 169.254.169.254 (cloud metadata): 64:ff9b::a9fe:a9fe.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0xa9fe, 0xa9fe
))));
// NAT64 for 127.0.0.1: 64:ff9b::7f00:0001.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0x7f00, 0x0001
))));
// NAT64 wrapping a PUBLIC IPv4 (8.8.8.8 → 64:ff9b::0808:0808) is allowed.
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0x0808, 0x0808
))));
}
#[test]
fn ssrf_guard_allows_public_ips() {
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
// Public IPv6 (Cloudflare DNS 2606:4700:4700::1111).
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111
))));
}
// 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_rejects_internal_literals() {
// Numeric literals resolve without DNS — must still be rejected. All
// https:// so the rejection is the SSRF address guard, not the scheme
// check (cleartext http:// is covered by its own test below).
assert!(resolve_and_guard("https://127.0.0.1/keys").is_err());
assert!(resolve_and_guard("https://169.254.169.254/latest/meta-data/").is_err());
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}:8080/keys", 10, 0, 0, 5)).is_err());
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}/keys", 192, 168, 0, 1)).is_err());
assert!(resolve_and_guard("https://[::1]:9000/keys").is_err());
}
#[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"
);
}
// The POST reply body has its own deadline, not just the headers: ureq
// 3.4.1+ recv_response covers headers only, so without recv_body a stalled
// reply would hang the key request forever.
#[test]
fn the_reply_body_read_is_bounded_not_only_the_headers() {
let agent = hardened_agent(Vec::new());
let t = agent.config().timeouts();
assert_eq!(
t.recv_body,
Some(Duration::from_secs(TIMEOUT_SECS)),
"ureq 3.4.1+ recv_response covers headers only; without recv_body the \
reply read has no deadline at all"
);
assert_eq!(t.recv_response, Some(Duration::from_secs(TIMEOUT_SECS)));
}
#[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 (a mis-wired resolver fails OPEN to
// live DNS with no symptom — see docs/online-ssrf-guard.md): 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}"
);
}
// ── 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_rejects_internal_and_bad_scheme() {
// Mirrors resolve_and_guard: the public wrapper rejects the same hosts.
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_err());
assert!(validate_keyserver_url("https://169.254.169.254/latest/meta-data/").is_err());
assert!(validate_keyserver_url(&format!("https://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_err());
assert!(validate_keyserver_url("https://[::1]:9000/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<libfreemkv::aacs::types::Vid> {
None
}
fn mkb(&self) -> Result<&[u8], Error> {
Ok(&[])
}
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
Ok(&[])
}
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, 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::Body> {
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) — never "no key" from a status.
#[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),
(404, 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,
"no HTTP status may ever 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 can classify a
// no-key from the REAL answer instead of firing a second empty probe: an
// HTTP answer (200/404/422) → `Status(code)`, a transport failure →
// `Transport`. This is the signal that removes autorip's redundant probe.
#[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 [404u16, 422] {
let _ = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 1);
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, "<html>gateway error</html>")), &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<libfreemkv::aacs::types::Vid> {
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<Vec<Vec<u8>>, 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<libfreemkv::aacs::types::Vid> {
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<Vec<Vec<u8>>, 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://127.0.0.1/keys",
"http://169.254.169.254/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) ──────
// See docs/online-preflight-guard-tests.md — why each guard's verdict
// differs and why `.test` is the discriminator host used below.
/// A `ResolveCtx` whose MKB size and sample COUNT are dialled per guard.
struct GuardCtx {
mkb: Vec<u8>,
samples: usize,
}
impl ResolveCtx for GuardCtx {
fn disc_hash(&self) -> &str {
"0x422EB"
}
fn title(&self) -> Option<&str> {
None
}
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
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<Vec<Vec<u8>>, Error> {
Ok(vec![vec![0u8; 16]; self.samples])
}
}
// An `http://` key-service URL must never be POSTed to; the source
// refuses with `Err`, not an empty that reads as "no key" — see
// docs/online-query-guards.md.
#[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,
};
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,
};
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,
};
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` — see
// docs/online-guardfail.md for why collapsing the two is the bug.
#[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` (docs/online-guardfail.md).
#[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,
};
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() {
// 127.0.0.1 needs no DNS and is unconditionally rejected by is_blocked_ip.
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
};
assert_eq!(
src.get_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://127.0.0.1/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
};
assert_eq!(
src.get_fmts_indexes(&ctx)
.expect_err("a blocked address means the service was never asked")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
}
// A host that 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,
};
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<libfreemkv::aacs::types::Vid> {
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<Vec<Vec<u8>>, Error> {
Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS])
}
}
// 127.0.0.1 needs no DNS and is unconditionally rejected — the guard
// fires AFTER the body (incl. vid/title) is already built.
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
assert_eq!(
src.get_unit_keys(&VidTitleCtx)
.expect_err("a blocked address means the service was never asked")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
}
/// A title that is present but ALL WHITESPACE must not be sent — `query`
/// trims then checks `is_empty()` before adding the `title` field.
#[test]
fn query_skips_a_whitespace_only_title() {
struct WhitespaceTitleCtx;
impl ResolveCtx for WhitespaceTitleCtx {
fn disc_hash(&self) -> &str {
"0x422EB"
}
fn title(&self) -> Option<&str> {
Some(" ")
}
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
None
}
fn mkb(&self) -> Result<&[u8], Error> {
Ok(&[])
}
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
Ok(&[])
}
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS])
}
}
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
// Reaches the same guard-blocked failure either way; this test's
// value is in exercising the whitespace-title branch without panic.
assert!(src.get_unit_keys(&WhitespaceTitleCtx).is_err());
}
// ── One agent per address set, not one per query ──────────────────────
// A round-robin keyserver reorders the SAME addresses; still the same
// set, so it must reuse the agent — see docs/online-agent-cache.md.
#[test]
fn a_reordered_but_identical_address_set_reuses_the_agent() {
let src = OnlineSource::new("https://keyserver.test/keys", "");
let forward: Vec<SocketAddr> = vec![
"8.8.8.8:443".parse().unwrap(),
"8.8.4.4:443".parse().unwrap(),
];
let reversed: Vec<SocketAddr> = 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<SocketAddr> = 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 — see
// docs/online-agent-cache.md.
#[test]
fn the_agent_is_reused_per_address_set_only() {
let src = OnlineSource::new("https://keyserver.test/keys", "");
let a: Vec<SocketAddr> = vec!["8.8.8.8:443".parse().unwrap()];
let b: Vec<SocketAddr> = 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());
}
}