Return a failure from the online source instead of an empty result

`query` returned `Vec::new()` for every outcome: a 502, a refused token, a DNS
failure, an unreadable body, and a service that answered 200 holding no key for
this disc. Only the last one means what the caller then reported -- E7022, "no
key source has a decryption key for this disc". The module already knew the
difference and logged it; the distinction died at the `return`, and the tracing
line that carried it is not on by default (RUST_LOG=info did not surface it
through the CLI in practice).

`query` now returns `Result<Vec<UnitKey>, Error>`:

  Ok(non-empty)  the service answered with a key
  Ok(empty)      the service ANSWERED and holds nothing, or there was nothing
                 to ask with (no service configured, blocked URL, over-cap MKB,
                 too few samples) -- a genuine miss, E7022 is correct
  Err(..)        the service could not answer -- nothing is known

`classify_http_status` is the table the fix exists for: 401/403 ->
KeyServiceUnauthorized, 429 -> KeyServiceRateLimited, 5xx -> unavailable, and
anything else non-2xx -> unavailable too. Deliberately: the genuine miss is a
**200 with no key in the body**, so a status code is never evidence about this
disc, and guessing "no key" from one is the original bug. An unreadable,
over-cap or unparseable body is the service failing mid-answer, so those are
failures as well.

`resolve_and_guard` now reports WHY it rejected a URL. A host that did not
resolve (DNS failure or DNS timeout) is the service being unreachable, and was
being folded in with "this URL is not allowed" -- one is transient, the other
never fixes itself. `GuardFail::Config` still falls through to the next source
as before.

The reply-to-verdict mapping moved into `interpret_reply` so it is testable
without a network. A stub HTTP server is not an option here: the SSRF guard
blocks loopback by design, so 127.0.0.1 can never be reached through `query`.

Nothing new is logged. The request body carries base64 key material and the
reply carries keys; the added fields are the error code and the status already
being recorded.

Red before green: http_5xx_is_a_source_failure_not_a_missing_key,
transport_failure_is_a_source_failure and unparseable_reply_is_a_source_failure
all fail against the old collapse-to-empty behaviour and pass after.
service_with_a_key_still_resolves_it_in_order is a regression pin -- it passes
either way, and is here because the happy path went through a signature change.
This commit is contained in:
Matthew Jackson
2026-08-02 11:53:39 -07:00
parent ca60c8fe1c
commit c4259249e1
+464 -147
View File
@@ -87,34 +87,59 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
}
}
/// Why [`resolve_and_guard`] rejected a URL. The two halves demand OPPOSITE
/// operator actions, so they must not be collapsed: a `Config` rejection is a
/// standing misconfiguration that will never fix itself, while `Unreachable` is
/// the key service being down right now — exactly the condition this module's
/// callers must not report as "this disc has no key".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GuardFail {
/// Malformed URL, bad scheme, or a host that is (or resolves to) a
/// non-public address. Operator configuration; retrying changes nothing.
Config,
/// The host did not resolve — DNS failure or DNS timeout. The service is
/// unreachable; nothing is known about this disc's key. Transient.
Unreachable,
}
/// Resolve `url`'s host and validate every resulting address against the SSRF
/// guard. Returns the pinned socket addresses (for use with a custom ureq
/// resolver) on success, or an error message on rejection.
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
/// resolver) on success, or the rejection reason plus an explanatory message.
///
/// SECURITY: the message names the resolved address on an SSRF rejection and is
/// for the CONFIG-time caller only — never log it (see the call site in
/// [`OnlineSource::query`]).
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)> {
let rest = if let Some(r) = url.strip_prefix("https://") {
(r, 443u16)
} else if let Some(r) = url.strip_prefix("http://") {
(r, 80u16)
} else {
return Err("URL must start with http:// or https://".into());
return Err((
GuardFail::Config,
"URL must start with http:// or https://".into(),
));
};
let (authority, default_port) = rest;
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
let authority = authority.rsplit('@').next().unwrap_or(authority);
if authority.is_empty() {
return Err("URL has no host".into());
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)) => {
let p = after
.strip_prefix(':')
.map(|s| s.parse::<u16>().map_err(|_| "invalid port".to_string()))
.map(|s| {
s.parse::<u16>()
.map_err(|_| (GuardFail::Config, "invalid port".to_string()))
})
.transpose()?
.unwrap_or(default_port);
(h.to_string(), p)
}
None => return Err("malformed IPv6 host".into()),
None => return Err((GuardFail::Config, "malformed IPv6 host".into())),
}
} else if let Some((h, p)) = authority.rsplit_once(':') {
match p.parse::<u16>() {
@@ -125,7 +150,7 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
(authority.to_string(), default_port)
};
if host.is_empty() {
return Err("URL has no host".into());
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 (tens of seconds) and freeze the rip thread that called
@@ -146,18 +171,29 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
});
match rx.recv_timeout(DNS_TIMEOUT) {
Ok(Ok(addrs)) => addrs,
Ok(Err(e)) => return Err(format!("could not resolve host: {e}")),
Err(_) => return Err("DNS resolution timed out".into()),
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("host did not resolve to any address".into());
return Err((
GuardFail::Unreachable,
"host did not resolve to any address".into(),
));
}
for a in &addrs {
if is_blocked_ip(&a.ip()) {
return Err(format!(
"refusing to connect to non-public address {} (SSRF guard)",
a.ip()
return Err((
GuardFail::Config,
format!(
"refusing to connect to non-public address {} (SSRF guard)",
a.ip()
),
));
}
}
@@ -178,7 +214,7 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
/// SAME bounded-resolve, so their verdicts never diverge — the reason this lives
/// here, in the key-source crate, rather than being re-rolled per application.
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
resolve_and_guard(url).map(|_| ())
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
}
/// Build a ureq agent that follows zero redirects (so a public URL can't
@@ -206,18 +242,32 @@ impl OnlineSource {
}
}
/// The server-resolved Unit Keys for this disc, or an empty `Vec`. Runs
/// exactly one network round-trip. The service returns either a terminal
/// `UK` (used directly) or a `VUK` (derived to Unit Keys locally via the
/// disc's encrypted title keys from `ctx`). Any failure — no service,
/// over-cap MKB, network/parse error, or no key for this disc — yields an
/// empty `Vec` (the resolver tries the next source). `&self`: one-shot is
/// the resolver's contract (each source's `get_unit_keys` is called once), so no
/// per-call latch is needed.
fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
/// The server-resolved Unit Keys for this disc. Runs exactly one network
/// round-trip. The service returns either a terminal `UK` (used directly) or
/// a `VUK` (derived to Unit Keys locally via the disc's encrypted title keys
/// from `ctx`).
///
/// The return type draws THE distinction this source exists to draw:
///
/// * `Ok(non-empty)` — the service answered with a key.
/// * `Ok(empty)` — the service ANSWERED and holds no key for this disc, or
/// this source had nothing to ask with (no service configured, a
/// misconfigured URL, an over-cap MKB, too few samples). A genuine miss;
/// the resolver moves to the next source and `E7022` is the right verdict.
/// * `Err(..)` — the service could not answer: unreachable, timed out, DNS
/// failed, returned 5xx, rejected the token (401/403), rate-limited (429),
/// or replied with something unreadable. NOTHING is known about whether a
/// key exists. Collapsing this into `Ok(empty)` is the bug this signature
/// fixes: a seven-hour run of HTTP 502s was reported to operators as
/// `E7022 No key source has a decryption key for this disc`, and they went
/// hunting for a VUK when the correct action was to wait.
///
/// `&self`: one-shot is the resolver's contract (each source's
/// `get_unit_keys` is called once), so no per-call latch is needed.
fn query(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
// No configured service: nothing to resolve.
if self.base_url.is_empty() {
return Vec::new();
return Ok(Vec::new());
}
// Refuse to transmit over plaintext. The request body always carries
// base64 AACS key material (inf/mkb/units/vid) and, when configured, a
@@ -239,7 +289,7 @@ impl OnlineSource {
"key-service URL is not https:// — refusing to send key material \
and credentials in cleartext; skipping the online source"
);
return Vec::new();
return Ok(Vec::new());
}
let mkb = ctx.mkb().unwrap_or(&[]);
// An over-cap MKB cannot be forwarded — bound the body. Log it: a silent
@@ -252,7 +302,7 @@ impl OnlineSource {
cap = MAX_MKB_BYTES,
"MKB exceeds the key-service forward cap; skipping the online source for this disc (no key from online)"
);
return Vec::new();
return Ok(Vec::new());
}
// Gather encrypted-content samples and prove the minimum by TYPE: a
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
@@ -269,7 +319,7 @@ impl OnlineSource {
min = MIN_SAMPLE_UNITS,
"too few content samples for a reliable online key request; skipping the online source"
);
return Vec::new();
return Ok(Vec::new());
};
let b64 = base64::engine::general_purpose::STANDARD;
let mut body = serde_json::json!({
@@ -303,7 +353,20 @@ impl OnlineSource {
// request (and the bearer token) to an internal/metadata host.
let pinned = match resolve_and_guard(&self.base_url) {
Ok(addrs) => addrs,
Err(_) => {
// A host that did not RESOLVE is the service being unreachable, not a
// bad URL — DNS failure and DNS timeout are exactly what a key-service
// outage looks like from here, and reporting them as a genuine miss is
// the same conflation as reporting a 502 that way.
Err((GuardFail::Unreachable, _)) => {
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 guard's message
// names the resolved address, and an internal address must not
// reach a log an operator may paste into a bug report. The
@@ -315,7 +378,7 @@ impl OnlineSource {
phase = "keyserver_post",
"key-service URL failed the address guard; skipping the online source"
);
return Vec::new();
return Ok(Vec::new());
}
};
let agent = hardened_agent(pinned);
@@ -331,151 +394,215 @@ impl OnlineSource {
// material.
tracing::info!(target: "freemkv::keysource", phase = "keyserver_post", "begin");
let post_t0 = std::time::Instant::now();
let resp = match req.send_json(body) {
Ok(r) => r,
Err(e) => {
// Record the HTTP status when the service actually answered:
// 401/403 (bad or expired token), 429 (rate limited) and
// 5xx (service down) are completely different operator actions,
// and collapsing them into one line is why a 502 was read as
// "this disc has no key". The status is the service's own
// response code — it carries no key material and no address.
// A transport error has no status; report it as such rather
// than inventing one.
match e {
ureq::Error::Status(code, _) => tracing::warn!(
let sent = req.send_json(body);
interpret_reply(sent, ctx, post_t0.elapsed().as_millis() as u64)
}
}
/// Map a key-service HTTP status the client never asked for into the operator
/// action it implies. THE table this fix exists for — each arm is a different
/// thing for a person to do, and none of them is "your disc has no key":
///
/// * 401 / 403 → the token is wrong or expired. Fix the credentials.
/// * 429 → rate limited. Back off and retry more slowly.
/// * 5xx → the service is down. Wait; this is the seven-hour-502 case.
/// * anything else non-2xx → the service is misbehaving in a way this client
/// cannot interpret. Treated as unavailable, deliberately: the genuine miss is
/// a **200 with no key in the body**, so a non-2xx status is never evidence
/// about this disc, and guessing "no key" from one is the original bug.
fn classify_http_status(code: u16) -> Error {
match code {
401 | 403 => Error::KeyServiceUnauthorized,
429 => Error::KeyServiceRateLimited,
_ => Error::KeyServiceUnavailable,
}
}
/// Turn the raw outcome of the key-service POST into this source's answer.
///
/// Split out of [`OnlineSource::query`] so the reply → verdict mapping is
/// testable WITHOUT a network: the SSRF guard in `resolve_and_guard` blocks
/// loopback by design, so a stub HTTP server on 127.0.0.1 can never be reached
/// through `query`, and this seam is the only honest way to drive "the service
/// returned 502" and "the service returned 200 with no entry" through the same
/// code the rip uses.
///
/// `elapsed_ms` is the round-trip time, for the timing logs only.
///
/// SECURITY: logs status codes, byte counts and static labels only. The request
/// body carries base64 AACS key material and the reply carries keys — neither is
/// ever logged, and nor is a serde parse error (it quotes the offending input).
fn interpret_reply(
sent: Result<ureq::Response, ureq::Error>,
ctx: &dyn ResolveCtx,
elapsed_ms: u64,
) -> Result<Vec<UnitKey>, Error> {
let resp = match sent {
Ok(r) => r,
Err(e) => {
// Record the HTTP status when the service actually answered:
// 401/403 (bad or expired token), 429 (rate limited) and
// 5xx (service down) are completely different operator actions,
// and collapsing them into one line is why a 502 was read as
// "this disc has no key". The status is the service's own
// response code — it carries no key material and no address.
// A transport error has no status; report it as such rather
// than inventing one.
//
// Each arm now RETURNS the classified error instead of an empty
// vec, so the distinction survives past this function.
return Err(match e {
ureq::Error::Status(code, _) => {
let err = classify_http_status(code);
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
http_status = code,
elapsed_ms = post_t0.elapsed().as_millis() as u64,
"key service returned an HTTP error; no key from online"
),
ureq::Error::Transport(_) => tracing::warn!(
error_code = err.code(),
elapsed_ms,
"key service returned an HTTP error; no ANSWER from online (not a missing key)"
);
err
}
ureq::Error::Transport(_) => {
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
elapsed_ms = post_t0.elapsed().as_millis() as u64,
"key service unreachable (connect/timeout/TLS); no key from online"
),
elapsed_ms,
"key service unreachable (connect/timeout/TLS); no ANSWER from online (not a missing key)"
);
Error::KeyServiceUnavailable
}
return Vec::new();
}
};
tracing::info!(
});
}
};
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
.into_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",
elapsed_ms = post_t0.elapsed().as_millis() as u64,
"end"
cap = MAX_RESPONSE_BYTES,
"key-service reply was unreadable or over the size cap; no ANSWER from online"
);
// 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
.into_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.
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 the payload: a serde message
// quotes the offending input, which here is key material.
// Unparseable is not "no key" — it is the service answering in a
// language this client does not speak. Transient / a service bug.
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
cap = MAX_RESPONSE_BYTES,
"key-service reply was unreadable or over the size cap; no key from online"
"key-service reply was not valid JSON; no ANSWER from online"
);
return Vec::new();
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 the payload: a serde message
// quotes the offending input, which here is key material.
};
// `UK` is an ARRAY of hex keys (the service always returns an array now,
// even of one). A single element is the base Unit Key. A full set (one per
// forensic index, ordered index 1..N) is returned for a forensic sample.
// Preserve array order and tag each key with its array position, so the
// caller can map position → index (element 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 and never assumes 32 (see
// `get_fmts_indexes`). Skipping an unparseable element would
// hand back a short set that silently omits an index, so a
// malformed element rejects the whole reply — that is a service
// bug, not a usable answer, and therefore a source FAILURE.
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",
"key-service reply was not valid JSON; no key from online"
keys = arr.len(),
"key-service returned a malformed key in the UK set; rejecting the whole reply"
);
return Vec::new();
}
};
// `UK` is an ARRAY of hex keys (the service always returns an array now,
// even of one). A single element is the base Unit Key. A full set (one per
// forensic index, ordered index 1..N) is returned for a forensic sample.
// Preserve array order and tag each key with its array position, so the
// caller can map position → index (element 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 and never assumes 32 (see
// `get_fmts_indexes`). Skipping an unparseable element would
// hand back a short set that silently omits an index, so a
// malformed element rejects the whole reply — that is a service
// bug, not a usable answer.
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 Vec::new();
}
}
if !out.is_empty() {
return out;
return Err(Error::KeyServiceUnavailable);
}
}
// 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 uks_from_vuk(&vuk, enc),
Err(_) => {
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 Vec::new();
}
}
if !out.is_empty() {
return Ok(out);
}
// The genuine miss. Logged distinctly from every failure above so an
// operator can tell "the service has no key for this disc" from "the
// service did not answer" — collapsing the two is what made a 502 look
// like a missing key.
tracing::info!(
target: "freemkv::keysource",
phase = "keyserver_post",
"key service has no key for this disc"
);
Vec::new()
}
// 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 correctly; the DISC's encrypted title keys
// are what could not be read. Not a service failure, so not an
// `Err` from this source — the disc-side reason is the library's
// to report.
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 — and the ONLY path that returns `Ok(empty)` from a
// completed round-trip. Logged distinctly from every failure above so an
// operator can tell "the service has no key for this disc" from "the
// service did not answer"; collapsing the two is what made a 502 look like
// a missing key. This is the one case where `E7022` is the truth.
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: submit the ctx's content samples and take the
/// service's reply (a terminal `UK`, or a `VUK` derived locally). One network
/// round-trip; any failure yields empty (the resolver tries the next source).
/// round-trip. An empty `Ok` means the service ANSWERED and holds no key; an
/// `Err` means it could not answer (see [`OnlineSource::query`]). Both let the
/// resolver try the next source — the difference is what the operator is told
/// when no source has one.
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(self.query(ctx))
self.query(ctx)
}
/// AACS 2.1 forensic index set: the mux injects an index-1 single-phase anchor
@@ -486,7 +613,7 @@ impl KeySource for OnlineSource {
/// reads the reply. The count is whatever the service returns; the mux trusts
/// any non-empty result as the complete set and never assumes 32.
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(self.query(ctx))
self.query(ctx)
}
fn label(&self) -> &'static str {
@@ -653,6 +780,196 @@ mod tests {
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
}
// ── the reply → verdict mapping (THE defect) ───────────────────────────
//
// Driven through `interpret_reply`, the seam `query` hands its round-trip
// to. A stub HTTP server is NOT usable here: `resolve_and_guard` blocks
// loopback by design (SSRF guard), so 127.0.0.1 can never be reached through
// `query`, and pointing the test at a public host would make it a network
// test. Everything from the response onward is this function.
/// 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())
}
}
fn reply(status: u16, body: &str) -> ureq::Response {
ureq::Response::new(status, "stub", body).expect("stub response")
}
/// THE regression. The key service returned HTTP 502 for ~seven hours and
/// the CLI told operators the disc had no key, so they went hunting for a
/// VUK that was never missing. A 5xx must be a source FAILURE, and it must
/// be distinguishable from the service answering 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::Status(status, reply(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::Status(502, reply(502, ""))), &BareCtx, 7);
assert!(
down.is_err(),
"502 and 200-with-no-entry must not collapse to the same result"
);
}
/// Each status the service can return maps to a DIFFERENT operator action:
/// fix the token (401/403), back off (429), wait (5xx). A non-2xx status is
/// never evidence about this disc, so anything else is "unavailable" too —
/// guessing "no key" from a status code is the original bug.
#[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 — nothing answered at all — is the same verdict as a
/// 5xx and equally not a missing key. Uses a refused connection to
/// 127.0.0.1:1 to obtain a REAL `ureq::Error::Transport` (which has no public
/// constructor); this touches no network and never reaches `query`, so the
/// SSRF guard is not involved.
#[test]
fn transport_failure_is_a_source_failure() {
let sent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(2))
.build()
.post("http://127.0.0.1:1/")
.send_string("{}");
assert!(
matches!(sent, Err(ureq::Error::Transport(_))),
"a refused connection must surface as a ureq transport error"
);
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,
);
}
/// 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);
assert_eq!(keys[0].key[0], 0x00);
assert_eq!(keys[1].key[0], 0x0f);
}
/// 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"
);
}
}
/// 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.).