acdp_primitives/error.rs
1//! Error types for the ACDP library.
2//!
3//! Error variants align with the wire vocabulary defined by
4//! `acdp-error.schema.json` and RFC-ACDP-0007 §5. The
5//! [`AcdpError::from_wire_error`] helper converts a
6//! [`crate::wire_error::WireError`] (HTTP response body shape) into a typed
7//! variant.
8
9use crate::primitives::ContentHash;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Top-level error type.
14///
15/// `#[non_exhaustive]`: the wire vocabulary (RFC-ACDP-0007 §5) keeps growing
16/// as new RFCs land — 25 wire codes now, up from 21 not long ago, with
17/// RFC-ACDP-0009 reserved and still unimplemented. Without this attribute,
18/// every new variant is a semver-breaking change for any downstream crate
19/// that matches on `AcdpError` exhaustively. Same rationale as
20/// `SsrfReason` in `crates/acdp-safe-http/src/lib.rs` ("future spec
21/// revisions may add ranges"); match with a wildcard arm.
22/// `Clone` (added for issue #248 Phase 4): `VerifiedContext` and
23/// `VerificationReport` each need an independently-owned copy of a
24/// `ProceedWithKnown`-swallowed revocation-discovery failure — one via
25/// `VerifiedContext::revocation_discovery_failure()`, the other via
26/// `VerificationReport::revocation_discovery` — so both surfaces stay
27/// non-silent from a single call. Every variant field (`String`,
28/// `&'static str`, `ContentHash`, `WireError`, `SupersessionReason`,
29/// and the recursive `Box<AcdpError>` in `RevocationDiscoveryFailed`)
30/// is already `Clone`, so this is a free addition with no wire-format
31/// or matching impact.
32///
33/// This is a standing constraint on every future variant, not just the
34/// ones that exist today: every future variant's payloads must remain
35/// `Clone`. A variant that needs a structured source should hold
36/// `Arc<dyn std::error::Error + Send + Sync>` (which is `Clone`
37/// regardless of the inner type, and still preserves `source()`
38/// chaining), never `Box<dyn Error>` or a bare `io::Error` (neither is
39/// `Clone`). This is not a new tradeoff introduced by adding `Clone`
40/// here — `From<std::io::Error>` and `From<reqwest::Error>` already
41/// stringify into `Http(String)` rather than carry the error value, so
42/// value semantics over reference/source-chain semantics was already
43/// the twice-exercised choice for this type; `Clone` just makes it a
44/// documented rule instead of an implicit pattern.
45#[derive(Debug, Clone, Error)]
46#[non_exhaustive]
47pub enum AcdpError {
48 // ── Cryptography ─────────────────────────────────────────────────────────
49 /// JCS canonicalization failed (input not serializable).
50 #[error("JCS canonicalization failed: {0}")]
51 Canonicalization(String),
52
53 /// Stored `content_hash` did not match the recomputed value
54 /// (locally detected during signature verification).
55 #[error("content_hash mismatch\n stored: {stored}\n recomputed: {recomputed}")]
56 HashMismatch {
57 /// The hash claimed by the body or request.
58 stored: ContentHash,
59 /// The hash recomputed by the verifier.
60 recomputed: ContentHash,
61 },
62
63 /// Locally detected: the registry returned a body whose `ctx_id` is not
64 /// the one that was requested (context substitution). Not a wire code —
65 /// the client detects it. Permanent; fail closed.
66 ///
67 /// Implements RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
68 /// identity"): step 7 requires exactly this comparison — `body.ctx_id`
69 /// against the `ctx_id` used to construct the request — and permits a
70 /// consumer to surface "an equivalent typed error" in place of the
71 /// registry-side `cross_registry_resolution_failed` wire code; this
72 /// variant is that typed error. See RFC-ACDP-0008 §9.1 for the threat
73 /// this closes: without it, a registry can serve any other
74 /// validly-signed body by the same producer under the requested
75 /// context's URL, and both signature verification and `content_hash`
76 /// recomputation still pass. It does **not** close §9.1 in full: a
77 /// registry that genuinely republishes the same content under a new
78 /// `ctx_id` still passes; only serve-time substitution — a different
79 /// id claimed to be the one requested — is caught.
80 #[error("context substitution: requested {requested}, registry served {served}")]
81 ContextIdMismatch {
82 /// The `ctx_id` the caller requested.
83 requested: String,
84 /// The `ctx_id` actually present on the body the registry served.
85 served: String,
86 },
87
88 /// Locally detected: `GET /lineages/{id}` (RFC-ACDP-0013 §8.1) did not
89 /// serve a lineage the caller could accept — either it came back with
90 /// no members at all, or (when `ctx_id` is `Some`) its members did not
91 /// include the `ctx_id` a live search match named for that lineage.
92 /// Not a wire code — RFC-ACDP-0014 §10 ("No new wire error code"):
93 /// this is a locally-detected consumer-side binding failure, exactly
94 /// the same kind of thing as [`AcdpError::ContextIdMismatch`] (issue
95 /// #189) but for the lineage-walk path added for issue #226. Permanent;
96 /// fail closed — never added to [`AcdpError::is_transient`].
97 #[error(
98 "incomplete lineage {lineage_id}: registry-served members did not satisfy the \
99 expected membership (expected ctx_id: {ctx_id:?})"
100 )]
101 IncompleteLineage {
102 /// The lineage id that was walked (`GET /lineages/{id}`).
103 lineage_id: String,
104 /// The `ctx_id` a live search match named for this lineage, when
105 /// the walk was invoked with a specific member to check for.
106 /// `None` when the walk was invoked directly with no particular
107 /// member to verify (e.g. via `find_revocations_in_lineage`),
108 /// in which case only the empty-lineage case can produce this
109 /// variant.
110 ctx_id: Option<String>,
111 },
112
113 /// Locally detected: a revocation-discovery search helper
114 /// (`find_revocations`, `find_registry_attested_revocations` in
115 /// `acdp-client`) exhausted its pagination safety cap
116 /// (`MAX_SEARCH_PAGES`) with a search cursor still remaining, or
117 /// named more candidate revocation-lineage ids than its lineage-walk
118 /// safety cap (`MAX_LINEAGE_WALKS`) allows to fetch. Not a wire code
119 /// — RFC-ACDP-0014 §10 ("No new wire error code"): this is a
120 /// client-local safety-limit signal the registry cannot express, the
121 /// same rationale as [`AcdpError::ContextIdMismatch`] and
122 /// [`AcdpError::IncompleteLineage`]. Permanent for the same request
123 /// shape — never added to [`AcdpError::is_transient`]; retrying an
124 /// identical query reproduces the same truncation.
125 ///
126 /// Both caps exist as hostile-registry DoS protection
127 /// (`MAX_SEARCH_PAGES` bounds search round-trips; `MAX_LINEAGE_WALKS`
128 /// bounds the `GET /lineages/{id}` fetches a search result can
129 /// trigger, each capped at 1 MB). Search results are ordered
130 /// `created_at DESC`, so hitting a cap sheds the OLDEST — most
131 /// likely earliest-`compromised_since` — members first: a silently
132 /// truncated revocation set narrows a compromise window, which is
133 /// precisely the outcome RFC-ACDP-0014 §4 ("earliest T across a
134 /// revocation lineage is effective") forbids. A knowingly-partial
135 /// answer from these discovery helpers is strictly worse than a
136 /// loud refusal, so exhausting either cap with more results
137 /// remaining on the registry is a hard error, never a silently
138 /// partial `Vec`.
139 #[error("search truncated: {0}")]
140 SearchTruncated(String),
141
142 /// Locally detected (issue #258): RFC-ACDP-0014 §8 revocation
143 /// auto-discovery (`RevocationDiscovery::max_requests` /
144 /// `RevocationDiscovery::max_bytes` in `acdp-client`) exhausted its
145 /// caller-configured request-count or cumulative-byte budget before
146 /// both trust-class lookups finished. Not a wire code — RFC-ACDP-0014
147 /// §10 ("No new wire error code") forbids one, and this is
148 /// client-side only, the same rationale as
149 /// [`AcdpError::SearchTruncated`]. Permanent for the same request
150 /// shape — never added to [`AcdpError::is_transient`] — exhausting a
151 /// budget is closer to `SearchTruncated` ("we did not see
152 /// everything") than to a transport error, and carries the same
153 /// attacker-inducible-downgrade warning: a hostile registry that
154 /// learns a caller's budget can pad harmless-looking traffic to
155 /// exhaust it before a real revocation is found.
156 ///
157 /// The two budgets are combined across BOTH lookups
158 /// (`find_revocations` and, when opted in,
159 /// `find_registry_attested_revocations`), not one ceiling each —
160 /// enabling the registry-attested trust class does not double the
161 /// allowance. The request count is enforced exactly: the slot is
162 /// reserved *before* the request is issued, so a request is never
163 /// issued once the budget is reserved out, and — unlike the byte
164 /// count below — a 503, a parse failure, or a `PayloadTooLarge` on
165 /// an already-reserved request still consumes its slot. The byte
166 /// count is enforced on a check-before-issue basis using the running
167 /// total from completed requests, so up to TWO in-flight requests
168 /// (one per concurrently-running trust-class lookup) can push the
169 /// total over `max_bytes` before the NEXT request observes the
170 /// overrun. Counts registry traffic only (`acdp-client`'s
171 /// `RegistryClient::{capabilities, retrieve, lineage, search}`) —
172 /// DID-document fetches issued via `WebResolver` are not counted.
173 /// Only the byte count is scoped to successfully-parsed response
174 /// bodies: the capped error-envelope read on a non-success response
175 /// is never charged to `max_bytes`, but IS charged to `max_requests`
176 /// (the slot was already reserved before the response arrived).
177 #[error("revocation discovery budget exceeded: {0}")]
178 RevocationDiscoveryBudgetExceeded(String),
179
180 /// Locally detected: RFC-ACDP-0014 §8 revocation auto-discovery
181 /// (`RevocationPolicy::discover` in `acdp-client`) failed under
182 /// `DiscoveryFailurePolicy::FailClosed`. Not a wire code — the same
183 /// rationale as [`AcdpError::ContextIdMismatch`],
184 /// [`AcdpError::IncompleteLineage`], and [`AcdpError::SearchTruncated`]:
185 /// this is a client-local decision about how to react to a failure
186 /// the registry already reported some other way, so it wraps that
187 /// failure rather than inventing a new wire vocabulary entry for it.
188 /// `source` is boxed rather than inline: this variant recurses
189 /// (`AcdpError` containing `AcdpError`), and an unboxed recursive
190 /// field would make the enum infinite-sized. `Box<AcdpError>` still
191 /// gets `Clone` for free from [`AcdpError`]'s own derive (added for
192 /// issue #248 Phase 4 — see the enum's doc), which is what lets a
193 /// `ProceedWithKnown`-swallowed discovery failure be independently
194 /// owned by both `VerifiedContext::revocation_discovery_failure`
195 /// and `VerificationReport::revocation_discovery` from one call.
196 /// `AcdpError` still has no `PartialEq`.
197 #[error("revocation auto-discovery failed: {source}")]
198 RevocationDiscoveryFailed {
199 /// The underlying error discovery hit — typically a transport
200 /// failure from the search or lineage-walk requests
201 /// (`AcdpError::KeyResolutionUnreachable`, `AcdpError::Http`,
202 /// …) or `AcdpError::SearchTruncated` from exhausting a
203 /// discovery safety cap. [`AcdpError::is_transient`] delegates
204 /// to this field, so a retry-aware caller still gets a correct
205 /// answer through the wrapper.
206 source: Box<AcdpError>,
207 },
208
209 /// Wire code: `hash_mismatch`. The remote registry rejected a
210 /// publish request because its independent hash recomputation did
211 /// not match the producer-supplied `content_hash`. Distinct from
212 /// the local [`AcdpError::HashMismatch`] variant: this one carries
213 /// the registry's message verbatim and indicates a *producer-side*
214 /// bug (most often canonicalization divergence — see RFC-ACDP-0001
215 /// §5.7 and the `can-001` conformance fixture).
216 #[error("registry rejected hash_mismatch: {0}")]
217 RemoteHashMismatch(String),
218
219 /// Wire code: `data_ref_hash_mismatch`. A DataRef's fetched or decoded
220 /// bytes do not match the producer-declared `data_ref.content_hash`.
221 /// The body itself remains cryptographically valid — only the
222 /// referenced data has diverged. Distinct from
223 /// [`AcdpError::RemoteHashMismatch`] / [`AcdpError::HashMismatch`]
224 /// (body-level ProducerContent failure — the whole body is untrusted)
225 /// and [`AcdpError::InvalidSignature`] (a key / key-binding problem).
226 /// RFC-ACDP-0002 §6.5–6.6, RFC-ACDP-0007 §5.
227 #[error("data_ref hash mismatch: {0}")]
228 DataRefHashMismatch(String),
229
230 /// Signature verification failed or signature was malformed.
231 /// Wire code: `invalid_signature`.
232 #[error("invalid signature: {0}")]
233 InvalidSignature(String),
234
235 // ── DID / key resolution ─────────────────────────────────────────────
236 /// Wire code: `key_resolution_failed` (HTTP 400).
237 #[error("key resolution failed: {0}")]
238 KeyResolution(String),
239
240 /// Wire code: `key_resolution_unreachable` (HTTP 502) — transient, may retry.
241 #[error("key resolution unreachable (transient): {0}")]
242 KeyResolutionUnreachable(String),
243
244 /// Wire code: `key_not_authorized` (HTTP 403).
245 #[error("key not authorized: {0}")]
246 KeyNotAuthorized(String),
247
248 // ── Input validation ─────────────────────────────────────────────────
249 /// Producer body could not be parsed.
250 #[error("invalid body: {0}")]
251 InvalidBody(String),
252
253 /// A required field was missing.
254 #[error("missing required field: {0}")]
255 MissingField(&'static str),
256
257 /// Schema validation failed (string length, array uniqueness, oneOf, etc).
258 /// Wire code: `schema_violation`.
259 #[error("schema violation: {0}")]
260 SchemaViolation(String),
261
262 /// Wire code: `payload_too_large` — request body exceeds the registry limit.
263 #[error("payload too large: {0}")]
264 PayloadTooLarge(String),
265
266 /// Wire code: `embedded_too_large` — a single `DataRef.embedded.content`
267 /// exceeds the 64 KB cap.
268 #[error("embedded data reference too large: {0}")]
269 EmbeddedTooLarge(String),
270
271 /// Wire code: `unsupported_algorithm` — the producer used a signature
272 /// algorithm the registry does not accept.
273 #[error("unsupported algorithm: {0}")]
274 UnsupportedAlgorithm(String),
275
276 /// Wire code: `unsupported_media_type` — the request carried a body whose
277 /// `Content-Type` is outside the registry's accept-set (RFC-ACDP-0007 §4.1,
278 /// §5; HTTP 415). Added on the 0.5.0 line.
279 ///
280 /// Distinct from [`AcdpError::SchemaViolation`] on purpose: the body is
281 /// rejected *unparsed*, so no structural claim about it is made. A registry
282 /// answering `schema_violation` here would be asserting a validation that
283 /// never ran — and that code is pinned to HTTP 400. Registries advertising
284 /// `acdp_version` below 0.5.0 MUST NOT emit this code.
285 #[error("unsupported media type: {0}")]
286 UnsupportedMediaType(String),
287
288 /// Wire code: `not_implemented` — endpoint or feature not supported by
289 /// this registry.
290 #[error("not implemented: {0}")]
291 NotImplemented(String),
292
293 // ── Retrieval / authorization ────────────────────────────────────────
294 /// Wire code: `not_found`.
295 #[error("not found: {0}")]
296 NotFound(String),
297
298 /// Wire code: `not_authorized` — the caller is not permitted to access
299 /// this resource.
300 #[error("not authorized: {0}")]
301 NotAuthorized(String),
302
303 /// Wire code: `rate_limited`.
304 #[error("rate limited: {0}")]
305 RateLimited(String),
306
307 // ── Pagination ───────────────────────────────────────────────────────
308 /// Wire code: `cursor_expired`.
309 #[error("search cursor expired")]
310 CursorExpired,
311
312 /// Wire code: `invalid_cursor`.
313 #[error("invalid cursor: {0}")]
314 InvalidCursor(String),
315
316 // ── Publication ──────────────────────────────────────────────────────
317 /// Wire code: `superseded_target`. The supersession target was rejected;
318 /// the [`SupersessionReason`] disambiguates the cause.
319 #[error("superseded target rejected ({reason:?}): {message}")]
320 SupersededTarget {
321 /// Why the target was rejected.
322 reason: SupersessionReason,
323 /// Human-readable message from the registry.
324 message: String,
325 },
326
327 /// Wire code: `duplicate_publish` — an Idempotency-Key replay produced
328 /// a different request body than the original.
329 #[error("duplicate publish: {0}")]
330 DuplicatePublish(String),
331
332 // ── Cross-registry ───────────────────────────────────────────────────
333 /// Wire code: `cross_registry_resolution_failed`.
334 #[error("cross-registry resolution failed: {0}")]
335 CrossRegistryResolutionFailed(String),
336
337 // ── Registry receipts (ACDP 0.2, RFC-ACDP-0010) ─────────────────────
338 /// Wire code: `invalid_receipt`. A `registry_receipt` failed
339 /// verification: bad signature, a cross-check mismatch (`ctx_id`,
340 /// `content_hash`, `key_fingerprint`, serving authority), a
341 /// malformed shape, or a receipt required by policy but absent.
342 /// Permanent — the receipt will not verify on retry.
343 #[error("invalid registry receipt: {0}")]
344 InvalidReceipt(String),
345
346 /// Wire code: `invalid_log_proof` (RFC-ACDP-0012 §9, §11 — 0.3.0).
347 /// A transparency-log artifact failed verification: an inclusion
348 /// proof that does not fold to the checkpoint's root, a failed
349 /// consistency proof between tree sizes, or a checkpoint whose
350 /// signature does not verify. Permanent — a bad proof will not
351 /// verify on retry (HTTP 502 on the wire: the upstream log is at
352 /// fault when a federated resolver emits it).
353 #[error("invalid transparency-log proof: {0}")]
354 InvalidLogProof(String),
355
356 /// Wire code: `invalid_witness_cosignature` (RFC-ACDP-0015 §8,
357 /// §10 — 0.4.0). A transparency-log **witness cosignature** failed
358 /// the §8 verification procedure: closed parse, the witness-key
359 /// signature, witness binding (`signature.key_id` DID ≠
360 /// `witness_id`), checkpoint binding (`witnessed_checkpoint` ≠ the
361 /// checkpoint being evaluated), or the `witnessed_at` skew check.
362 /// Deliberately **distinct** from [`AcdpError::InvalidLogProof`]:
363 /// that indicts the *log* (tree membership, history consistency,
364 /// the registry's checkpoint signature); this indicts a *witness's*
365 /// attestation — an independent verdict over an independent signer
366 /// (RFC-ACDP-0015 §10). Permanent — a bad cosignature will not
367 /// verify on retry (HTTP 502 on the wire: the cosignature came from
368 /// an upstream party — a registry aggregating on a caller's behalf
369 /// or a resolver validating a witness's cosignatures). A cosignature
370 /// that verifies but is merely *stale* is consumer freshness policy
371 /// (§8.1), never this code.
372 #[error("invalid witness cosignature: {0}")]
373 InvalidWitnessCosignature(String),
374
375 /// Wire code: `immutable_field` (RFC-ACDP-0013 §6, §10 — 0.3.0;
376 /// activated from the v0.1.0 reservation). A lifecycle (or future
377 /// mutation) endpoint request attempted to supply or alter
378 /// immutable body content. Bodies are immutable; lifecycle
379 /// endpoints mutate registry state only. Permanent (HTTP 400).
380 #[error("immutable field: {0}")]
381 ImmutableField(String),
382
383 /// Wire code: `invalid_lifecycle_transition` (RFC-ACDP-0013 §6
384 /// step 4, §10 — 0.3.0). The requested lifecycle transition
385 /// conflicts with the context's current retraction state (retract
386 /// of an already-retracted context; republish of a never-retracted
387 /// one). A state conflict like the 409 arm of `superseded_target`;
388 /// retryable only after the state changes (HTTP 409).
389 #[error("invalid lifecycle transition: {0}")]
390 InvalidLifecycleTransition(String),
391
392 // ── Wire / transport ─────────────────────────────────────────────────
393 /// Wire code: `internal_error`.
394 #[error("registry internal error: {0}")]
395 RegistryInternal(String),
396
397 /// Catch-all for `WireError` codes that have no typed variant in this
398 /// version of the library. Forward-compatible: registries may emit
399 /// reserved codes (`unsupported_embedding_model`)
400 /// that future ACDP versions add.
401 #[error("registry returned error: {0:?}")]
402 Registry(crate::wire_error::WireError),
403
404 /// JSON (de)serialization failed.
405 #[error("serialization failed: {0}")]
406 Serialization(String),
407
408 /// HTTP transport error.
409 #[error("HTTP error: {0}")]
410 Http(String),
411}
412
413/// Sub-reason for [`AcdpError::SupersededTarget`]. Starts from the
414/// `details.reason` values enumerated by `acdp-error.schema.json`'s
415/// `superseded_target` description (`not_found`, `lineage_mismatch`,
416/// `version_mismatch`, `already_superseded`,
417/// `cross_registry_supersession_unsupported`) and adds typed reasons this
418/// library recognizes beyond what that enum currently lists
419/// (`LineageWalkFailed`, `RevocationTypeMismatch`) — it is a superset, not
420/// a mirror. `#[non_exhaustive]` plus the `Other` catch-all below is what
421/// keeps a schema addition from being a breaking change here.
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423#[non_exhaustive]
424#[serde(rename_all = "snake_case")]
425pub enum SupersessionReason {
426 /// The supersedes target context does not exist on this registry.
427 NotFound,
428 /// The target's lineage_id differs from the new publication's lineage.
429 LineageMismatch,
430 /// The new version is not exactly `previous.version + 1`.
431 VersionMismatch,
432 /// The target has already been superseded by a different version.
433 AlreadySuperseded,
434 /// The target lives on a different registry; v0.1.0 only allows
435 /// same-registry supersession.
436 CrossRegistrySupersessionUnsupported,
437 /// The lineage walk through `supersedes` failed because an
438 /// intermediate context could not be retrieved (RFC-ACDP-0001 §5.6.1).
439 LineageWalkFailed,
440 /// A non-revocation context superseded a `key-revocation` (or interim
441 /// `acdp:key-revocation`) target (RFC-ACDP-0014 §4/§10). Only emitted by
442 /// registries advertising `acdp_version >= 0.5.0`; below that version
443 /// this rejection surfaces as `AcdpError::SchemaViolation` instead.
444 /// `Provisional` per `registries/error-codes.md` (the 0.5.0 line is
445 /// still Draft).
446 RevocationTypeMismatch,
447 /// A reason this version of the library does not recognize.
448 #[serde(other)]
449 Other,
450}
451
452impl AcdpError {
453 /// Whether this error is plausibly transient and worth retrying
454 /// with the same request body (and, if applicable, the same
455 /// `Idempotency-Key`).
456 ///
457 /// Returned by [`AcdpError::is_transient`] only for variants whose
458 /// wire codes the spec marks retryable: `key_resolution_unreachable`
459 /// (RFC-ACDP-0001 §5.11), `rate_limited` (RFC-ACDP-0008 §4.3),
460 /// `cross_registry_resolution_failed` (RFC-ACDP-0006 §7), and
461 /// `internal_error` (RFC-ACDP-0007 §5). Generic `Http` transport
462 /// errors are conservatively treated as transient since they
463 /// usually mean DNS or TCP-level glitches.
464 ///
465 /// All cryptographic, schema, and authorization errors are NOT
466 /// transient: a malformed body or invalid signature will not
467 /// magically validate on retry.
468 ///
469 /// [`AcdpError::RevocationDiscoveryFailed`] is a wrapper, not a
470 /// wire code, so it is exempted from the list above and instead
471 /// delegates to its own `source` — a retry-aware caller unwrapping
472 /// the wrapper still gets the right answer.
473 pub fn is_transient(&self) -> bool {
474 if let AcdpError::RevocationDiscoveryFailed { source } = self {
475 return source.is_transient();
476 }
477 matches!(
478 self,
479 AcdpError::KeyResolutionUnreachable(_)
480 | AcdpError::RateLimited(_)
481 | AcdpError::CrossRegistryResolutionFailed(_)
482 | AcdpError::RegistryInternal(_)
483 | AcdpError::Http(_)
484 )
485 }
486
487 /// Map a wire-protocol [`crate::wire_error::WireError`] into a typed
488 /// [`AcdpError`].
489 ///
490 /// Codes the library does not yet recognize are returned as
491 /// [`AcdpError::Registry`] for forward compatibility.
492 pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
493 let code = wire.error.code.as_str();
494 let msg = wire.error.message.clone();
495
496 match code {
497 "invalid_signature" => AcdpError::InvalidSignature(msg),
498 "hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
499 "data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
500 "schema_violation" => AcdpError::SchemaViolation(msg),
501 "not_authorized" => AcdpError::NotAuthorized(msg),
502 "not_found" => AcdpError::NotFound(msg),
503 "rate_limited" => AcdpError::RateLimited(msg),
504 "payload_too_large" => AcdpError::PayloadTooLarge(msg),
505 "embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
506 "key_resolution_failed" => AcdpError::KeyResolution(msg),
507 "key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
508 "key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
509 "unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
510 "unsupported_media_type" => AcdpError::UnsupportedMediaType(msg),
511 "not_implemented" => AcdpError::NotImplemented(msg),
512 "cursor_expired" => AcdpError::CursorExpired,
513 "invalid_cursor" => AcdpError::InvalidCursor(msg),
514 "duplicate_publish" => AcdpError::DuplicatePublish(msg),
515 "cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
516 "invalid_receipt" => AcdpError::InvalidReceipt(msg),
517 "invalid_log_proof" => AcdpError::InvalidLogProof(msg),
518 "invalid_witness_cosignature" => AcdpError::InvalidWitnessCosignature(msg),
519 "immutable_field" => AcdpError::ImmutableField(msg),
520 "invalid_lifecycle_transition" => AcdpError::InvalidLifecycleTransition(msg),
521 "internal_error" => AcdpError::RegistryInternal(msg),
522 "superseded_target" => {
523 let reason = wire
524 .error
525 .details
526 .as_ref()
527 .and_then(|d| d.get("reason"))
528 .and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
529 .unwrap_or(SupersessionReason::Other);
530 AcdpError::SupersededTarget {
531 reason,
532 message: msg,
533 }
534 }
535 // Unknown / future codes pass through as the catch-all variant
536 _ => AcdpError::Registry(wire),
537 }
538 }
539}
540
541impl From<serde_json::Error> for AcdpError {
542 fn from(e: serde_json::Error) -> Self {
543 AcdpError::Serialization(e.to_string())
544 }
545}
546
547impl From<std::io::Error> for AcdpError {
548 fn from(e: std::io::Error) -> Self {
549 AcdpError::Http(format!("io error: {e}"))
550 }
551}
552
553#[cfg(feature = "reqwest")]
554impl From<reqwest::Error> for AcdpError {
555 fn from(e: reqwest::Error) -> Self {
556 // reqwest's own `Display` prints only its outermost frame (e.g.
557 // "error sending request for url (...)"); the cause it wraps —
558 // notably a `SafeDnsResolver` SSRF rejection, which is otherwise
559 // silently dropped — lives in the `source()` chain. Walk it so
560 // that detail reaches the caller instead of vanishing into a
561 // generic "connection failed".
562 let mut msg = e.to_string();
563 let mut source: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
564 while let Some(s) = source {
565 msg.push_str(&format!(": {s}"));
566 source = s.source();
567 }
568 if e.is_connect() || e.is_timeout() {
569 AcdpError::Http(format!("connection failed: {msg}"))
570 } else {
571 AcdpError::Http(msg)
572 }
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::wire_error::{WireError, WireErrorBody};
580 use serde_json::json;
581
582 fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
583 WireError {
584 error: WireErrorBody {
585 code: code.into(),
586 message: message.into(),
587 details,
588 },
589 }
590 }
591
592 #[test]
593 fn all_26_wire_codes_round_trip() {
594 // Test-coverage matrix entry: "All 26 error codes parse from WireError".
595 // Every code enumerated by acdp-error.schema.json's enum MUST map to a
596 // typed AcdpError variant (or, for `superseded_target` with details,
597 // produce the right SupersessionReason).
598 type Check = fn(&AcdpError) -> bool;
599 let cases: &[(&str, Check)] = &[
600 ("invalid_signature", |e| {
601 matches!(e, AcdpError::InvalidSignature(_))
602 }),
603 ("hash_mismatch", |e| {
604 matches!(e, AcdpError::RemoteHashMismatch(_))
605 }),
606 ("data_ref_hash_mismatch", |e| {
607 matches!(e, AcdpError::DataRefHashMismatch(_))
608 }),
609 ("schema_violation", |e| {
610 matches!(e, AcdpError::SchemaViolation(_))
611 }),
612 ("not_authorized", |e| {
613 matches!(e, AcdpError::NotAuthorized(_))
614 }),
615 ("not_found", |e| matches!(e, AcdpError::NotFound(_))),
616 ("superseded_target", |e| {
617 matches!(e, AcdpError::SupersededTarget { .. })
618 }),
619 ("unsupported_algorithm", |e| {
620 matches!(e, AcdpError::UnsupportedAlgorithm(_))
621 }),
622 ("rate_limited", |e| matches!(e, AcdpError::RateLimited(_))),
623 ("payload_too_large", |e| {
624 matches!(e, AcdpError::PayloadTooLarge(_))
625 }),
626 ("embedded_too_large", |e| {
627 matches!(e, AcdpError::EmbeddedTooLarge(_))
628 }),
629 ("key_resolution_failed", |e| {
630 matches!(e, AcdpError::KeyResolution(_))
631 }),
632 ("key_resolution_unreachable", |e| {
633 matches!(e, AcdpError::KeyResolutionUnreachable(_))
634 }),
635 ("key_not_authorized", |e| {
636 matches!(e, AcdpError::KeyNotAuthorized(_))
637 }),
638 ("not_implemented", |e| {
639 matches!(e, AcdpError::NotImplemented(_))
640 }),
641 ("unsupported_media_type", |e| {
642 matches!(e, AcdpError::UnsupportedMediaType(_))
643 }),
644 ("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
645 ("invalid_cursor", |e| {
646 matches!(e, AcdpError::InvalidCursor(_))
647 }),
648 ("duplicate_publish", |e| {
649 matches!(e, AcdpError::DuplicatePublish(_))
650 }),
651 ("cross_registry_resolution_failed", |e| {
652 matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
653 }),
654 ("invalid_receipt", |e| {
655 matches!(e, AcdpError::InvalidReceipt(_))
656 }),
657 ("invalid_log_proof", |e| {
658 matches!(e, AcdpError::InvalidLogProof(_))
659 }),
660 ("invalid_witness_cosignature", |e| {
661 matches!(e, AcdpError::InvalidWitnessCosignature(_))
662 }),
663 ("immutable_field", |e| {
664 matches!(e, AcdpError::ImmutableField(_))
665 }),
666 ("invalid_lifecycle_transition", |e| {
667 matches!(e, AcdpError::InvalidLifecycleTransition(_))
668 }),
669 ("internal_error", |e| {
670 matches!(e, AcdpError::RegistryInternal(_))
671 }),
672 ];
673 // Schema enumerates exactly 26 codes (RFC-ACDP-0007 §5 + the
674 // RFC-ACDP-0010 `invalid_receipt` addition + the 0.3.0 codes:
675 // `invalid_log_proof` (RFC-0012), `immutable_field` and
676 // `invalid_lifecycle_transition` (RFC-0013) + the 0.4.0 code
677 // `invalid_witness_cosignature` (RFC-0015) + the 0.5.0 code
678 // `unsupported_media_type` (RFC-0007 §4.1, spec #68)).
679 //
680 // This count is hand-maintained and therefore only catches a code this
681 // repo forgot to *add*. The forcing function that catches the spec
682 // growing underneath us is `wire_error_codes_cover_the_spec_enum` in
683 // `tests/conformance.rs`, which reads the enum out of
684 // `acdp-error.schema.json` itself.
685 assert_eq!(cases.len(), 26);
686 for (code, expected) in cases {
687 let err = AcdpError::from_wire_error(wire(code, "msg", None));
688 assert!(
689 expected(&err),
690 "code '{code}' did not map to its typed variant: got {err:?}"
691 );
692 }
693 }
694
695 #[test]
696 fn superseded_target_with_reason_details() {
697 let w = wire(
698 "superseded_target",
699 "lineage mismatch",
700 Some(json!({"reason": "lineage_mismatch"})),
701 );
702 match AcdpError::from_wire_error(w) {
703 AcdpError::SupersededTarget { reason, .. } => {
704 assert_eq!(reason, SupersessionReason::LineageMismatch);
705 }
706 other => panic!("expected SupersededTarget, got {other:?}"),
707 }
708 }
709
710 #[test]
711 fn superseded_target_without_details_falls_back_to_other() {
712 let w = wire("superseded_target", "?", None);
713 match AcdpError::from_wire_error(w) {
714 AcdpError::SupersededTarget { reason, .. } => {
715 assert_eq!(reason, SupersessionReason::Other);
716 }
717 other => panic!("got {other:?}"),
718 }
719 }
720
721 #[test]
722 fn unknown_code_passes_through_as_registry() {
723 let w = wire("unsupported_embedding_model", "reserved future code", None);
724 assert!(matches!(
725 AcdpError::from_wire_error(w),
726 AcdpError::Registry(_)
727 ));
728 }
729
730 /// T4 — `lineage_walk_failed` reason round-trips via WireError
731 /// (RFC-ACDP-0001 §5.6.1).
732 #[test]
733 fn lineage_walk_failed_reason_roundtrip() {
734 let w = wire(
735 "superseded_target",
736 "intermediate not retrievable",
737 Some(json!({
738 "reason": "lineage_walk_failed",
739 "unreachable_ctx_id":
740 "acdp://r.example.com/12345678-1234-4321-8123-123456781234"
741 })),
742 );
743 match AcdpError::from_wire_error(w) {
744 AcdpError::SupersededTarget { reason, .. } => {
745 assert_eq!(reason, SupersessionReason::LineageWalkFailed);
746 }
747 other => panic!("got {other:?}"),
748 }
749 }
750
751 /// RFC-ACDP-0014 §4 — `revocation_type_mismatch` reason round-trips via
752 /// `WireError`, same shape as `lineage_walk_failed_reason_roundtrip`
753 /// above. This is also a direct serde round-trip of the
754 /// `SupersessionReason` variant itself (`to_value`/`from_value`), not
755 /// just an assertion on the value `from_wire_error` produces.
756 #[test]
757 fn revocation_type_mismatch_reason_roundtrip() {
758 assert_eq!(
759 serde_json::to_value(SupersessionReason::RevocationTypeMismatch).unwrap(),
760 json!("revocation_type_mismatch")
761 );
762 assert_eq!(
763 serde_json::from_value::<SupersessionReason>(json!("revocation_type_mismatch"))
764 .unwrap(),
765 SupersessionReason::RevocationTypeMismatch
766 );
767
768 let w = wire(
769 "superseded_target",
770 "non-revocation context cannot supersede a key-revocation target",
771 Some(json!({"reason": "revocation_type_mismatch"})),
772 );
773 match AcdpError::from_wire_error(w) {
774 AcdpError::SupersededTarget { reason, .. } => {
775 assert_eq!(reason, SupersessionReason::RevocationTypeMismatch);
776 }
777 other => panic!("got {other:?}"),
778 }
779 }
780
781 /// `is_transient` covers the wire codes the spec marks retryable.
782 #[test]
783 fn is_transient_for_known_retryables() {
784 assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
785 assert!(AcdpError::RateLimited("x".into()).is_transient());
786 assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
787 assert!(AcdpError::RegistryInternal("x".into()).is_transient());
788 assert!(AcdpError::Http("x".into()).is_transient());
789 assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
790 assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
791 assert!(!AcdpError::NotFound("x".into()).is_transient());
792 // BUG-02: data-ref hash mismatch is a data-integrity failure;
793 // retrying the SAME publish/fetch will return the same answer.
794 // The spec marks it permanent, not retryable.
795 assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
796 // RFC-ACDP-0010: a failed receipt will not verify on retry.
797 assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
798 assert!(!AcdpError::InvalidLogProof("x".into()).is_transient());
799 // RFC-ACDP-0015: a bad witness cosignature will not verify on retry.
800 assert!(!AcdpError::InvalidWitnessCosignature("x".into()).is_transient());
801 // RFC-ACDP-0007 §4.1: retrying with the same Content-Type gets the
802 // same 415. The fix is a different request, not a later one.
803 assert!(!AcdpError::UnsupportedMediaType("x".into()).is_transient());
804 assert!(!AcdpError::ImmutableField("x".into()).is_transient());
805 assert!(!AcdpError::InvalidLifecycleTransition("x".into()).is_transient());
806 // Issue #189: context substitution is locally detected and permanent;
807 // retrying against the same misbehaving registry will not fix it.
808 assert!(!AcdpError::ContextIdMismatch {
809 requested: "a".into(),
810 served: "b".into(),
811 }
812 .is_transient());
813 // Issue #226 Phase 3: an incomplete/mismatched lineage is a
814 // locally-detected consumer-side binding failure, not a
815 // transport hiccup — retrying will not change what the
816 // registry serves for the same lineage_id.
817 assert!(!AcdpError::IncompleteLineage {
818 lineage_id: "lin:sha256:aa".into(),
819 ctx_id: Some("acdp://r.example.com/x".into()),
820 }
821 .is_transient());
822 // Issue #226 Phase 4: exhausting the search-page or lineage-walk
823 // safety cap is a client-local safety-limit signal, not a
824 // transport hiccup — retrying the identical query reproduces the
825 // same truncation.
826 assert!(!AcdpError::SearchTruncated("x".into()).is_transient());
827 // Issue #258: exhausting a caller-configured discovery request/byte
828 // budget is a client-local safety-limit signal, like
829 // `SearchTruncated` above — never transient, and deliberately NOT
830 // added to the `matches!` list in `is_transient`.
831 assert!(!AcdpError::RevocationDiscoveryBudgetExceeded("x".into()).is_transient());
832 }
833}