Skip to main content

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;
10#[cfg(doc)]
11use crate::revocation::RevocationEffect;
12use crate::revocation::{RevocationRefusal, UnknownReason};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16/// Top-level error type.
17///
18/// `#[non_exhaustive]`: the wire vocabulary (RFC-ACDP-0007 §5) keeps growing
19/// as new RFCs land — 25 wire codes now, up from 21 not long ago, with
20/// RFC-ACDP-0009 reserved and still unimplemented. Without this attribute,
21/// every new variant is a semver-breaking change for any downstream crate
22/// that matches on `AcdpError` exhaustively. Same rationale as
23/// `SsrfReason` in `crates/acdp-safe-http/src/lib.rs` ("future spec
24/// revisions may add ranges"); match with a wildcard arm.
25/// `Clone` (added for issue #248 Phase 4): `VerifiedContext` and
26/// `VerificationReport` each need an independently-owned copy of a
27/// `ProceedWithKnown`-swallowed revocation-discovery failure — one via
28/// `VerifiedContext::revocation_discovery_failure()`, the other via
29/// `VerificationReport::revocation_discovery` — so both surfaces stay
30/// non-silent from a single call. Every variant field (`String`,
31/// `&'static str`, `ContentHash`, `WireError`, `SupersessionReason`,
32/// and the recursive `Box<AcdpError>` in `RevocationUnknown`)
33/// is already `Clone`, so this is a free addition with no wire-format
34/// or matching impact.
35///
36/// This is a standing constraint on every future variant, not just the
37/// ones that exist today: every future variant's payloads must remain
38/// `Clone`. A variant that needs a structured source should hold
39/// `Arc<dyn std::error::Error + Send + Sync>` (which is `Clone`
40/// regardless of the inner type, and still preserves `source()`
41/// chaining), never `Box<dyn Error>` or a bare `io::Error` (neither is
42/// `Clone`). This is not a new tradeoff introduced by adding `Clone`
43/// here — `From<std::io::Error>` and `From<reqwest::Error>` already
44/// stringify into `Http(String)` rather than carry the error value, so
45/// value semantics over reference/source-chain semantics was already
46/// the twice-exercised choice for this type; `Clone` just makes it a
47/// documented rule instead of an implicit pattern.
48#[derive(Debug, Clone, Error)]
49#[non_exhaustive]
50pub enum AcdpError {
51    // ── Cryptography ─────────────────────────────────────────────────────────
52    /// JCS canonicalization failed (input not serializable).
53    #[error("JCS canonicalization failed: {0}")]
54    Canonicalization(String),
55
56    /// Stored `content_hash` did not match the recomputed value
57    /// (locally detected during signature verification).
58    #[error("content_hash mismatch\n  stored:     {stored}\n  recomputed: {recomputed}")]
59    HashMismatch {
60        /// The hash claimed by the body or request.
61        stored: ContentHash,
62        /// The hash recomputed by the verifier.
63        recomputed: ContentHash,
64    },
65
66    /// Locally detected: the registry returned a body whose `ctx_id` is not
67    /// the one that was requested (context substitution). Not a wire code —
68    /// the client detects it. Permanent; fail closed.
69    ///
70    /// Implements RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
71    /// identity"): step 7 requires exactly this comparison — `body.ctx_id`
72    /// against the `ctx_id` used to construct the request — and permits a
73    /// consumer to surface "an equivalent typed error" in place of the
74    /// registry-side `cross_registry_resolution_failed` wire code; this
75    /// variant is that typed error. See RFC-ACDP-0008 §9.1 for the threat
76    /// this closes: without it, a registry can serve any other
77    /// validly-signed body by the same producer under the requested
78    /// context's URL, and both signature verification and `content_hash`
79    /// recomputation still pass. It does **not** close §9.1 in full: a
80    /// registry that genuinely republishes the same content under a new
81    /// `ctx_id` still passes; only serve-time substitution — a different
82    /// id claimed to be the one requested — is caught.
83    #[error("context substitution: requested {requested}, registry served {served}")]
84    ContextIdMismatch {
85        /// The `ctx_id` the caller requested.
86        requested: String,
87        /// The `ctx_id` actually present on the body the registry served.
88        served: String,
89    },
90
91    /// Locally detected: a caller-supplied deadline passed before the
92    /// operation finished (the `acdp-client` `blocking` facade). Not a wire
93    /// code. The operation is abandoned and fails closed: no partial
94    /// result is returned. Transient (see [`AcdpError::is_transient`]): the
95    /// same call with more time, or on a later attempt, may succeed.
96    #[error("deadline exceeded: {0}")]
97    DeadlineExceeded(String),
98
99    /// Locally detected: `GET /lineages/{id}` (RFC-ACDP-0013 §8.1) did not
100    /// serve a lineage the caller could accept — either it came back with
101    /// no members at all, or (when `ctx_id` is `Some`) its members did not
102    /// include the `ctx_id` a live search match named for that lineage.
103    /// Not a wire code — RFC-ACDP-0014 §10 ("No new wire error code"):
104    /// this is a locally-detected consumer-side binding failure, exactly
105    /// the same kind of thing as [`AcdpError::ContextIdMismatch`] (issue
106    /// #189) but for the lineage-walk path added for issue #226. Permanent;
107    /// fail closed — never added to [`AcdpError::is_transient`].
108    #[error(
109        "incomplete lineage {lineage_id}: registry-served members did not satisfy the \
110         expected membership (expected ctx_id: {ctx_id:?})"
111    )]
112    IncompleteLineage {
113        /// The lineage id that was walked (`GET /lineages/{id}`).
114        lineage_id: String,
115        /// The `ctx_id` a live search match named for this lineage, when
116        /// the walk was invoked with a specific member to check for.
117        /// `None` when the walk was invoked directly with no particular
118        /// member to verify (e.g. via `find_revocations_in_lineage`),
119        /// in which case only the empty-lineage case can produce this
120        /// variant.
121        ctx_id: Option<String>,
122    },
123
124    /// Locally detected: a revocation-discovery search helper
125    /// (`find_revocations`, `find_registry_attested_revocations` in
126    /// `acdp-client`) exhausted its pagination safety cap
127    /// (`MAX_SEARCH_PAGES`) with a search cursor still remaining, or
128    /// named more candidate revocation-lineage ids than its lineage-walk
129    /// safety cap (`MAX_LINEAGE_WALKS`) allows to fetch. Not a wire code
130    /// — RFC-ACDP-0014 §10 ("No new wire error code"): this is a
131    /// client-local safety-limit signal the registry cannot express, the
132    /// same rationale as [`AcdpError::ContextIdMismatch`] and
133    /// [`AcdpError::IncompleteLineage`]. Permanent for the same request
134    /// shape — never added to [`AcdpError::is_transient`]; retrying an
135    /// identical query reproduces the same truncation.
136    ///
137    /// Both caps exist as hostile-registry DoS protection
138    /// (`MAX_SEARCH_PAGES` bounds search round-trips; `MAX_LINEAGE_WALKS`
139    /// bounds the `GET /lineages/{id}` fetches a search result can
140    /// trigger, each capped at 1 MB). Search results are ordered
141    /// `created_at DESC`, so hitting a cap sheds the OLDEST — most
142    /// likely earliest-`compromised_since` — members first: a silently
143    /// truncated revocation set narrows a compromise window, which is
144    /// precisely the outcome RFC-ACDP-0014 §4 ("earliest T across a
145    /// revocation lineage is effective") forbids. A knowingly-partial
146    /// answer from these discovery helpers is strictly worse than a
147    /// loud refusal, so exhausting either cap with more results
148    /// remaining on the registry is a hard error, never a silently
149    /// partial `Vec`.
150    #[error("search truncated: {0}")]
151    SearchTruncated(String),
152
153    /// Locally detected (issue #258): RFC-ACDP-0014 §8 revocation
154    /// auto-discovery (`RevocationDiscovery::max_requests` /
155    /// `RevocationDiscovery::max_bytes` in `acdp-client`) exhausted its
156    /// caller-configured request-count or cumulative-byte budget before
157    /// both trust-class lookups finished. Not a wire code — RFC-ACDP-0014
158    /// §10 ("No new wire error code") forbids one, and this is
159    /// client-side only, the same rationale as
160    /// [`AcdpError::SearchTruncated`]. Permanent for the same request
161    /// shape — never added to [`AcdpError::is_transient`] — exhausting a
162    /// budget is closer to `SearchTruncated` ("we did not see
163    /// everything") than to a transport error, and carries the same
164    /// attacker-inducible-downgrade warning: a hostile registry that
165    /// learns a caller's budget can pad harmless-looking traffic to
166    /// exhaust it before a real revocation is found.
167    ///
168    /// The two budgets are combined across BOTH lookups
169    /// (`find_revocations` and, when opted in,
170    /// `find_registry_attested_revocations`), not one ceiling each —
171    /// enabling the registry-attested trust class does not double the
172    /// allowance. The request count is enforced exactly: the slot is
173    /// reserved *before* the request is issued, so a request is never
174    /// issued once the budget is reserved out, and — unlike the byte
175    /// count below — a 503, a parse failure, or a `PayloadTooLarge` on
176    /// an already-reserved request still consumes its slot. The byte
177    /// count is enforced on a check-before-issue basis using the running
178    /// total from completed requests, so up to TWO in-flight requests
179    /// (one per concurrently-running trust-class lookup) can push the
180    /// total over `max_bytes` before the NEXT request observes the
181    /// overrun. Counts registry traffic only (`acdp-client`'s
182    /// `RegistryClient::{capabilities, retrieve, lineage, search}`) —
183    /// DID-document fetches issued via `WebResolver` are not counted.
184    /// Only the byte count is scoped to successfully-parsed response
185    /// bodies: the capped error-envelope read on a non-success response
186    /// is never charged to `max_bytes`, but IS charged to `max_requests`
187    /// (the slot was already reserved before the response arrived).
188    #[error("revocation discovery budget exceeded: {0}")]
189    RevocationDiscoveryBudgetExceeded(String),
190
191    /// Locally detected (RFC-ACDP-0014 §8, 0.5.0 "Discovery outcome
192    /// classes"): a revocation candidate found during auto-discovery
193    /// could be neither verified nor proven invalid. The canonical case is
194    /// the mandatory *historical arm*: the candidate's signing key is in
195    /// the producer's `verificationMethod` but no longer in
196    /// `assertionMethod` (the producer rotated after publishing it), and
197    /// the candidate carries no `registry_receipt` — or one that does not
198    /// verify — so the RFC-ACDP-0010 §10 historical-key rule cannot place
199    /// the signature (rev-005 B).
200    ///
201    /// Not a wire code (RFC-ACDP-0014 §10). Not transient — retrying the
202    /// same discovery will see the same candidate — but **discovery-
203    /// fatal**: the spec forbids folding an unverifiable candidate into
204    /// "not revoked", so discovery surfaces it (wrapped in
205    /// [`AcdpError::RevocationUnknown`]) exactly like a transient
206    /// lookup failure, and the overall outcome is *unknown*.
207    #[error("revocation candidate unverifiable: {0}")]
208    RevocationCandidateUnverifiable(String),
209
210    /// Locally detected (RFC-ACDP-0014 §8, 0.5.0 "Discovery outcome
211    /// classes"): revocation auto-discovery (`RevocationPolicy::discover`
212    /// in `acdp-client`) could not establish whether the signing key is
213    /// revoked — the outcome is **`unknown`**, which is never folded into
214    /// not-revoked. Returned under `DiscoveryFailurePolicy::FailClosed`
215    /// (the default); under `ProceedWithKnown` the same value is reported
216    /// on the verified context instead.
217    ///
218    /// Not a wire code (RFC-ACDP-0014 §10). [`AcdpError::is_transient`] is
219    /// `true` exactly when `reason` is [`UnknownReason::Transient`] — map
220    /// that to "unavailable, retry" and every other reason to "failed
221    /// precondition".
222    ///
223    /// Replaces 0.14's `RevocationDiscoveryFailed { source }`: match
224    /// `RevocationUnknown { source, .. }` for the same shape.
225    #[error("revocation status unknown ({reason}): {detail}")]
226    RevocationUnknown {
227        /// The `unknown` reason sub-code.
228        reason: UnknownReason,
229        /// The underlying failure (a transport or lookup error,
230        /// [`AcdpError::SearchTruncated`],
231        /// [`AcdpError::RevocationDiscoveryBudgetExceeded`],
232        /// [`AcdpError::RevocationCandidateUnverifiable`], …). Boxed:
233        /// the variant recurses.
234        source: Box<AcdpError>,
235        /// Human-readable context (which lookup, which vantage).
236        detail: String,
237    },
238
239    /// Locally detected (RFC-ACDP-0014 §7, 0.5.0 "Attribution"): a
240    /// verified, in-scope revocation makes the context's signing key not
241    /// authorized — §7 step 3 ([`RevocationEffect::Revoked`], final) or
242    /// step 4 ([`RevocationEffect::Unplaceable`], curable by a receipt).
243    /// The refusal names the revocation that fixes the boundary: its
244    /// `ctx_id`, signer, trust class, controller and T.
245    ///
246    /// Wire code: `key_not_authorized` — a revocation is a verification
247    /// verdict with no wire code of its own (RFC-ACDP-0014 §10), so a
248    /// round trip through the wire yields [`AcdpError::KeyNotAuthorized`].
249    /// [`AcdpError::is_key_not_authorized`] covers both.
250    #[error("key revoked — {0}")]
251    KeyRevoked(Box<RevocationRefusal>),
252
253    /// Wire code: `hash_mismatch`. The remote registry rejected a
254    /// publish request because its independent hash recomputation did
255    /// not match the producer-supplied `content_hash`. Distinct from
256    /// the local [`AcdpError::HashMismatch`] variant: this one carries
257    /// the registry's message verbatim and indicates a *producer-side*
258    /// bug (most often canonicalization divergence — see RFC-ACDP-0001
259    /// §5.7 and the `can-001` conformance fixture).
260    #[error("registry rejected hash_mismatch: {0}")]
261    RemoteHashMismatch(String),
262
263    /// Wire code: `data_ref_hash_mismatch`. A DataRef's fetched or decoded
264    /// bytes do not match the producer-declared `data_ref.content_hash`.
265    /// The body itself remains cryptographically valid — only the
266    /// referenced data has diverged. Distinct from
267    /// [`AcdpError::RemoteHashMismatch`] / [`AcdpError::HashMismatch`]
268    /// (body-level ProducerContent failure — the whole body is untrusted)
269    /// and [`AcdpError::InvalidSignature`] (a key / key-binding problem).
270    /// RFC-ACDP-0002 §6.5–6.6, RFC-ACDP-0007 §5.
271    #[error("data_ref hash mismatch: {0}")]
272    DataRefHashMismatch(String),
273
274    /// Signature verification failed or signature was malformed.
275    /// Wire code: `invalid_signature`.
276    #[error("invalid signature: {0}")]
277    InvalidSignature(String),
278
279    // ── DID / key resolution ─────────────────────────────────────────────
280    /// Wire code: `key_resolution_failed` (HTTP 400).
281    #[error("key resolution failed: {0}")]
282    KeyResolution(String),
283
284    /// Wire code: `key_resolution_unreachable` (HTTP 502) — transient, may retry.
285    #[error("key resolution unreachable (transient): {0}")]
286    KeyResolutionUnreachable(String),
287
288    /// Wire code: `key_not_authorized` (HTTP 403).
289    #[error("key not authorized: {0}")]
290    KeyNotAuthorized(String),
291
292    // ── Input validation ─────────────────────────────────────────────────
293    /// Producer body could not be parsed.
294    #[error("invalid body: {0}")]
295    InvalidBody(String),
296
297    /// A required field was missing.
298    #[error("missing required field: {0}")]
299    MissingField(&'static str),
300
301    /// Schema validation failed (string length, array uniqueness, oneOf, etc).
302    /// Wire code: `schema_violation`.
303    #[error("schema violation: {0}")]
304    SchemaViolation(String),
305
306    /// Wire code: `payload_too_large` — request body exceeds the registry limit.
307    #[error("payload too large: {0}")]
308    PayloadTooLarge(String),
309
310    /// Wire code: `embedded_too_large` — a single `DataRef.embedded.content`
311    /// exceeds the 64 KB cap.
312    #[error("embedded data reference too large: {0}")]
313    EmbeddedTooLarge(String),
314
315    /// Wire code: `unsupported_algorithm` — the producer used a signature
316    /// algorithm the registry does not accept.
317    #[error("unsupported algorithm: {0}")]
318    UnsupportedAlgorithm(String),
319
320    /// Wire code: `unsupported_media_type` — the request carried a body whose
321    /// `Content-Type` is outside the registry's accept-set (RFC-ACDP-0007 §4.1,
322    /// §5; HTTP 415). Added on the 0.5.0 line.
323    ///
324    /// Distinct from [`AcdpError::SchemaViolation`] on purpose: the body is
325    /// rejected *unparsed*, so no structural claim about it is made. A registry
326    /// answering `schema_violation` here would be asserting a validation that
327    /// never ran — and that code is pinned to HTTP 400. Registries advertising
328    /// `acdp_version` below 0.5.0 MUST NOT emit this code.
329    #[error("unsupported media type: {0}")]
330    UnsupportedMediaType(String),
331
332    /// Wire code: `not_implemented` — endpoint or feature not supported by
333    /// this registry.
334    #[error("not implemented: {0}")]
335    NotImplemented(String),
336
337    // ── Retrieval / authorization ────────────────────────────────────────
338    /// Wire code: `not_found`.
339    #[error("not found: {0}")]
340    NotFound(String),
341
342    /// Wire code: `not_authorized` — the caller is not permitted to access
343    /// this resource.
344    #[error("not authorized: {0}")]
345    NotAuthorized(String),
346
347    /// Wire code: `rate_limited` (HTTP 429, RFC-ACDP-0008 §4.3).
348    ///
349    /// A struct variant since 0.20.0 (was `RateLimited(String)`): it now
350    /// also carries the delay from the response's `Retry-After` header.
351    /// Match with `RateLimited { .. }`; [`AcdpError::retry_after`] reads
352    /// the delay without a match.
353    #[error("rate limited: {message}")]
354    RateLimited {
355        /// The registry's `error.message`.
356        message: String,
357        /// The delay the registry's `Retry-After` header asked for, parsed
358        /// by [`crate::time::parse_retry_after`]. `None` when the header
359        /// was absent or unparsable (a non-conformant registry), or when
360        /// the error did not come from an HTTP response (e.g.
361        /// [`AcdpError::from_wire_error`]). A 0.5.0 consumer MUST NOT
362        /// retry the refused request before this delay has elapsed; with
363        /// `None` it MUST still back off.
364        retry_after: Option<std::time::Duration>,
365    },
366
367    // ── Pagination ───────────────────────────────────────────────────────
368    /// Wire code: `cursor_expired`.
369    #[error("search cursor expired")]
370    CursorExpired,
371
372    /// Wire code: `invalid_cursor`.
373    #[error("invalid cursor: {0}")]
374    InvalidCursor(String),
375
376    // ── Publication ──────────────────────────────────────────────────────
377    /// Wire code: `superseded_target`. The supersession target was rejected;
378    /// the [`SupersessionReason`] disambiguates the cause.
379    #[error("superseded target rejected ({reason:?}): {message}")]
380    SupersededTarget {
381        /// Why the target was rejected.
382        reason: SupersessionReason,
383        /// Human-readable message from the registry.
384        message: String,
385    },
386
387    /// Wire code: `duplicate_publish` — an Idempotency-Key replay produced
388    /// a different request body than the original.
389    #[error("duplicate publish: {0}")]
390    DuplicatePublish(String),
391
392    // ── Cross-registry ───────────────────────────────────────────────────
393    /// Wire code: `cross_registry_resolution_failed`.
394    #[error("cross-registry resolution failed: {0}")]
395    CrossRegistryResolutionFailed(String),
396
397    // ── Registry receipts (ACDP 0.2, RFC-ACDP-0010) ─────────────────────
398    /// Wire code: `invalid_receipt`. A `registry_receipt` failed
399    /// verification: bad signature, a cross-check mismatch (`ctx_id`,
400    /// `content_hash`, `key_fingerprint`, serving authority), a
401    /// malformed shape, or a receipt required by policy but absent.
402    /// Permanent — the receipt will not verify on retry.
403    #[error("invalid registry receipt: {0}")]
404    InvalidReceipt(String),
405
406    /// Wire code: `invalid_log_proof` (RFC-ACDP-0012 §9, §11 — 0.3.0).
407    /// A transparency-log artifact failed verification: an inclusion
408    /// proof that does not fold to the checkpoint's root, a failed
409    /// consistency proof between tree sizes, or a checkpoint whose
410    /// signature does not verify. Permanent — a bad proof will not
411    /// verify on retry (HTTP 502 on the wire: the upstream log is at
412    /// fault when a federated resolver emits it).
413    #[error("invalid transparency-log proof: {0}")]
414    InvalidLogProof(String),
415
416    /// Wire code: `invalid_witness_cosignature` (RFC-ACDP-0015 §8,
417    /// §10 — 0.4.0). A transparency-log **witness cosignature** failed
418    /// the §8 verification procedure: closed parse, the witness-key
419    /// signature, witness binding (`signature.key_id` DID ≠
420    /// `witness_id`), checkpoint binding (`witnessed_checkpoint` ≠ the
421    /// checkpoint being evaluated), or the `witnessed_at` skew check.
422    /// Deliberately **distinct** from [`AcdpError::InvalidLogProof`]:
423    /// that indicts the *log* (tree membership, history consistency,
424    /// the registry's checkpoint signature); this indicts a *witness's*
425    /// attestation — an independent verdict over an independent signer
426    /// (RFC-ACDP-0015 §10). Permanent — a bad cosignature will not
427    /// verify on retry (HTTP 502 on the wire: the cosignature came from
428    /// an upstream party — a registry aggregating on a caller's behalf
429    /// or a resolver validating a witness's cosignatures). A cosignature
430    /// that verifies but is merely *stale* is consumer freshness policy
431    /// (§8.1), never this code.
432    #[error("invalid witness cosignature: {0}")]
433    InvalidWitnessCosignature(String),
434
435    /// Wire code: `immutable_field` (RFC-ACDP-0013 §6, §10 — 0.3.0;
436    /// activated from the v0.1.0 reservation). A lifecycle (or future
437    /// mutation) endpoint request attempted to supply or alter
438    /// immutable body content. Bodies are immutable; lifecycle
439    /// endpoints mutate registry state only. Permanent (HTTP 400).
440    #[error("immutable field: {0}")]
441    ImmutableField(String),
442
443    /// Wire code: `invalid_lifecycle_transition` (RFC-ACDP-0013 §6
444    /// step 4, §10 — 0.3.0). The requested lifecycle transition
445    /// conflicts with the context's current retraction state (retract
446    /// of an already-retracted context; republish of a never-retracted
447    /// one). A state conflict like the 409 arm of `superseded_target`;
448    /// retryable only after the state changes (HTTP 409).
449    #[error("invalid lifecycle transition: {0}")]
450    InvalidLifecycleTransition(String),
451
452    // ── Wire / transport ─────────────────────────────────────────────────
453    /// Wire code: `internal_error`.
454    #[error("registry internal error: {0}")]
455    RegistryInternal(String),
456
457    /// Catch-all for `WireError` codes that have no typed variant in this
458    /// version of the library. Forward-compatible: registries may emit
459    /// reserved codes (`unsupported_embedding_model`)
460    /// that future ACDP versions add.
461    #[error("registry returned error: {0:?}")]
462    Registry(crate::wire_error::WireError),
463
464    /// JSON (de)serialization failed.
465    #[error("serialization failed: {0}")]
466    Serialization(String),
467
468    /// HTTP transport error.
469    #[error("HTTP error: {0}")]
470    Http(String),
471}
472
473/// Sub-reason for [`AcdpError::SupersededTarget`]. Starts from the
474/// `details.reason` values enumerated by `acdp-error.schema.json`'s
475/// `superseded_target` description (`not_found`, `lineage_mismatch`,
476/// `version_mismatch`, `already_superseded`,
477/// `cross_registry_supersession_unsupported`) and adds typed reasons this
478/// library recognizes beyond what that enum currently lists
479/// (`LineageWalkFailed`, `RevocationTypeMismatch`) — it is a superset, not
480/// a mirror. `#[non_exhaustive]` plus the `Other` catch-all below is what
481/// keeps a schema addition from being a breaking change here.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483#[non_exhaustive]
484#[serde(rename_all = "snake_case")]
485pub enum SupersessionReason {
486    /// The supersedes target context does not exist on this registry.
487    NotFound,
488    /// The target's lineage_id differs from the new publication's lineage.
489    LineageMismatch,
490    /// The new version is not exactly `previous.version + 1`.
491    VersionMismatch,
492    /// The target has already been superseded by a different version.
493    AlreadySuperseded,
494    /// The target lives on a different registry; v0.1.0 only allows
495    /// same-registry supersession.
496    CrossRegistrySupersessionUnsupported,
497    /// The lineage walk through `supersedes` failed because an
498    /// intermediate context could not be retrieved (RFC-ACDP-0001 §5.6.1).
499    LineageWalkFailed,
500    /// A non-revocation context superseded a `key-revocation` (or interim
501    /// `acdp:key-revocation`) target (RFC-ACDP-0014 §4/§10). Only emitted by
502    /// registries advertising `acdp_version >= 0.5.0`; below that version
503    /// this rejection surfaces as `AcdpError::SchemaViolation` instead.
504    /// `Provisional` per `registries/error-codes.md` (the 0.5.0 line is
505    /// still Draft).
506    RevocationTypeMismatch,
507    /// A reason this version of the library does not recognize.
508    #[serde(other)]
509    Other,
510}
511
512impl AcdpError {
513    /// Build an [`AcdpError::RateLimited`].
514    pub fn rate_limited(
515        message: impl Into<String>,
516        retry_after: Option<std::time::Duration>,
517    ) -> Self {
518        AcdpError::RateLimited {
519            message: message.into(),
520            retry_after,
521        }
522    }
523
524    /// The `Retry-After` delay a [`AcdpError::RateLimited`] carries, or
525    /// `None` for every other variant and for a 429 without a usable
526    /// header. RFC-ACDP-0008 §4.3: do not retry before it elapses.
527    pub fn retry_after(&self) -> Option<std::time::Duration> {
528        match self {
529            AcdpError::RateLimited { retry_after, .. } => *retry_after,
530            _ => None,
531        }
532    }
533
534    /// Whether this error is plausibly transient and worth retrying
535    /// with the same request body (and, if applicable, the same
536    /// `Idempotency-Key`).
537    ///
538    /// Returned by [`AcdpError::is_transient`] only for variants whose
539    /// wire codes the spec marks retryable: `key_resolution_unreachable`
540    /// (RFC-ACDP-0001 §5.11), `rate_limited` (RFC-ACDP-0008 §4.3),
541    /// `cross_registry_resolution_failed` (RFC-ACDP-0006 §7), and
542    /// `internal_error` (RFC-ACDP-0007 §5). Generic `Http` transport
543    /// errors are conservatively treated as transient since they
544    /// usually mean DNS or TCP-level glitches.
545    ///
546    /// An SSRF-policy refusal is never `Http`: the DNS-time refusal raised
547    /// by `acdp_safe_http::SafeDnsResolver` (see [`SsrfDnsRefusal`]) is
548    /// classified by `From<reqwest::Error>` as the permanent
549    /// [`AcdpError::SchemaViolation`] — the same variant the URL-time
550    /// `SsrfPolicy::check_url` refusal already uses — so a retry loop such
551    /// as `RegistryClient::publish_with_retry` does not retry a policy
552    /// decision that will be identical on every attempt (issue #321).
553    /// `CrossRegistryResolver` is the one deliberate exception: RFC-ACDP-0007
554    /// §5 lists "DNS resolution refused" under
555    /// `cross_registry_resolution_failed` (fixture `fed-007`), so that
556    /// resolver re-maps the refusal to
557    /// [`AcdpError::CrossRegistryResolutionFailed`].
558    ///
559    /// All cryptographic, schema, and authorization errors are NOT
560    /// transient: a malformed body or invalid signature will not
561    /// magically validate on retry.
562    ///
563    /// [`AcdpError::RevocationUnknown`] is not a wire code; it is
564    /// transient exactly when its reason is [`UnknownReason::Transient`].
565    pub fn is_transient(&self) -> bool {
566        if let AcdpError::RevocationUnknown { reason, .. } = self {
567            return *reason == UnknownReason::Transient;
568        }
569        matches!(
570            self,
571            AcdpError::KeyResolutionUnreachable(_)
572                | AcdpError::RateLimited { .. }
573                | AcdpError::CrossRegistryResolutionFailed(_)
574                | AcdpError::RegistryInternal(_)
575                | AcdpError::Http(_)
576                | AcdpError::DeadlineExceeded(_)
577        )
578    }
579
580    /// The RFC-ACDP-0014 §8 `unknown` reason a revocation-discovery
581    /// failure carrying this error represents (0.5.0 "Discovery outcome
582    /// classes"):
583    ///
584    /// - [`UnknownReason::Truncated`] — [`AcdpError::SearchTruncated`],
585    ///   [`AcdpError::RevocationDiscoveryBudgetExceeded`],
586    ///   [`AcdpError::IncompleteLineage`] (truncation is not completeness);
587    /// - [`UnknownReason::UnverifiableCandidate`] —
588    ///   [`AcdpError::RevocationCandidateUnverifiable`];
589    /// - [`UnknownReason::Transient`] — any [`Self::is_transient`] error;
590    /// - [`UnknownReason::Unanswerable`] — everything else, notably
591    ///   `not_authorized` (403) and `not_implemented` (501) from the
592    ///   discovery vantage.
593    ///
594    /// An existing [`AcdpError::RevocationUnknown`] keeps its own reason.
595    pub fn unknown_reason(&self) -> UnknownReason {
596        match self {
597            AcdpError::RevocationUnknown { reason, .. } => *reason,
598            AcdpError::SearchTruncated(_)
599            | AcdpError::RevocationDiscoveryBudgetExceeded(_)
600            | AcdpError::IncompleteLineage { .. } => UnknownReason::Truncated,
601            AcdpError::RevocationCandidateUnverifiable(_) => UnknownReason::UnverifiableCandidate,
602            e if e.is_transient() => UnknownReason::Transient,
603            _ => UnknownReason::Unanswerable,
604        }
605    }
606
607    /// Whether this is a `key_not_authorized` verdict:
608    /// [`AcdpError::KeyNotAuthorized`] or the attributed
609    /// [`AcdpError::KeyRevoked`].
610    pub fn is_key_not_authorized(&self) -> bool {
611        matches!(
612            self,
613            AcdpError::KeyNotAuthorized(_) | AcdpError::KeyRevoked(_)
614        )
615    }
616
617    /// The attributed revocation refusal, when this is
618    /// [`AcdpError::KeyRevoked`].
619    pub fn revocation_refusal(&self) -> Option<&RevocationRefusal> {
620        match self {
621            AcdpError::KeyRevoked(r) => Some(r),
622            _ => None,
623        }
624    }
625
626    /// Whether this error is a client-local SSRF-policy refusal: an
627    /// [`AcdpError::SchemaViolation`] whose message starts with
628    /// [`SSRF_POLICY_PREFIX`].
629    ///
630    /// Covers the URL-time `SsrfPolicy::check_url` refusal, the pin-time
631    /// `SsrfPolicy::pin_resolved_ip` refusal, and the DNS-time
632    /// `SafeDnsResolver` refusal as classified by `From<reqwest::Error>`
633    /// (issue #321). Use this rather than matching message text yourself.
634    /// `WebResolver` surfaces its refusals as [`AcdpError::KeyResolution`]
635    /// and `CrossRegistryResolver` as
636    /// [`AcdpError::CrossRegistryResolutionFailed`]; neither is reported
637    /// here.
638    pub fn is_ssrf_policy_refusal(&self) -> bool {
639        matches!(self, AcdpError::SchemaViolation(m) if m.starts_with(SSRF_POLICY_PREFIX))
640    }
641
642    /// Map a wire-protocol [`crate::wire_error::WireError`] into a typed
643    /// [`AcdpError`].
644    ///
645    /// Codes the library does not yet recognize are returned as
646    /// [`AcdpError::Registry`] for forward compatibility.
647    ///
648    /// A `details.reason` that is an RFC-ACDP-0021 registry token is
649    /// kept as a `keyless:<token>: ` message prefix, which
650    /// [`crate::keyless::KeylessReason::of`] reads back (§13.3: no new
651    /// code, so no new variant).
652    pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
653        let code = wire.error.code.as_str();
654        let keyless = wire
655            .error
656            .details
657            .as_ref()
658            .and_then(|d| d.get("reason"))
659            .and_then(serde_json::Value::as_str)
660            .and_then(crate::keyless::KeylessReason::parse)
661            .filter(|r| r.is_registry_token());
662        let msg = match keyless {
663            Some(reason) => reason.tag(&wire.error.message),
664            None => wire.error.message.clone(),
665        };
666
667        match code {
668            "invalid_signature" => AcdpError::InvalidSignature(msg),
669            "hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
670            "data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
671            "schema_violation" => AcdpError::SchemaViolation(msg),
672            "not_authorized" => AcdpError::NotAuthorized(msg),
673            "not_found" => AcdpError::NotFound(msg),
674            "rate_limited" => AcdpError::RateLimited {
675                message: msg,
676                retry_after: None,
677            },
678            "payload_too_large" => AcdpError::PayloadTooLarge(msg),
679            "embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
680            "key_resolution_failed" => AcdpError::KeyResolution(msg),
681            "key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
682            "key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
683            "unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
684            "unsupported_media_type" => AcdpError::UnsupportedMediaType(msg),
685            "not_implemented" => AcdpError::NotImplemented(msg),
686            "cursor_expired" => AcdpError::CursorExpired,
687            "invalid_cursor" => AcdpError::InvalidCursor(msg),
688            "duplicate_publish" => AcdpError::DuplicatePublish(msg),
689            "cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
690            "invalid_receipt" => AcdpError::InvalidReceipt(msg),
691            "invalid_log_proof" => AcdpError::InvalidLogProof(msg),
692            "invalid_witness_cosignature" => AcdpError::InvalidWitnessCosignature(msg),
693            "immutable_field" => AcdpError::ImmutableField(msg),
694            "invalid_lifecycle_transition" => AcdpError::InvalidLifecycleTransition(msg),
695            "internal_error" => AcdpError::RegistryInternal(msg),
696            "superseded_target" => {
697                let reason = wire
698                    .error
699                    .details
700                    .as_ref()
701                    .and_then(|d| d.get("reason"))
702                    .and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
703                    .unwrap_or(SupersessionReason::Other);
704                AcdpError::SupersededTarget {
705                    reason,
706                    message: msg,
707                }
708            }
709            // Unknown / future codes pass through as the catch-all variant
710            _ => AcdpError::Registry(wire),
711        }
712    }
713}
714
715impl From<serde_json::Error> for AcdpError {
716    fn from(e: serde_json::Error) -> Self {
717        AcdpError::Serialization(e.to_string())
718    }
719}
720
721impl From<std::io::Error> for AcdpError {
722    fn from(e: std::io::Error) -> Self {
723        AcdpError::Http(format!("io error: {e}"))
724    }
725}
726
727/// Typed marker for an SSRF-policy refusal raised while resolving a
728/// hostname (RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8 DNS-rebinding
729/// protection).
730///
731/// `acdp_safe_http::SafeDnsResolver` returns this as the error of a
732/// refused resolution. reqwest wraps it (as an `io::Error` /
733/// connector error) before it reaches the caller, so it surfaces only in
734/// the `source()` chain of the resulting `reqwest::Error`;
735/// [`is_ssrf_refusal`] finds it there by downcast. Its presence is what
736/// makes `From<reqwest::Error> for AcdpError` (and `WebResolver`)
737/// classify the failure as permanent instead of as a retryable
738/// transport error.
739///
740/// Not a wire code: this never appears in a registry response body.
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub struct SsrfDnsRefusal {
743    message: String,
744}
745
746impl SsrfDnsRefusal {
747    /// Build a refusal carrying a human-readable explanation. The
748    /// message SHOULD start with [`SSRF_POLICY_PREFIX`] (every
749    /// `acdp-safe-http` refusal does) so it stays recognizable even after
750    /// a wrapper stringifies it.
751    pub fn new(message: impl Into<String>) -> Self {
752        Self {
753            message: message.into(),
754        }
755    }
756
757    /// The human-readable explanation.
758    pub fn message(&self) -> &str {
759        &self.message
760    }
761}
762
763impl std::fmt::Display for SsrfDnsRefusal {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        f.write_str(&self.message)
766    }
767}
768
769impl std::error::Error for SsrfDnsRefusal {}
770
771/// Prefix every SSRF-policy refusal message produced by this workspace
772/// starts with: the `acdp-safe-http` checks (`SsrfPolicy::check_url`,
773/// `check_ip`, `pin_resolved_ip`, `SafeDnsResolver`) and the
774/// [`AcdpError::SchemaViolation`] that `From<reqwest::Error>` builds for a
775/// refused request. Matched with `starts_with`, never `contains`, so a
776/// message that merely *mentions* the policy (a URL, an echoed registry
777/// error) does not classify.
778pub const SSRF_POLICY_PREFIX: &str = "SSRF policy:";
779
780/// Whether `err` — or any error in its `source()` chain — is an
781/// SSRF-policy refusal.
782///
783/// The single classification point shared by `From<reqwest::Error> for
784/// AcdpError`, `HttpsDataRefFetcher`, and `WebResolver` (issue #321), so
785/// the paths cannot drift. Primary detection is a downcast to the typed
786/// [`SsrfDnsRefusal`] marker; as a fallback, a chain link whose `Display`
787/// **starts with** [`SSRF_POLICY_PREFIX`] also counts (a wrapper that
788/// re-boxes the marker as a string still classifies the same way).
789pub fn is_ssrf_refusal(err: &(dyn std::error::Error + 'static)) -> bool {
790    let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err);
791    while let Some(e) = cur {
792        if e.downcast_ref::<SsrfDnsRefusal>().is_some()
793            || e.to_string().starts_with(SSRF_POLICY_PREFIX)
794        {
795            return true;
796        }
797        cur = e.source();
798    }
799    false
800}
801
802/// Render `err` followed by every error in its `source()` chain, joined by
803/// `": "`.
804///
805/// reqwest's own `Display` prints only its outermost frame (e.g. "error
806/// sending request for url (...)"); the cause it wraps — notably a
807/// `SafeDnsResolver` SSRF refusal — lives in the `source()` chain and
808/// would otherwise be silently dropped.
809pub fn error_chain_message(err: &(dyn std::error::Error + 'static)) -> String {
810    let mut msg = err.to_string();
811    let mut source = err.source();
812    while let Some(s) = source {
813        msg.push_str(": ");
814        msg.push_str(&s.to_string());
815        source = s.source();
816    }
817    msg
818}
819
820#[cfg(feature = "reqwest")]
821impl From<reqwest::Error> for AcdpError {
822    /// Classify a reqwest transport failure.
823    ///
824    /// - An SSRF-policy refusal anywhere in the `source()` chain
825    ///   ([`is_ssrf_refusal`]) → [`AcdpError::SchemaViolation`]
826    ///   (permanent; same variant as the URL-time `SsrfPolicy::check_url`
827    ///   refusal).
828    /// - A connect / timeout failure → [`AcdpError::Http`] prefixed
829    ///   `connection failed:` (transient).
830    /// - Anything else → [`AcdpError::Http`] (transient).
831    ///
832    /// In every case the message carries [`error_chain_message`], so the
833    /// underlying cause is never dropped. The SSRF message is prefixed
834    /// with [`SSRF_POLICY_PREFIX`] so [`AcdpError::is_ssrf_policy_refusal`]
835    /// recognizes it.
836    fn from(e: reqwest::Error) -> Self {
837        let msg = error_chain_message(&e);
838        if is_ssrf_refusal(&e) {
839            AcdpError::SchemaViolation(format!("{SSRF_POLICY_PREFIX} request refused: {msg}"))
840        } else if e.is_connect() || e.is_timeout() {
841            AcdpError::Http(format!("connection failed: {msg}"))
842        } else {
843            AcdpError::Http(msg)
844        }
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use crate::wire_error::{WireError, WireErrorBody};
852    use serde_json::json;
853
854    fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
855        WireError {
856            error: WireErrorBody {
857                code: code.into(),
858                message: message.into(),
859                details,
860            },
861        }
862    }
863
864    #[test]
865    fn keyless_details_reason_is_kept_on_the_existing_variant() {
866        use crate::keyless::KeylessReason;
867        let e = AcdpError::from_wire_error(wire(
868            "key_not_authorized",
869            "token audience",
870            Some(json!({"reason": "audience_mismatch"})),
871        ));
872        assert!(matches!(e, AcdpError::KeyNotAuthorized(_)));
873        assert_eq!(KeylessReason::of(&e), Some(KeylessReason::AudienceMismatch));
874        let e = AcdpError::from_wire_error(wire(
875            "not_authorized",
876            "lineage owned by another workload",
877            Some(json!({"reason": "keyless_identity_mismatch"})),
878        ));
879        assert!(matches!(e, AcdpError::NotAuthorized(_)));
880        assert_eq!(
881            KeylessReason::of(&e),
882            Some(KeylessReason::KeylessIdentityMismatch)
883        );
884        // §5.2 step 3: an unreachable issuer is the transient registry
885        // refusal a publishing client retries.
886        let e = AcdpError::from_wire_error(wire(
887            "key_resolution_unreachable",
888            "issuer JWKS fetch timed out",
889            Some(json!({"reason": "issuer_unreachable"})),
890        ));
891        assert!(matches!(e, AcdpError::KeyResolutionUnreachable(_)));
892        assert!(e.is_transient());
893        assert_eq!(
894            KeylessReason::of(&e),
895            Some(KeylessReason::IssuerUnreachable)
896        );
897        // Consumer-only tokens and unknown reasons are not registry tokens.
898        for reason in ["untrusted_attester", "something_else"] {
899            let e = AcdpError::from_wire_error(wire(
900                "key_not_authorized",
901                "m",
902                Some(json!({ "reason": reason })),
903            ));
904            assert_eq!(
905                e.to_string(),
906                AcdpError::KeyNotAuthorized("m".into()).to_string()
907            );
908        }
909    }
910
911    #[test]
912    fn all_26_wire_codes_round_trip() {
913        // Test-coverage matrix entry: "All 26 error codes parse from WireError".
914        // Every code enumerated by acdp-error.schema.json's enum MUST map to a
915        // typed AcdpError variant (or, for `superseded_target` with details,
916        // produce the right SupersessionReason).
917        type Check = fn(&AcdpError) -> bool;
918        let cases: &[(&str, Check)] = &[
919            ("invalid_signature", |e| {
920                matches!(e, AcdpError::InvalidSignature(_))
921            }),
922            ("hash_mismatch", |e| {
923                matches!(e, AcdpError::RemoteHashMismatch(_))
924            }),
925            ("data_ref_hash_mismatch", |e| {
926                matches!(e, AcdpError::DataRefHashMismatch(_))
927            }),
928            ("schema_violation", |e| {
929                matches!(e, AcdpError::SchemaViolation(_))
930            }),
931            ("not_authorized", |e| {
932                matches!(e, AcdpError::NotAuthorized(_))
933            }),
934            ("not_found", |e| matches!(e, AcdpError::NotFound(_))),
935            ("superseded_target", |e| {
936                matches!(e, AcdpError::SupersededTarget { .. })
937            }),
938            ("unsupported_algorithm", |e| {
939                matches!(e, AcdpError::UnsupportedAlgorithm(_))
940            }),
941            ("rate_limited", |e| {
942                matches!(e, AcdpError::RateLimited { .. })
943            }),
944            ("payload_too_large", |e| {
945                matches!(e, AcdpError::PayloadTooLarge(_))
946            }),
947            ("embedded_too_large", |e| {
948                matches!(e, AcdpError::EmbeddedTooLarge(_))
949            }),
950            ("key_resolution_failed", |e| {
951                matches!(e, AcdpError::KeyResolution(_))
952            }),
953            ("key_resolution_unreachable", |e| {
954                matches!(e, AcdpError::KeyResolutionUnreachable(_))
955            }),
956            ("key_not_authorized", |e| {
957                matches!(e, AcdpError::KeyNotAuthorized(_))
958            }),
959            ("not_implemented", |e| {
960                matches!(e, AcdpError::NotImplemented(_))
961            }),
962            ("unsupported_media_type", |e| {
963                matches!(e, AcdpError::UnsupportedMediaType(_))
964            }),
965            ("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
966            ("invalid_cursor", |e| {
967                matches!(e, AcdpError::InvalidCursor(_))
968            }),
969            ("duplicate_publish", |e| {
970                matches!(e, AcdpError::DuplicatePublish(_))
971            }),
972            ("cross_registry_resolution_failed", |e| {
973                matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
974            }),
975            ("invalid_receipt", |e| {
976                matches!(e, AcdpError::InvalidReceipt(_))
977            }),
978            ("invalid_log_proof", |e| {
979                matches!(e, AcdpError::InvalidLogProof(_))
980            }),
981            ("invalid_witness_cosignature", |e| {
982                matches!(e, AcdpError::InvalidWitnessCosignature(_))
983            }),
984            ("immutable_field", |e| {
985                matches!(e, AcdpError::ImmutableField(_))
986            }),
987            ("invalid_lifecycle_transition", |e| {
988                matches!(e, AcdpError::InvalidLifecycleTransition(_))
989            }),
990            ("internal_error", |e| {
991                matches!(e, AcdpError::RegistryInternal(_))
992            }),
993        ];
994        // Schema enumerates exactly 26 codes (RFC-ACDP-0007 §5 + the
995        // RFC-ACDP-0010 `invalid_receipt` addition + the 0.3.0 codes:
996        // `invalid_log_proof` (RFC-0012), `immutable_field` and
997        // `invalid_lifecycle_transition` (RFC-0013) + the 0.4.0 code
998        // `invalid_witness_cosignature` (RFC-0015) + the 0.5.0 code
999        // `unsupported_media_type` (RFC-0007 §4.1, spec #68)).
1000        //
1001        // This count is hand-maintained and therefore only catches a code this
1002        // repo forgot to *add*. The forcing function that catches the spec
1003        // growing underneath us is `wire_error_codes_cover_the_spec_enum` in
1004        // `tests/conformance.rs`, which reads the enum out of
1005        // `acdp-error.schema.json` itself.
1006        assert_eq!(cases.len(), 26);
1007        for (code, expected) in cases {
1008            let err = AcdpError::from_wire_error(wire(code, "msg", None));
1009            assert!(
1010                expected(&err),
1011                "code '{code}' did not map to its typed variant: got {err:?}"
1012            );
1013        }
1014    }
1015
1016    #[test]
1017    fn superseded_target_with_reason_details() {
1018        let w = wire(
1019            "superseded_target",
1020            "lineage mismatch",
1021            Some(json!({"reason": "lineage_mismatch"})),
1022        );
1023        match AcdpError::from_wire_error(w) {
1024            AcdpError::SupersededTarget { reason, .. } => {
1025                assert_eq!(reason, SupersessionReason::LineageMismatch);
1026            }
1027            other => panic!("expected SupersededTarget, got {other:?}"),
1028        }
1029    }
1030
1031    #[test]
1032    fn superseded_target_without_details_falls_back_to_other() {
1033        let w = wire("superseded_target", "?", None);
1034        match AcdpError::from_wire_error(w) {
1035            AcdpError::SupersededTarget { reason, .. } => {
1036                assert_eq!(reason, SupersessionReason::Other);
1037            }
1038            other => panic!("got {other:?}"),
1039        }
1040    }
1041
1042    #[test]
1043    fn unknown_code_passes_through_as_registry() {
1044        let w = wire("unsupported_embedding_model", "reserved future code", None);
1045        assert!(matches!(
1046            AcdpError::from_wire_error(w),
1047            AcdpError::Registry(_)
1048        ));
1049    }
1050
1051    /// T4 — `lineage_walk_failed` reason round-trips via WireError
1052    /// (RFC-ACDP-0001 §5.6.1).
1053    #[test]
1054    fn lineage_walk_failed_reason_roundtrip() {
1055        let w = wire(
1056            "superseded_target",
1057            "intermediate not retrievable",
1058            Some(json!({
1059                "reason": "lineage_walk_failed",
1060                "unreachable_ctx_id":
1061                    "acdp://r.example.com/12345678-1234-4321-8123-123456781234"
1062            })),
1063        );
1064        match AcdpError::from_wire_error(w) {
1065            AcdpError::SupersededTarget { reason, .. } => {
1066                assert_eq!(reason, SupersessionReason::LineageWalkFailed);
1067            }
1068            other => panic!("got {other:?}"),
1069        }
1070    }
1071
1072    /// RFC-ACDP-0014 §4 — `revocation_type_mismatch` reason round-trips via
1073    /// `WireError`, same shape as `lineage_walk_failed_reason_roundtrip`
1074    /// above. This is also a direct serde round-trip of the
1075    /// `SupersessionReason` variant itself (`to_value`/`from_value`), not
1076    /// just an assertion on the value `from_wire_error` produces.
1077    #[test]
1078    fn revocation_type_mismatch_reason_roundtrip() {
1079        assert_eq!(
1080            serde_json::to_value(SupersessionReason::RevocationTypeMismatch).unwrap(),
1081            json!("revocation_type_mismatch")
1082        );
1083        assert_eq!(
1084            serde_json::from_value::<SupersessionReason>(json!("revocation_type_mismatch"))
1085                .unwrap(),
1086            SupersessionReason::RevocationTypeMismatch
1087        );
1088
1089        let w = wire(
1090            "superseded_target",
1091            "non-revocation context cannot supersede a key-revocation target",
1092            Some(json!({"reason": "revocation_type_mismatch"})),
1093        );
1094        match AcdpError::from_wire_error(w) {
1095            AcdpError::SupersededTarget { reason, .. } => {
1096                assert_eq!(reason, SupersessionReason::RevocationTypeMismatch);
1097            }
1098            other => panic!("got {other:?}"),
1099        }
1100    }
1101
1102    /// `is_transient` covers the wire codes the spec marks retryable.
1103    #[test]
1104    fn is_transient_for_known_retryables() {
1105        assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
1106        assert!(AcdpError::rate_limited("x", None).is_transient());
1107        assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
1108        assert!(AcdpError::RegistryInternal("x".into()).is_transient());
1109        assert!(AcdpError::Http("x".into()).is_transient());
1110        assert!(AcdpError::DeadlineExceeded("x".into()).is_transient());
1111        assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
1112        assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
1113        assert!(!AcdpError::NotFound("x".into()).is_transient());
1114        // BUG-02: data-ref hash mismatch is a data-integrity failure;
1115        // retrying the SAME publish/fetch will return the same answer.
1116        // The spec marks it permanent, not retryable.
1117        assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
1118        // RFC-ACDP-0010: a failed receipt will not verify on retry.
1119        assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
1120        assert!(!AcdpError::InvalidLogProof("x".into()).is_transient());
1121        // RFC-ACDP-0015: a bad witness cosignature will not verify on retry.
1122        assert!(!AcdpError::InvalidWitnessCosignature("x".into()).is_transient());
1123        // RFC-ACDP-0007 §4.1: retrying with the same Content-Type gets the
1124        // same 415. The fix is a different request, not a later one.
1125        assert!(!AcdpError::UnsupportedMediaType("x".into()).is_transient());
1126        assert!(!AcdpError::ImmutableField("x".into()).is_transient());
1127        assert!(!AcdpError::InvalidLifecycleTransition("x".into()).is_transient());
1128        // Issue #189: context substitution is locally detected and permanent;
1129        // retrying against the same misbehaving registry will not fix it.
1130        assert!(!AcdpError::ContextIdMismatch {
1131            requested: "a".into(),
1132            served: "b".into(),
1133        }
1134        .is_transient());
1135        // Issue #226 Phase 3: an incomplete/mismatched lineage is a
1136        // locally-detected consumer-side binding failure, not a
1137        // transport hiccup — retrying will not change what the
1138        // registry serves for the same lineage_id.
1139        assert!(!AcdpError::IncompleteLineage {
1140            lineage_id: "lin:sha256:aa".into(),
1141            ctx_id: Some("acdp://r.example.com/x".into()),
1142        }
1143        .is_transient());
1144        // Issue #226 Phase 4: exhausting the search-page or lineage-walk
1145        // safety cap is a client-local safety-limit signal, not a
1146        // transport hiccup — retrying the identical query reproduces the
1147        // same truncation.
1148        assert!(!AcdpError::SearchTruncated("x".into()).is_transient());
1149        // Issue #258: exhausting a caller-configured discovery request/byte
1150        // budget is a client-local safety-limit signal, like
1151        // `SearchTruncated` above — never transient, and deliberately NOT
1152        // added to the `matches!` list in `is_transient`.
1153        assert!(!AcdpError::RevocationDiscoveryBudgetExceeded("x".into()).is_transient());
1154        assert!(!AcdpError::RevocationCandidateUnverifiable("x".into()).is_transient());
1155    }
1156
1157    /// A wrapper error with an optional source, standing in for the
1158    /// io/connector frames reqwest puts around a resolver error.
1159    #[derive(Debug)]
1160    struct Wrap(
1161        &'static str,
1162        Option<Box<dyn std::error::Error + Send + Sync>>,
1163    );
1164    impl std::fmt::Display for Wrap {
1165        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1166            f.write_str(self.0)
1167        }
1168    }
1169    impl std::error::Error for Wrap {
1170        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1171            self.1
1172                .as_deref()
1173                .map(|e| e as &(dyn std::error::Error + 'static))
1174        }
1175    }
1176
1177    /// Issue #321: the typed marker is found by downcast however deep it
1178    /// sits, independent of its message text.
1179    #[test]
1180    fn is_ssrf_refusal_finds_typed_marker_in_source_chain() {
1181        let marker = SsrfDnsRefusal::new("forbidden address (no marker text)");
1182        assert!(is_ssrf_refusal(&marker));
1183        let nested = Wrap(
1184            "error sending request",
1185            Some(Box::new(Wrap(
1186                "client error (Connect)",
1187                Some(Box::new(marker)),
1188            ))),
1189        );
1190        assert!(is_ssrf_refusal(&nested));
1191        assert_eq!(
1192            error_chain_message(&nested),
1193            "error sending request: client error (Connect): forbidden address (no marker text)"
1194        );
1195    }
1196
1197    /// Fallback: a wrapper that stringified the marker still classifies.
1198    #[test]
1199    fn is_ssrf_refusal_falls_back_to_message_text() {
1200        let stringified = Wrap(
1201            "outer",
1202            Some(Box::new(Wrap("SSRF policy: DNS answer is forbidden", None))),
1203        );
1204        assert!(is_ssrf_refusal(&stringified));
1205    }
1206
1207    /// The text fallback is anchored: a link that merely *mentions* the
1208    /// policy (e.g. an echoed URL or registry message) does not classify.
1209    #[test]
1210    fn is_ssrf_refusal_fallback_requires_prefix() {
1211        let mention = Wrap(
1212            "upstream said: SSRF policy: blocked",
1213            Some(Box::new(Wrap("see SSRF policy docs", None))),
1214        );
1215        assert!(!is_ssrf_refusal(&mention));
1216    }
1217
1218    /// `AcdpError::is_ssrf_policy_refusal` recognizes exactly the
1219    /// prefixed `SchemaViolation` shape every SSRF path produces.
1220    #[test]
1221    fn is_ssrf_policy_refusal_matches_prefixed_schema_violation_only() {
1222        assert!(
1223            AcdpError::SchemaViolation("SSRF policy: request refused: x".into())
1224                .is_ssrf_policy_refusal()
1225        );
1226        assert!(!AcdpError::SchemaViolation("title too long".into()).is_ssrf_policy_refusal());
1227        assert!(!AcdpError::SchemaViolation("note: SSRF policy: x".into()).is_ssrf_policy_refusal());
1228        assert!(!AcdpError::Http("SSRF policy: x".into()).is_ssrf_policy_refusal());
1229        assert!(!AcdpError::KeyResolution("SSRF policy: x".into()).is_ssrf_policy_refusal());
1230    }
1231
1232    /// An ordinary transport failure is NOT an SSRF refusal.
1233    #[test]
1234    fn is_ssrf_refusal_false_for_plain_transport_errors() {
1235        let plain = Wrap(
1236            "error sending request",
1237            Some(Box::new(Wrap(
1238                "tcp connect error: Connection refused",
1239                None,
1240            ))),
1241        );
1242        assert!(!is_ssrf_refusal(&plain));
1243        let io = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1244        assert!(!is_ssrf_refusal(&io));
1245    }
1246
1247    /// The variant an SSRF refusal classifies to is permanent; the
1248    /// generic transport variant stays transient.
1249    #[test]
1250    fn ssrf_refusal_classification_is_not_transient() {
1251        assert!(!AcdpError::SchemaViolation("SSRF policy: x".into()).is_transient());
1252        assert!(AcdpError::Http("connection failed: tcp reset".into()).is_transient());
1253    }
1254}