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:
+156
-4
@@ -2720,10 +2720,28 @@ impl Disc {
|
||||
// material (device / processing keys) but no Volume ID to derive
|
||||
// the unit key, the captured `aacs_error` is `AacsVidUnavailable`
|
||||
// — report THAT (the fix is recovering the VID, not adding keys),
|
||||
// not the generic `NoDiscKey`. Any other (or absent) reason →
|
||||
// `NoDiscKey` naming the disc by hash, unchanged.
|
||||
if matches!(self.aacs_error, Some(Error::AacsVidUnavailable)) {
|
||||
return Err(Error::AacsVidUnavailable);
|
||||
// not the generic `NoDiscKey`. Any reason NOT listed below (or an
|
||||
// absent one) → `NoDiscKey` naming the disc by hash, unchanged.
|
||||
//
|
||||
// Same split, second axis: when a key SOURCE could not answer
|
||||
// (unreachable / 5xx / bad token / rate-limited) resolution
|
||||
// stamps that reason here, and it must surface as ITSELF.
|
||||
// `NoDiscKey` asserts every source answered and none holds a key
|
||||
// — the opposite of what happened — and reporting a seven-hour
|
||||
// 502 outage that way sent operators looking for a VUK when the
|
||||
// right action was to wait.
|
||||
match self.aacs_error {
|
||||
Some(Error::AacsVidUnavailable) => return Err(Error::AacsVidUnavailable),
|
||||
Some(Error::KeyServiceUnavailable) => {
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
Some(Error::KeyServiceUnauthorized) => {
|
||||
return Err(Error::KeyServiceUnauthorized);
|
||||
}
|
||||
Some(Error::KeyServiceRateLimited) => {
|
||||
return Err(Error::KeyServiceRateLimited);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Err(Error::NoDiscKey {
|
||||
disc_hash: self.aacs_disc_hash(),
|
||||
@@ -4699,6 +4717,140 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// THE defect: a key SOURCE that could not answer must not be reported as
|
||||
/// "this disc has no key".
|
||||
///
|
||||
/// Drives the real chain end to end — `KeySource` → `resolve_and_apply_traced`
|
||||
/// → `Disc::aacs_error` → `ensure_decryptable` — twice over the SAME disc, and
|
||||
/// asserts the two operator-visible verdicts differ:
|
||||
///
|
||||
/// * source returns `Err(KeyServiceUnavailable)` (the HTTP-502 outage) → E7028
|
||||
/// "the key service could not answer; retry later",
|
||||
/// * source returns `Ok(vec![])` (the service answered, no entry) → E7022
|
||||
/// "no key source has a decryption key for this disc".
|
||||
///
|
||||
/// Before the fix both arms produced E7022, which is what sent an operator
|
||||
/// hunting for a VUK through seven hours of 502s. The credential and
|
||||
/// rate-limit verdicts are asserted alongside, since each is a different
|
||||
/// operator action (fix the token / back off).
|
||||
#[test]
|
||||
fn key_source_failure_is_not_reported_as_a_missing_disc_key() {
|
||||
use crate::keysource::{KeySource, ResolveCtx, resolve_and_apply_traced};
|
||||
|
||||
/// A source that fails the way a down / hostile key service fails.
|
||||
struct FailingSource(fn() -> crate::error::Error);
|
||||
impl KeySource for FailingSource {
|
||||
fn get_unit_keys(
|
||||
&self,
|
||||
_ctx: &dyn ResolveCtx,
|
||||
) -> std::result::Result<Vec<crate::aacs::types::UnitKey>, crate::error::Error>
|
||||
{
|
||||
Err((self.0)())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
"online"
|
||||
}
|
||||
}
|
||||
/// A source that ANSWERS and holds nothing — the genuine miss.
|
||||
struct AnsweredNoEntry;
|
||||
impl KeySource for AnsweredNoEntry {
|
||||
fn get_unit_keys(
|
||||
&self,
|
||||
_ctx: &dyn ResolveCtx,
|
||||
) -> std::result::Result<Vec<crate::aacs::types::UnitKey>, crate::error::Error>
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
"online"
|
||||
}
|
||||
}
|
||||
|
||||
let inputs = crate::keysource::DiscInputs {
|
||||
disc_hash: "0x422EB".into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
volume_label: None,
|
||||
};
|
||||
let encrypted_aacs_disc = || {
|
||||
let mut d = make_test_disc(1000, "UHD");
|
||||
d.encrypted = true;
|
||||
d.aacs = Some(aacs_with(Vec::new())); // AACS state, no unit keys
|
||||
d
|
||||
};
|
||||
|
||||
// The genuine miss — the service answered, nothing for this disc.
|
||||
let mut answered = encrypted_aacs_disc();
|
||||
let sources: Vec<Box<dyn KeySource>> = vec![Box::new(AnsweredNoEntry)];
|
||||
let (ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut answered);
|
||||
assert!(!ok);
|
||||
assert_eq!(
|
||||
trace.keys[0].path,
|
||||
vec![crate::aacs::trace::KeyNode::NoEntry],
|
||||
"a source that ANSWERED and holds nothing is the one true `NoEntry`"
|
||||
);
|
||||
assert!(
|
||||
answered.aacs_error.is_none(),
|
||||
"a genuine miss stamps no failure reason on the disc"
|
||||
);
|
||||
let miss_code = answered
|
||||
.ensure_decryptable(false)
|
||||
.expect_err("no key, !raw must error")
|
||||
.code();
|
||||
assert_eq!(
|
||||
miss_code,
|
||||
crate::error::Error::NoDiscKey {
|
||||
disc_hash: String::new()
|
||||
}
|
||||
.code(),
|
||||
"a genuine miss keeps E7022 — that wording is correct for it"
|
||||
);
|
||||
|
||||
// Each way the service can FAIL to answer, and the code it must produce.
|
||||
type MakeError = fn() -> crate::error::Error;
|
||||
let failures: &[(MakeError, u16)] = &[
|
||||
(
|
||||
|| crate::error::Error::KeyServiceUnavailable,
|
||||
crate::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
),
|
||||
(
|
||||
|| crate::error::Error::KeyServiceUnauthorized,
|
||||
crate::error::E_KEY_SERVICE_UNAUTHORIZED,
|
||||
),
|
||||
(
|
||||
|| crate::error::Error::KeyServiceRateLimited,
|
||||
crate::error::E_KEY_SERVICE_RATE_LIMITED,
|
||||
),
|
||||
];
|
||||
for (make, want) in failures {
|
||||
let mut down = encrypted_aacs_disc();
|
||||
let sources: Vec<Box<dyn KeySource>> = vec![Box::new(FailingSource(*make))];
|
||||
let (ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut down);
|
||||
assert!(!ok);
|
||||
assert!(
|
||||
trace.keys[0].path.is_empty(),
|
||||
"a source that could not ANSWER must not claim `no entry` in the trace"
|
||||
);
|
||||
assert_eq!(
|
||||
down.aacs_error.as_ref().map(crate::error::Error::code),
|
||||
Some(*want),
|
||||
"the source's failure reason must reach the disc"
|
||||
);
|
||||
let code = down
|
||||
.ensure_decryptable(false)
|
||||
.expect_err("no key, !raw must error")
|
||||
.code();
|
||||
assert_eq!(code, *want, "the gate must surface the SOURCE's code");
|
||||
assert_ne!(
|
||||
code, miss_code,
|
||||
"a source that could not answer must NOT render as \"this disc has no key\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Same AACS-no-key disc under `--raw` (raw=true) must PROCEED — the user
|
||||
/// asked for the encrypted image and needs no key.
|
||||
#[test]
|
||||
|
||||
+63
-1
@@ -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,
|
||||
|
||||
+50
-33
@@ -381,6 +381,14 @@ pub fn resolve_and_apply_traced(
|
||||
|
||||
let mut trace = crate::aacs::trace::ResolutionTrace::new();
|
||||
|
||||
// The FIRST source failure seen, if any. A source that returns `Err` did not
|
||||
// answer "no key for this disc" — it could not answer at all — and that
|
||||
// reason is stamped onto `disc.aacs_error` below so the decrypt gate reports
|
||||
// THAT instead of the generic `NoDiscKey`. First-wins (not last) so the
|
||||
// ordered sources' most-preferred failure is the one the operator is told
|
||||
// about, matching the first-valid-wins rule for successes.
|
||||
let mut source_failure: Option<crate::error::Error> = None;
|
||||
|
||||
// The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the
|
||||
// disc's own AACS major), so the stride is the disc's single source of truth.
|
||||
let ctx = DiscInputsCtx::new(inputs);
|
||||
@@ -413,46 +421,55 @@ pub fn resolve_and_apply_traced(
|
||||
outcome: KeyOutcome::NoKey,
|
||||
});
|
||||
}
|
||||
// Empty (no key here) or a source failure — both are "no key from
|
||||
// this source"; move on to the next.
|
||||
//
|
||||
// NOTE: the `Err` half is currently UNREACHABLE in production, and the
|
||||
// conflation below is therefore latent rather than live. Every shipped
|
||||
// `KeySource` swallows its own failures into `Ok(Vec::new())`:
|
||||
// `KeydbSource::get_unit_keys` maps a load/parse error to an empty vec,
|
||||
// `OnlineSource::get_unit_keys` is `Ok(self.query(ctx))` where `query`
|
||||
// returns empty on transport error, HTTP status, oversize body and bad
|
||||
// JSON alike, and `MultiSource` discards inner `Err`s. Only test doubles
|
||||
// return `Err`.
|
||||
//
|
||||
// Consequence: an unreachable key server arrives here as "no entry",
|
||||
// and an operator is told their disc is not in the database. autorip
|
||||
// works around it by re-probing the service over HTTP
|
||||
// (`probe_online_reachability` / `key_service_transient_status`), whose
|
||||
// own comment names the incident — "the online keysource swallows every
|
||||
// failure (transport error, 502, timeout)".
|
||||
//
|
||||
// Fixing it HERE would change nothing: the fix belongs at the source
|
||||
// boundary in `freemkv-keysources`, so a failure is reported as a
|
||||
// failure, with `Disc::aacs_error` as the channel the operator actually
|
||||
// reads. `FetchOutcome::errored` in `drive_unit_keys` /
|
||||
// `drive_fmts_indexes` never FIRES for the same reason — it is the
|
||||
// right contract, honoured by no shipped source yet. It is NOT dead
|
||||
// code: it is written at both `drive_*` sites and read by the
|
||||
// cache-insert guard `if !keys.is_empty() || !outcome.errored`, the
|
||||
// only thing that stops a transient source outage from being memoised
|
||||
// permanently into the per-fingerprint key cache — pinned by
|
||||
// `errored_empty_is_not_cached_and_retries_when_source_recovers`. Do
|
||||
// not delete it while making a source report failures as `Err`; that
|
||||
// is precisely when it starts to matter.
|
||||
Ok(_) | Err(_) => {
|
||||
// The source ANSWERED and holds nothing for this disc. This — and
|
||||
// only this — is `NoEntry`: the claim "I looked, it is not there".
|
||||
Ok(_) => {
|
||||
trace.keys.push(KeyStep {
|
||||
who,
|
||||
path: vec![KeyNode::NoEntry],
|
||||
outcome: KeyOutcome::NoKey,
|
||||
});
|
||||
}
|
||||
// The source could NOT answer — it was unreachable, it errored, or it
|
||||
// refused. Nothing is known about whether a key exists, so the path
|
||||
// is EMPTY: recording `NoEntry` here is exactly the conflation that
|
||||
// made a seven-hour run of HTTP 502s render as
|
||||
// `key: online > no entry > NO KEY` + `E7022 No key source has a
|
||||
// decryption key for this disc`, and sent operators hunting for a VUK
|
||||
// that was never missing.
|
||||
//
|
||||
// The reason itself rides out on `disc.aacs_error` (below), the
|
||||
// channel `Disc::ensure_decryptable_keys` already reads for the
|
||||
// E7017-vs-E7022 split — so the decrypt gate raises the SOURCE's code
|
||||
// (`KeyServiceUnavailable` / `KeyServiceUnauthorized` /
|
||||
// `KeyServiceRateLimited`) instead of the generic `NoDiscKey`.
|
||||
//
|
||||
// `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 turn
|
||||
// into a breaking change across four repos to say something the error
|
||||
// code already says precisely.
|
||||
Err(e) => {
|
||||
if source_failure.is_none() {
|
||||
source_failure = Some(e);
|
||||
}
|
||||
trace.keys.push(KeyStep {
|
||||
who,
|
||||
path: Vec::new(),
|
||||
outcome: KeyOutcome::NoKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nothing resolved. If a source FAILED rather than answered, stamp that
|
||||
// reason onto the disc so the decrypt gate can report it — but never clobber
|
||||
// a reason the scan already captured (e.g. `AacsVidUnavailable`), which is
|
||||
// closer to the disc itself than a source outage is.
|
||||
if let Some(e) = source_failure
|
||||
&& disc.aacs_error.is_none()
|
||||
{
|
||||
disc.aacs_error = Some(e);
|
||||
}
|
||||
(false, trace)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user