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;
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    /// An SSRF-policy refusal is never `Http`: the DNS-time refusal raised
466    /// by `acdp_safe_http::SafeDnsResolver` (see [`SsrfDnsRefusal`]) is
467    /// classified by `From<reqwest::Error>` as the permanent
468    /// [`AcdpError::SchemaViolation`] — the same variant the URL-time
469    /// `SsrfPolicy::check_url` refusal already uses — so a retry loop such
470    /// as `RegistryClient::publish_with_retry` does not retry a policy
471    /// decision that will be identical on every attempt (issue #321).
472    /// `CrossRegistryResolver` is the one deliberate exception: RFC-ACDP-0007
473    /// §5 lists "DNS resolution refused" under
474    /// `cross_registry_resolution_failed` (fixture `fed-007`), so that
475    /// resolver re-maps the refusal to
476    /// [`AcdpError::CrossRegistryResolutionFailed`].
477    ///
478    /// All cryptographic, schema, and authorization errors are NOT
479    /// transient: a malformed body or invalid signature will not
480    /// magically validate on retry.
481    ///
482    /// [`AcdpError::RevocationDiscoveryFailed`] is a wrapper, not a
483    /// wire code, so it is exempted from the list above and instead
484    /// delegates to its own `source` — a retry-aware caller unwrapping
485    /// the wrapper still gets the right answer.
486    pub fn is_transient(&self) -> bool {
487        if let AcdpError::RevocationDiscoveryFailed { source } = self {
488            return source.is_transient();
489        }
490        matches!(
491            self,
492            AcdpError::KeyResolutionUnreachable(_)
493                | AcdpError::RateLimited(_)
494                | AcdpError::CrossRegistryResolutionFailed(_)
495                | AcdpError::RegistryInternal(_)
496                | AcdpError::Http(_)
497        )
498    }
499
500    /// Whether this error is a client-local SSRF-policy refusal: an
501    /// [`AcdpError::SchemaViolation`] whose message starts with
502    /// [`SSRF_POLICY_PREFIX`].
503    ///
504    /// Covers the URL-time `SsrfPolicy::check_url` refusal, the pin-time
505    /// `SsrfPolicy::pin_resolved_ip` refusal, and the DNS-time
506    /// `SafeDnsResolver` refusal as classified by `From<reqwest::Error>`
507    /// (issue #321). Use this rather than matching message text yourself.
508    /// `WebResolver` surfaces its refusals as [`AcdpError::KeyResolution`]
509    /// and `CrossRegistryResolver` as
510    /// [`AcdpError::CrossRegistryResolutionFailed`]; neither is reported
511    /// here.
512    pub fn is_ssrf_policy_refusal(&self) -> bool {
513        matches!(self, AcdpError::SchemaViolation(m) if m.starts_with(SSRF_POLICY_PREFIX))
514    }
515
516    /// Map a wire-protocol [`crate::wire_error::WireError`] into a typed
517    /// [`AcdpError`].
518    ///
519    /// Codes the library does not yet recognize are returned as
520    /// [`AcdpError::Registry`] for forward compatibility.
521    pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
522        let code = wire.error.code.as_str();
523        let msg = wire.error.message.clone();
524
525        match code {
526            "invalid_signature" => AcdpError::InvalidSignature(msg),
527            "hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
528            "data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
529            "schema_violation" => AcdpError::SchemaViolation(msg),
530            "not_authorized" => AcdpError::NotAuthorized(msg),
531            "not_found" => AcdpError::NotFound(msg),
532            "rate_limited" => AcdpError::RateLimited(msg),
533            "payload_too_large" => AcdpError::PayloadTooLarge(msg),
534            "embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
535            "key_resolution_failed" => AcdpError::KeyResolution(msg),
536            "key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
537            "key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
538            "unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
539            "unsupported_media_type" => AcdpError::UnsupportedMediaType(msg),
540            "not_implemented" => AcdpError::NotImplemented(msg),
541            "cursor_expired" => AcdpError::CursorExpired,
542            "invalid_cursor" => AcdpError::InvalidCursor(msg),
543            "duplicate_publish" => AcdpError::DuplicatePublish(msg),
544            "cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
545            "invalid_receipt" => AcdpError::InvalidReceipt(msg),
546            "invalid_log_proof" => AcdpError::InvalidLogProof(msg),
547            "invalid_witness_cosignature" => AcdpError::InvalidWitnessCosignature(msg),
548            "immutable_field" => AcdpError::ImmutableField(msg),
549            "invalid_lifecycle_transition" => AcdpError::InvalidLifecycleTransition(msg),
550            "internal_error" => AcdpError::RegistryInternal(msg),
551            "superseded_target" => {
552                let reason = wire
553                    .error
554                    .details
555                    .as_ref()
556                    .and_then(|d| d.get("reason"))
557                    .and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
558                    .unwrap_or(SupersessionReason::Other);
559                AcdpError::SupersededTarget {
560                    reason,
561                    message: msg,
562                }
563            }
564            // Unknown / future codes pass through as the catch-all variant
565            _ => AcdpError::Registry(wire),
566        }
567    }
568}
569
570impl From<serde_json::Error> for AcdpError {
571    fn from(e: serde_json::Error) -> Self {
572        AcdpError::Serialization(e.to_string())
573    }
574}
575
576impl From<std::io::Error> for AcdpError {
577    fn from(e: std::io::Error) -> Self {
578        AcdpError::Http(format!("io error: {e}"))
579    }
580}
581
582/// Typed marker for an SSRF-policy refusal raised while resolving a
583/// hostname (RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8 DNS-rebinding
584/// protection).
585///
586/// `acdp_safe_http::SafeDnsResolver` returns this as the error of a
587/// refused resolution. reqwest wraps it (as an `io::Error` /
588/// connector error) before it reaches the caller, so it surfaces only in
589/// the `source()` chain of the resulting `reqwest::Error`;
590/// [`is_ssrf_refusal`] finds it there by downcast. Its presence is what
591/// makes `From<reqwest::Error> for AcdpError` (and `WebResolver`)
592/// classify the failure as permanent instead of as a retryable
593/// transport error.
594///
595/// Not a wire code: this never appears in a registry response body.
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct SsrfDnsRefusal {
598    message: String,
599}
600
601impl SsrfDnsRefusal {
602    /// Build a refusal carrying a human-readable explanation. The
603    /// message SHOULD start with [`SSRF_POLICY_PREFIX`] (every
604    /// `acdp-safe-http` refusal does) so it stays recognizable even after
605    /// a wrapper stringifies it.
606    pub fn new(message: impl Into<String>) -> Self {
607        Self {
608            message: message.into(),
609        }
610    }
611
612    /// The human-readable explanation.
613    pub fn message(&self) -> &str {
614        &self.message
615    }
616}
617
618impl std::fmt::Display for SsrfDnsRefusal {
619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620        f.write_str(&self.message)
621    }
622}
623
624impl std::error::Error for SsrfDnsRefusal {}
625
626/// Prefix every SSRF-policy refusal message produced by this workspace
627/// starts with: the `acdp-safe-http` checks (`SsrfPolicy::check_url`,
628/// `check_ip`, `pin_resolved_ip`, `SafeDnsResolver`) and the
629/// [`AcdpError::SchemaViolation`] that `From<reqwest::Error>` builds for a
630/// refused request. Matched with `starts_with`, never `contains`, so a
631/// message that merely *mentions* the policy (a URL, an echoed registry
632/// error) does not classify.
633pub const SSRF_POLICY_PREFIX: &str = "SSRF policy:";
634
635/// Whether `err` — or any error in its `source()` chain — is an
636/// SSRF-policy refusal.
637///
638/// The single classification point shared by `From<reqwest::Error> for
639/// AcdpError`, `HttpsDataRefFetcher`, and `WebResolver` (issue #321), so
640/// the paths cannot drift. Primary detection is a downcast to the typed
641/// [`SsrfDnsRefusal`] marker; as a fallback, a chain link whose `Display`
642/// **starts with** [`SSRF_POLICY_PREFIX`] also counts (a wrapper that
643/// re-boxes the marker as a string still classifies the same way).
644pub fn is_ssrf_refusal(err: &(dyn std::error::Error + 'static)) -> bool {
645    let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err);
646    while let Some(e) = cur {
647        if e.downcast_ref::<SsrfDnsRefusal>().is_some()
648            || e.to_string().starts_with(SSRF_POLICY_PREFIX)
649        {
650            return true;
651        }
652        cur = e.source();
653    }
654    false
655}
656
657/// Render `err` followed by every error in its `source()` chain, joined by
658/// `": "`.
659///
660/// reqwest's own `Display` prints only its outermost frame (e.g. "error
661/// sending request for url (...)"); the cause it wraps — notably a
662/// `SafeDnsResolver` SSRF refusal — lives in the `source()` chain and
663/// would otherwise be silently dropped.
664pub fn error_chain_message(err: &(dyn std::error::Error + 'static)) -> String {
665    let mut msg = err.to_string();
666    let mut source = err.source();
667    while let Some(s) = source {
668        msg.push_str(": ");
669        msg.push_str(&s.to_string());
670        source = s.source();
671    }
672    msg
673}
674
675#[cfg(feature = "reqwest")]
676impl From<reqwest::Error> for AcdpError {
677    /// Classify a reqwest transport failure.
678    ///
679    /// - An SSRF-policy refusal anywhere in the `source()` chain
680    ///   ([`is_ssrf_refusal`]) → [`AcdpError::SchemaViolation`]
681    ///   (permanent; same variant as the URL-time `SsrfPolicy::check_url`
682    ///   refusal).
683    /// - A connect / timeout failure → [`AcdpError::Http`] prefixed
684    ///   `connection failed:` (transient).
685    /// - Anything else → [`AcdpError::Http`] (transient).
686    ///
687    /// In every case the message carries [`error_chain_message`], so the
688    /// underlying cause is never dropped. The SSRF message is prefixed
689    /// with [`SSRF_POLICY_PREFIX`] so [`AcdpError::is_ssrf_policy_refusal`]
690    /// recognizes it.
691    fn from(e: reqwest::Error) -> Self {
692        let msg = error_chain_message(&e);
693        if is_ssrf_refusal(&e) {
694            AcdpError::SchemaViolation(format!("{SSRF_POLICY_PREFIX} request refused: {msg}"))
695        } else if e.is_connect() || e.is_timeout() {
696            AcdpError::Http(format!("connection failed: {msg}"))
697        } else {
698            AcdpError::Http(msg)
699        }
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::wire_error::{WireError, WireErrorBody};
707    use serde_json::json;
708
709    fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
710        WireError {
711            error: WireErrorBody {
712                code: code.into(),
713                message: message.into(),
714                details,
715            },
716        }
717    }
718
719    #[test]
720    fn all_26_wire_codes_round_trip() {
721        // Test-coverage matrix entry: "All 26 error codes parse from WireError".
722        // Every code enumerated by acdp-error.schema.json's enum MUST map to a
723        // typed AcdpError variant (or, for `superseded_target` with details,
724        // produce the right SupersessionReason).
725        type Check = fn(&AcdpError) -> bool;
726        let cases: &[(&str, Check)] = &[
727            ("invalid_signature", |e| {
728                matches!(e, AcdpError::InvalidSignature(_))
729            }),
730            ("hash_mismatch", |e| {
731                matches!(e, AcdpError::RemoteHashMismatch(_))
732            }),
733            ("data_ref_hash_mismatch", |e| {
734                matches!(e, AcdpError::DataRefHashMismatch(_))
735            }),
736            ("schema_violation", |e| {
737                matches!(e, AcdpError::SchemaViolation(_))
738            }),
739            ("not_authorized", |e| {
740                matches!(e, AcdpError::NotAuthorized(_))
741            }),
742            ("not_found", |e| matches!(e, AcdpError::NotFound(_))),
743            ("superseded_target", |e| {
744                matches!(e, AcdpError::SupersededTarget { .. })
745            }),
746            ("unsupported_algorithm", |e| {
747                matches!(e, AcdpError::UnsupportedAlgorithm(_))
748            }),
749            ("rate_limited", |e| matches!(e, AcdpError::RateLimited(_))),
750            ("payload_too_large", |e| {
751                matches!(e, AcdpError::PayloadTooLarge(_))
752            }),
753            ("embedded_too_large", |e| {
754                matches!(e, AcdpError::EmbeddedTooLarge(_))
755            }),
756            ("key_resolution_failed", |e| {
757                matches!(e, AcdpError::KeyResolution(_))
758            }),
759            ("key_resolution_unreachable", |e| {
760                matches!(e, AcdpError::KeyResolutionUnreachable(_))
761            }),
762            ("key_not_authorized", |e| {
763                matches!(e, AcdpError::KeyNotAuthorized(_))
764            }),
765            ("not_implemented", |e| {
766                matches!(e, AcdpError::NotImplemented(_))
767            }),
768            ("unsupported_media_type", |e| {
769                matches!(e, AcdpError::UnsupportedMediaType(_))
770            }),
771            ("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
772            ("invalid_cursor", |e| {
773                matches!(e, AcdpError::InvalidCursor(_))
774            }),
775            ("duplicate_publish", |e| {
776                matches!(e, AcdpError::DuplicatePublish(_))
777            }),
778            ("cross_registry_resolution_failed", |e| {
779                matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
780            }),
781            ("invalid_receipt", |e| {
782                matches!(e, AcdpError::InvalidReceipt(_))
783            }),
784            ("invalid_log_proof", |e| {
785                matches!(e, AcdpError::InvalidLogProof(_))
786            }),
787            ("invalid_witness_cosignature", |e| {
788                matches!(e, AcdpError::InvalidWitnessCosignature(_))
789            }),
790            ("immutable_field", |e| {
791                matches!(e, AcdpError::ImmutableField(_))
792            }),
793            ("invalid_lifecycle_transition", |e| {
794                matches!(e, AcdpError::InvalidLifecycleTransition(_))
795            }),
796            ("internal_error", |e| {
797                matches!(e, AcdpError::RegistryInternal(_))
798            }),
799        ];
800        // Schema enumerates exactly 26 codes (RFC-ACDP-0007 §5 + the
801        // RFC-ACDP-0010 `invalid_receipt` addition + the 0.3.0 codes:
802        // `invalid_log_proof` (RFC-0012), `immutable_field` and
803        // `invalid_lifecycle_transition` (RFC-0013) + the 0.4.0 code
804        // `invalid_witness_cosignature` (RFC-0015) + the 0.5.0 code
805        // `unsupported_media_type` (RFC-0007 §4.1, spec #68)).
806        //
807        // This count is hand-maintained and therefore only catches a code this
808        // repo forgot to *add*. The forcing function that catches the spec
809        // growing underneath us is `wire_error_codes_cover_the_spec_enum` in
810        // `tests/conformance.rs`, which reads the enum out of
811        // `acdp-error.schema.json` itself.
812        assert_eq!(cases.len(), 26);
813        for (code, expected) in cases {
814            let err = AcdpError::from_wire_error(wire(code, "msg", None));
815            assert!(
816                expected(&err),
817                "code '{code}' did not map to its typed variant: got {err:?}"
818            );
819        }
820    }
821
822    #[test]
823    fn superseded_target_with_reason_details() {
824        let w = wire(
825            "superseded_target",
826            "lineage mismatch",
827            Some(json!({"reason": "lineage_mismatch"})),
828        );
829        match AcdpError::from_wire_error(w) {
830            AcdpError::SupersededTarget { reason, .. } => {
831                assert_eq!(reason, SupersessionReason::LineageMismatch);
832            }
833            other => panic!("expected SupersededTarget, got {other:?}"),
834        }
835    }
836
837    #[test]
838    fn superseded_target_without_details_falls_back_to_other() {
839        let w = wire("superseded_target", "?", None);
840        match AcdpError::from_wire_error(w) {
841            AcdpError::SupersededTarget { reason, .. } => {
842                assert_eq!(reason, SupersessionReason::Other);
843            }
844            other => panic!("got {other:?}"),
845        }
846    }
847
848    #[test]
849    fn unknown_code_passes_through_as_registry() {
850        let w = wire("unsupported_embedding_model", "reserved future code", None);
851        assert!(matches!(
852            AcdpError::from_wire_error(w),
853            AcdpError::Registry(_)
854        ));
855    }
856
857    /// T4 — `lineage_walk_failed` reason round-trips via WireError
858    /// (RFC-ACDP-0001 §5.6.1).
859    #[test]
860    fn lineage_walk_failed_reason_roundtrip() {
861        let w = wire(
862            "superseded_target",
863            "intermediate not retrievable",
864            Some(json!({
865                "reason": "lineage_walk_failed",
866                "unreachable_ctx_id":
867                    "acdp://r.example.com/12345678-1234-4321-8123-123456781234"
868            })),
869        );
870        match AcdpError::from_wire_error(w) {
871            AcdpError::SupersededTarget { reason, .. } => {
872                assert_eq!(reason, SupersessionReason::LineageWalkFailed);
873            }
874            other => panic!("got {other:?}"),
875        }
876    }
877
878    /// RFC-ACDP-0014 §4 — `revocation_type_mismatch` reason round-trips via
879    /// `WireError`, same shape as `lineage_walk_failed_reason_roundtrip`
880    /// above. This is also a direct serde round-trip of the
881    /// `SupersessionReason` variant itself (`to_value`/`from_value`), not
882    /// just an assertion on the value `from_wire_error` produces.
883    #[test]
884    fn revocation_type_mismatch_reason_roundtrip() {
885        assert_eq!(
886            serde_json::to_value(SupersessionReason::RevocationTypeMismatch).unwrap(),
887            json!("revocation_type_mismatch")
888        );
889        assert_eq!(
890            serde_json::from_value::<SupersessionReason>(json!("revocation_type_mismatch"))
891                .unwrap(),
892            SupersessionReason::RevocationTypeMismatch
893        );
894
895        let w = wire(
896            "superseded_target",
897            "non-revocation context cannot supersede a key-revocation target",
898            Some(json!({"reason": "revocation_type_mismatch"})),
899        );
900        match AcdpError::from_wire_error(w) {
901            AcdpError::SupersededTarget { reason, .. } => {
902                assert_eq!(reason, SupersessionReason::RevocationTypeMismatch);
903            }
904            other => panic!("got {other:?}"),
905        }
906    }
907
908    /// `is_transient` covers the wire codes the spec marks retryable.
909    #[test]
910    fn is_transient_for_known_retryables() {
911        assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
912        assert!(AcdpError::RateLimited("x".into()).is_transient());
913        assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
914        assert!(AcdpError::RegistryInternal("x".into()).is_transient());
915        assert!(AcdpError::Http("x".into()).is_transient());
916        assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
917        assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
918        assert!(!AcdpError::NotFound("x".into()).is_transient());
919        // BUG-02: data-ref hash mismatch is a data-integrity failure;
920        // retrying the SAME publish/fetch will return the same answer.
921        // The spec marks it permanent, not retryable.
922        assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
923        // RFC-ACDP-0010: a failed receipt will not verify on retry.
924        assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
925        assert!(!AcdpError::InvalidLogProof("x".into()).is_transient());
926        // RFC-ACDP-0015: a bad witness cosignature will not verify on retry.
927        assert!(!AcdpError::InvalidWitnessCosignature("x".into()).is_transient());
928        // RFC-ACDP-0007 §4.1: retrying with the same Content-Type gets the
929        // same 415. The fix is a different request, not a later one.
930        assert!(!AcdpError::UnsupportedMediaType("x".into()).is_transient());
931        assert!(!AcdpError::ImmutableField("x".into()).is_transient());
932        assert!(!AcdpError::InvalidLifecycleTransition("x".into()).is_transient());
933        // Issue #189: context substitution is locally detected and permanent;
934        // retrying against the same misbehaving registry will not fix it.
935        assert!(!AcdpError::ContextIdMismatch {
936            requested: "a".into(),
937            served: "b".into(),
938        }
939        .is_transient());
940        // Issue #226 Phase 3: an incomplete/mismatched lineage is a
941        // locally-detected consumer-side binding failure, not a
942        // transport hiccup — retrying will not change what the
943        // registry serves for the same lineage_id.
944        assert!(!AcdpError::IncompleteLineage {
945            lineage_id: "lin:sha256:aa".into(),
946            ctx_id: Some("acdp://r.example.com/x".into()),
947        }
948        .is_transient());
949        // Issue #226 Phase 4: exhausting the search-page or lineage-walk
950        // safety cap is a client-local safety-limit signal, not a
951        // transport hiccup — retrying the identical query reproduces the
952        // same truncation.
953        assert!(!AcdpError::SearchTruncated("x".into()).is_transient());
954        // Issue #258: exhausting a caller-configured discovery request/byte
955        // budget is a client-local safety-limit signal, like
956        // `SearchTruncated` above — never transient, and deliberately NOT
957        // added to the `matches!` list in `is_transient`.
958        assert!(!AcdpError::RevocationDiscoveryBudgetExceeded("x".into()).is_transient());
959    }
960
961    /// A wrapper error with an optional source, standing in for the
962    /// io/connector frames reqwest puts around a resolver error.
963    #[derive(Debug)]
964    struct Wrap(
965        &'static str,
966        Option<Box<dyn std::error::Error + Send + Sync>>,
967    );
968    impl std::fmt::Display for Wrap {
969        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
970            f.write_str(self.0)
971        }
972    }
973    impl std::error::Error for Wrap {
974        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
975            self.1
976                .as_deref()
977                .map(|e| e as &(dyn std::error::Error + 'static))
978        }
979    }
980
981    /// Issue #321: the typed marker is found by downcast however deep it
982    /// sits, independent of its message text.
983    #[test]
984    fn is_ssrf_refusal_finds_typed_marker_in_source_chain() {
985        let marker = SsrfDnsRefusal::new("forbidden address (no marker text)");
986        assert!(is_ssrf_refusal(&marker));
987        let nested = Wrap(
988            "error sending request",
989            Some(Box::new(Wrap(
990                "client error (Connect)",
991                Some(Box::new(marker)),
992            ))),
993        );
994        assert!(is_ssrf_refusal(&nested));
995        assert_eq!(
996            error_chain_message(&nested),
997            "error sending request: client error (Connect): forbidden address (no marker text)"
998        );
999    }
1000
1001    /// Fallback: a wrapper that stringified the marker still classifies.
1002    #[test]
1003    fn is_ssrf_refusal_falls_back_to_message_text() {
1004        let stringified = Wrap(
1005            "outer",
1006            Some(Box::new(Wrap("SSRF policy: DNS answer is forbidden", None))),
1007        );
1008        assert!(is_ssrf_refusal(&stringified));
1009    }
1010
1011    /// The text fallback is anchored: a link that merely *mentions* the
1012    /// policy (e.g. an echoed URL or registry message) does not classify.
1013    #[test]
1014    fn is_ssrf_refusal_fallback_requires_prefix() {
1015        let mention = Wrap(
1016            "upstream said: SSRF policy: blocked",
1017            Some(Box::new(Wrap("see SSRF policy docs", None))),
1018        );
1019        assert!(!is_ssrf_refusal(&mention));
1020    }
1021
1022    /// `AcdpError::is_ssrf_policy_refusal` recognizes exactly the
1023    /// prefixed `SchemaViolation` shape every SSRF path produces.
1024    #[test]
1025    fn is_ssrf_policy_refusal_matches_prefixed_schema_violation_only() {
1026        assert!(
1027            AcdpError::SchemaViolation("SSRF policy: request refused: x".into())
1028                .is_ssrf_policy_refusal()
1029        );
1030        assert!(!AcdpError::SchemaViolation("title too long".into()).is_ssrf_policy_refusal());
1031        assert!(!AcdpError::SchemaViolation("note: SSRF policy: x".into()).is_ssrf_policy_refusal());
1032        assert!(!AcdpError::Http("SSRF policy: x".into()).is_ssrf_policy_refusal());
1033        assert!(!AcdpError::KeyResolution("SSRF policy: x".into()).is_ssrf_policy_refusal());
1034    }
1035
1036    /// An ordinary transport failure is NOT an SSRF refusal.
1037    #[test]
1038    fn is_ssrf_refusal_false_for_plain_transport_errors() {
1039        let plain = Wrap(
1040            "error sending request",
1041            Some(Box::new(Wrap(
1042                "tcp connect error: Connection refused",
1043                None,
1044            ))),
1045        );
1046        assert!(!is_ssrf_refusal(&plain));
1047        let io = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1048        assert!(!is_ssrf_refusal(&io));
1049    }
1050
1051    /// The variant an SSRF refusal classifies to is permanent; the
1052    /// generic transport variant stays transient.
1053    #[test]
1054    fn ssrf_refusal_classification_is_not_transient() {
1055        assert!(!AcdpError::SchemaViolation("SSRF policy: x".into()).is_transient());
1056        assert!(AcdpError::Http("connection failed: tcp reset".into()).is_transient());
1057    }
1058}