Report a key source that could not answer as its own failure, not as "no key"

The online key service returned HTTP 502 for about seven hours. Every rip in
that window ended with

    key: online > no entry > NO KEY
    Error: E7022 No key source has a decryption key for this disc (id: 422EB...)

which reads as "this disc is not in the key database". Operators went hunting
for a VUK that was never missing; the correct action was to wait.

E7022 is a claim about the WORLD: every source answered, and none holds a key
for this disc. A source that could not be reached made no such claim -- nothing
at all was learned. `resolve_and_apply_traced` collapsed the two anyway, with
its own comment naming the incident and pointing at the fix.

Three codes for the three different operator actions, each a variant with a
number and no English (the library ships none):

    E7028 KeyServiceUnavailable   unreachable / DNS / timeout / 5xx -> wait
    E7029 KeyServiceUnauthorized  401 or 403                        -> fix token
    E7030 KeyServiceRateLimited   429                               -> back off

None carries a payload: the key-service URL and its resolved address are
operator-confidential and must not ride out in a Display an operator pastes
into a bug report.

`resolve_and_apply_traced` now keeps `Err` and `Ok(empty)` apart. A source that
answered and holds nothing still records `KeyNode::NoEntry` -- that is the one
true "I looked, it is not there". A source that FAILED records an empty path,
and its reason is stamped onto `Disc::aacs_error`, the channel
`ensure_decryptable_keys` already reads for the E7017-vs-E7022 split. The gate
gained three arms alongside `AacsVidUnavailable` and raises the source's own
code.

`KeyOutcome` deliberately gains no variant. It is matched exhaustively by every
front-end's trace renderer (freemkv's `pipe::render_resolution_trace`,
autorip's `keysource::render_resolution_trace`), and this fix must not become a
breaking change across four repos to say something the error code already says
precisely. Dropping the false `NoEntry` node is enough for the trace line.

The three codes join `is_disc_level_no_key`: a service that is down, refusing
the token or throttling is down for every title, so a rip loop must stop rather
than issue N doomed requests -- and on 429, dig the hole deeper.

`FetchOutcome::errored`, documented as unreachable-in-production, now fires for
real: the negative-result cache stops memoising a transient outage.

Red before green: `key_source_failure_is_not_reported_as_a_missing_disc_key`
drives KeySource -> resolve -> aacs_error -> ensure_decryptable twice over the
same disc and asserts the verdicts differ. With the old conflation restored it
fails on the trace node, and with that assertion removed it fails
`left: 7022, right: 7028` at the gate.
This commit is contained in:
Matthew Jackson
2026-08-02 11:53:09 -07:00
parent e008e71a17
commit b2a274782a
3 changed files with 269 additions and 38 deletions
+63 -1
View File
@@ -101,6 +101,25 @@ pub const E_FMTS_KEY_MISSING: u16 = 7026;
/// failure reported as success. [`is_disc_level_no_key`] classifies this code, so
/// a multi-title rip loop fails fast on it.
pub const E_CSS_NO_DISC_KEY: u16 = 7027;
/// A key SOURCE could not be reached, or failed on its own side — transport
/// error, DNS failure, timeout, TLS failure, an HTTP 5xx, or a reply the client
/// could not read. The source never got as far as answering the question, so
/// nothing at all is known about whether a key for this disc exists.
///
/// Deliberately NOT [`E_NO_DISC_KEY`], which asserts the OPPOSITE — every source
/// answered and none holds a key. A seven-hour run of HTTP 502s reported as
/// `E_NO_DISC_KEY` told operators their disc was not in the key database and sent
/// them hunting for a VUK that was never missing; the correct action was to wait.
/// Transient: retry later.
pub const E_KEY_SERVICE_UNAVAILABLE: u16 = 7028;
/// A key source rejected the configured credentials (HTTP 401/403 from the online
/// key service). NOT transient and NOT an absent key — the operator action is to
/// fix the token, not to wait and not to look for a VUK.
pub const E_KEY_SERVICE_UNAUTHORIZED: u16 = 7029;
/// A key source rate-limited the request (HTTP 429 from the online key service).
/// The operator action is to back off and retry more slowly; the disc's key may
/// well exist.
pub const E_KEY_SERVICE_RATE_LIMITED: u16 = 7030;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
@@ -462,6 +481,21 @@ pub enum Error {
/// gate once did) makes an undecryptable disc log one "title skipped" notice
/// per title and exit successfully.
CssNoDiscKey,
/// A key source could not be reached, or failed on its own side — transport
/// error, DNS failure, timeout, TLS failure, HTTP 5xx, or an unreadable /
/// unparseable reply. See [`E_KEY_SERVICE_UNAVAILABLE`]: the source never
/// answered the question, so this is emphatically NOT [`Error::NoDiscKey`]
/// (which asserts every source DID answer and none holds a key). Transient.
///
/// Carries no detail by design: the key-service URL and the resolved address
/// are operator-confidential and must not reach a log or a bug report.
KeyServiceUnavailable,
/// A key source rejected the configured credentials (HTTP 401/403). See
/// [`E_KEY_SERVICE_UNAUTHORIZED`]. Not transient: fix the token.
KeyServiceUnauthorized,
/// A key source rate-limited the request (HTTP 429). See
/// [`E_KEY_SERVICE_RATE_LIMITED`]. Back off and retry more slowly.
KeyServiceRateLimited,
/// The live-drive AACS cert-auth handshake (the OEM/AACS baseline route)
/// could not run because NO host certificate was available from any key
/// source. Host certs are keysource-served, never compiled in, so without
@@ -742,6 +776,9 @@ impl Error {
Error::NoDiscKey { .. } => E_NO_DISC_KEY,
Error::CssKeyMissing => E_CSS_KEY_MISSING,
Error::CssNoDiscKey => E_CSS_NO_DISC_KEY,
Error::KeyServiceUnavailable => E_KEY_SERVICE_UNAVAILABLE,
Error::KeyServiceUnauthorized => E_KEY_SERVICE_UNAUTHORIZED,
Error::KeyServiceRateLimited => E_KEY_SERVICE_RATE_LIMITED,
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
Error::FmtsKeyMissing => E_FMTS_KEY_MISSING,
@@ -1139,10 +1176,26 @@ pub fn is_halt(e: &std::io::Error) -> bool {
/// [`E_CSS_KEY_MISSING`]: an undecryptable CSS disc landed in
/// [`is_skippable_title_stub`], so the rip loop skipped all N titles with an
/// "empty stub" notice and exited successfully.
/// The key-SOURCE failures ([`E_KEY_SERVICE_UNAVAILABLE`],
/// [`E_KEY_SERVICE_UNAUTHORIZED`], [`E_KEY_SERVICE_RATE_LIMITED`]) are here for
/// the same fail-fast reason and NOT because they mean "no key": a service that
/// is down, refusing the token, or throttling is down for every title on the
/// disc, so iterating N titles re-issues N doomed requests (and, on 429, digs the
/// rate-limit hole deeper). They are separate CODES precisely so the front-end
/// can say "retry later" / "fix the token" instead of `E_NO_DISC_KEY`'s "no key
/// source has a key for this disc".
pub fn is_disc_level_no_key(e: &std::io::Error) -> bool {
matches!(
error_code(e),
Some(E_NO_DISC_KEY | E_KEYDB_LOAD | E_AACS_NO_KEYS | E_CSS_NO_DISC_KEY)
Some(
E_NO_DISC_KEY
| E_KEYDB_LOAD
| E_AACS_NO_KEYS
| E_CSS_NO_DISC_KEY
| E_KEY_SERVICE_UNAVAILABLE
| E_KEY_SERVICE_UNAUTHORIZED
| E_KEY_SERVICE_RATE_LIMITED
)
)
}
@@ -1420,6 +1473,12 @@ mod tests {
// Both CSS no-key verdicts: numeric-only Display, no English.
(Error::CssKeyMissing, E_CSS_KEY_MISSING),
(Error::CssNoDiscKey, E_CSS_NO_DISC_KEY),
// Key-SOURCE failures: bare numeric Display. They must carry NO
// detail — the service URL and its resolved address are
// operator-confidential and must never reach a pasted bug report.
(Error::KeyServiceUnavailable, E_KEY_SERVICE_UNAVAILABLE),
(Error::KeyServiceUnauthorized, E_KEY_SERVICE_UNAUTHORIZED),
(Error::KeyServiceRateLimited, E_KEY_SERVICE_RATE_LIMITED),
];
for (e, want_code) in cases {
let s = e.to_string();
@@ -1621,6 +1680,9 @@ mod tests {
E_NO_DISC_KEY,
E_CSS_KEY_MISSING,
E_CSS_NO_DISC_KEY,
E_KEY_SERVICE_UNAVAILABLE,
E_KEY_SERVICE_UNAUTHORIZED,
E_KEY_SERVICE_RATE_LIMITED,
E_AACS_NO_HOST_CERT,
E_AACS_BUS_KEY_UNAVAILABLE,
E_FMTS_KEY_MISSING,