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`.
348 #[error("rate limited: {0}")]
349 RateLimited(String),
350
351 // ── Pagination ───────────────────────────────────────────────────────
352 /// Wire code: `cursor_expired`.
353 #[error("search cursor expired")]
354 CursorExpired,
355
356 /// Wire code: `invalid_cursor`.
357 #[error("invalid cursor: {0}")]
358 InvalidCursor(String),
359
360 // ── Publication ──────────────────────────────────────────────────────
361 /// Wire code: `superseded_target`. The supersession target was rejected;
362 /// the [`SupersessionReason`] disambiguates the cause.
363 #[error("superseded target rejected ({reason:?}): {message}")]
364 SupersededTarget {
365 /// Why the target was rejected.
366 reason: SupersessionReason,
367 /// Human-readable message from the registry.
368 message: String,
369 },
370
371 /// Wire code: `duplicate_publish` — an Idempotency-Key replay produced
372 /// a different request body than the original.
373 #[error("duplicate publish: {0}")]
374 DuplicatePublish(String),
375
376 // ── Cross-registry ───────────────────────────────────────────────────
377 /// Wire code: `cross_registry_resolution_failed`.
378 #[error("cross-registry resolution failed: {0}")]
379 CrossRegistryResolutionFailed(String),
380
381 // ── Registry receipts (ACDP 0.2, RFC-ACDP-0010) ─────────────────────
382 /// Wire code: `invalid_receipt`. A `registry_receipt` failed
383 /// verification: bad signature, a cross-check mismatch (`ctx_id`,
384 /// `content_hash`, `key_fingerprint`, serving authority), a
385 /// malformed shape, or a receipt required by policy but absent.
386 /// Permanent — the receipt will not verify on retry.
387 #[error("invalid registry receipt: {0}")]
388 InvalidReceipt(String),
389
390 /// Wire code: `invalid_log_proof` (RFC-ACDP-0012 §9, §11 — 0.3.0).
391 /// A transparency-log artifact failed verification: an inclusion
392 /// proof that does not fold to the checkpoint's root, a failed
393 /// consistency proof between tree sizes, or a checkpoint whose
394 /// signature does not verify. Permanent — a bad proof will not
395 /// verify on retry (HTTP 502 on the wire: the upstream log is at
396 /// fault when a federated resolver emits it).
397 #[error("invalid transparency-log proof: {0}")]
398 InvalidLogProof(String),
399
400 /// Wire code: `invalid_witness_cosignature` (RFC-ACDP-0015 §8,
401 /// §10 — 0.4.0). A transparency-log **witness cosignature** failed
402 /// the §8 verification procedure: closed parse, the witness-key
403 /// signature, witness binding (`signature.key_id` DID ≠
404 /// `witness_id`), checkpoint binding (`witnessed_checkpoint` ≠ the
405 /// checkpoint being evaluated), or the `witnessed_at` skew check.
406 /// Deliberately **distinct** from [`AcdpError::InvalidLogProof`]:
407 /// that indicts the *log* (tree membership, history consistency,
408 /// the registry's checkpoint signature); this indicts a *witness's*
409 /// attestation — an independent verdict over an independent signer
410 /// (RFC-ACDP-0015 §10). Permanent — a bad cosignature will not
411 /// verify on retry (HTTP 502 on the wire: the cosignature came from
412 /// an upstream party — a registry aggregating on a caller's behalf
413 /// or a resolver validating a witness's cosignatures). A cosignature
414 /// that verifies but is merely *stale* is consumer freshness policy
415 /// (§8.1), never this code.
416 #[error("invalid witness cosignature: {0}")]
417 InvalidWitnessCosignature(String),
418
419 /// Wire code: `immutable_field` (RFC-ACDP-0013 §6, §10 — 0.3.0;
420 /// activated from the v0.1.0 reservation). A lifecycle (or future
421 /// mutation) endpoint request attempted to supply or alter
422 /// immutable body content. Bodies are immutable; lifecycle
423 /// endpoints mutate registry state only. Permanent (HTTP 400).
424 #[error("immutable field: {0}")]
425 ImmutableField(String),
426
427 /// Wire code: `invalid_lifecycle_transition` (RFC-ACDP-0013 §6
428 /// step 4, §10 — 0.3.0). The requested lifecycle transition
429 /// conflicts with the context's current retraction state (retract
430 /// of an already-retracted context; republish of a never-retracted
431 /// one). A state conflict like the 409 arm of `superseded_target`;
432 /// retryable only after the state changes (HTTP 409).
433 #[error("invalid lifecycle transition: {0}")]
434 InvalidLifecycleTransition(String),
435
436 // ── Wire / transport ─────────────────────────────────────────────────
437 /// Wire code: `internal_error`.
438 #[error("registry internal error: {0}")]
439 RegistryInternal(String),
440
441 /// Catch-all for `WireError` codes that have no typed variant in this
442 /// version of the library. Forward-compatible: registries may emit
443 /// reserved codes (`unsupported_embedding_model`)
444 /// that future ACDP versions add.
445 #[error("registry returned error: {0:?}")]
446 Registry(crate::wire_error::WireError),
447
448 /// JSON (de)serialization failed.
449 #[error("serialization failed: {0}")]
450 Serialization(String),
451
452 /// HTTP transport error.
453 #[error("HTTP error: {0}")]
454 Http(String),
455}
456
457/// Sub-reason for [`AcdpError::SupersededTarget`]. Starts from the
458/// `details.reason` values enumerated by `acdp-error.schema.json`'s
459/// `superseded_target` description (`not_found`, `lineage_mismatch`,
460/// `version_mismatch`, `already_superseded`,
461/// `cross_registry_supersession_unsupported`) and adds typed reasons this
462/// library recognizes beyond what that enum currently lists
463/// (`LineageWalkFailed`, `RevocationTypeMismatch`) — it is a superset, not
464/// a mirror. `#[non_exhaustive]` plus the `Other` catch-all below is what
465/// keeps a schema addition from being a breaking change here.
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[non_exhaustive]
468#[serde(rename_all = "snake_case")]
469pub enum SupersessionReason {
470 /// The supersedes target context does not exist on this registry.
471 NotFound,
472 /// The target's lineage_id differs from the new publication's lineage.
473 LineageMismatch,
474 /// The new version is not exactly `previous.version + 1`.
475 VersionMismatch,
476 /// The target has already been superseded by a different version.
477 AlreadySuperseded,
478 /// The target lives on a different registry; v0.1.0 only allows
479 /// same-registry supersession.
480 CrossRegistrySupersessionUnsupported,
481 /// The lineage walk through `supersedes` failed because an
482 /// intermediate context could not be retrieved (RFC-ACDP-0001 §5.6.1).
483 LineageWalkFailed,
484 /// A non-revocation context superseded a `key-revocation` (or interim
485 /// `acdp:key-revocation`) target (RFC-ACDP-0014 §4/§10). Only emitted by
486 /// registries advertising `acdp_version >= 0.5.0`; below that version
487 /// this rejection surfaces as `AcdpError::SchemaViolation` instead.
488 /// `Provisional` per `registries/error-codes.md` (the 0.5.0 line is
489 /// still Draft).
490 RevocationTypeMismatch,
491 /// A reason this version of the library does not recognize.
492 #[serde(other)]
493 Other,
494}
495
496impl AcdpError {
497 /// Whether this error is plausibly transient and worth retrying
498 /// with the same request body (and, if applicable, the same
499 /// `Idempotency-Key`).
500 ///
501 /// Returned by [`AcdpError::is_transient`] only for variants whose
502 /// wire codes the spec marks retryable: `key_resolution_unreachable`
503 /// (RFC-ACDP-0001 §5.11), `rate_limited` (RFC-ACDP-0008 §4.3),
504 /// `cross_registry_resolution_failed` (RFC-ACDP-0006 §7), and
505 /// `internal_error` (RFC-ACDP-0007 §5). Generic `Http` transport
506 /// errors are conservatively treated as transient since they
507 /// usually mean DNS or TCP-level glitches.
508 ///
509 /// An SSRF-policy refusal is never `Http`: the DNS-time refusal raised
510 /// by `acdp_safe_http::SafeDnsResolver` (see [`SsrfDnsRefusal`]) is
511 /// classified by `From<reqwest::Error>` as the permanent
512 /// [`AcdpError::SchemaViolation`] — the same variant the URL-time
513 /// `SsrfPolicy::check_url` refusal already uses — so a retry loop such
514 /// as `RegistryClient::publish_with_retry` does not retry a policy
515 /// decision that will be identical on every attempt (issue #321).
516 /// `CrossRegistryResolver` is the one deliberate exception: RFC-ACDP-0007
517 /// §5 lists "DNS resolution refused" under
518 /// `cross_registry_resolution_failed` (fixture `fed-007`), so that
519 /// resolver re-maps the refusal to
520 /// [`AcdpError::CrossRegistryResolutionFailed`].
521 ///
522 /// All cryptographic, schema, and authorization errors are NOT
523 /// transient: a malformed body or invalid signature will not
524 /// magically validate on retry.
525 ///
526 /// [`AcdpError::RevocationUnknown`] is not a wire code; it is
527 /// transient exactly when its reason is [`UnknownReason::Transient`].
528 pub fn is_transient(&self) -> bool {
529 if let AcdpError::RevocationUnknown { reason, .. } = self {
530 return *reason == UnknownReason::Transient;
531 }
532 matches!(
533 self,
534 AcdpError::KeyResolutionUnreachable(_)
535 | AcdpError::RateLimited(_)
536 | AcdpError::CrossRegistryResolutionFailed(_)
537 | AcdpError::RegistryInternal(_)
538 | AcdpError::Http(_)
539 | AcdpError::DeadlineExceeded(_)
540 )
541 }
542
543 /// The RFC-ACDP-0014 §8 `unknown` reason a revocation-discovery
544 /// failure carrying this error represents (0.5.0 "Discovery outcome
545 /// classes"):
546 ///
547 /// - [`UnknownReason::Truncated`] — [`AcdpError::SearchTruncated`],
548 /// [`AcdpError::RevocationDiscoveryBudgetExceeded`],
549 /// [`AcdpError::IncompleteLineage`] (truncation is not completeness);
550 /// - [`UnknownReason::UnverifiableCandidate`] —
551 /// [`AcdpError::RevocationCandidateUnverifiable`];
552 /// - [`UnknownReason::Transient`] — any [`Self::is_transient`] error;
553 /// - [`UnknownReason::Unanswerable`] — everything else, notably
554 /// `not_authorized` (403) and `not_implemented` (501) from the
555 /// discovery vantage.
556 ///
557 /// An existing [`AcdpError::RevocationUnknown`] keeps its own reason.
558 pub fn unknown_reason(&self) -> UnknownReason {
559 match self {
560 AcdpError::RevocationUnknown { reason, .. } => *reason,
561 AcdpError::SearchTruncated(_)
562 | AcdpError::RevocationDiscoveryBudgetExceeded(_)
563 | AcdpError::IncompleteLineage { .. } => UnknownReason::Truncated,
564 AcdpError::RevocationCandidateUnverifiable(_) => UnknownReason::UnverifiableCandidate,
565 e if e.is_transient() => UnknownReason::Transient,
566 _ => UnknownReason::Unanswerable,
567 }
568 }
569
570 /// Whether this is a `key_not_authorized` verdict:
571 /// [`AcdpError::KeyNotAuthorized`] or the attributed
572 /// [`AcdpError::KeyRevoked`].
573 pub fn is_key_not_authorized(&self) -> bool {
574 matches!(
575 self,
576 AcdpError::KeyNotAuthorized(_) | AcdpError::KeyRevoked(_)
577 )
578 }
579
580 /// The attributed revocation refusal, when this is
581 /// [`AcdpError::KeyRevoked`].
582 pub fn revocation_refusal(&self) -> Option<&RevocationRefusal> {
583 match self {
584 AcdpError::KeyRevoked(r) => Some(r),
585 _ => None,
586 }
587 }
588
589 /// Whether this error is a client-local SSRF-policy refusal: an
590 /// [`AcdpError::SchemaViolation`] whose message starts with
591 /// [`SSRF_POLICY_PREFIX`].
592 ///
593 /// Covers the URL-time `SsrfPolicy::check_url` refusal, the pin-time
594 /// `SsrfPolicy::pin_resolved_ip` refusal, and the DNS-time
595 /// `SafeDnsResolver` refusal as classified by `From<reqwest::Error>`
596 /// (issue #321). Use this rather than matching message text yourself.
597 /// `WebResolver` surfaces its refusals as [`AcdpError::KeyResolution`]
598 /// and `CrossRegistryResolver` as
599 /// [`AcdpError::CrossRegistryResolutionFailed`]; neither is reported
600 /// here.
601 pub fn is_ssrf_policy_refusal(&self) -> bool {
602 matches!(self, AcdpError::SchemaViolation(m) if m.starts_with(SSRF_POLICY_PREFIX))
603 }
604
605 /// Map a wire-protocol [`crate::wire_error::WireError`] into a typed
606 /// [`AcdpError`].
607 ///
608 /// Codes the library does not yet recognize are returned as
609 /// [`AcdpError::Registry`] for forward compatibility.
610 pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
611 let code = wire.error.code.as_str();
612 let msg = wire.error.message.clone();
613
614 match code {
615 "invalid_signature" => AcdpError::InvalidSignature(msg),
616 "hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
617 "data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
618 "schema_violation" => AcdpError::SchemaViolation(msg),
619 "not_authorized" => AcdpError::NotAuthorized(msg),
620 "not_found" => AcdpError::NotFound(msg),
621 "rate_limited" => AcdpError::RateLimited(msg),
622 "payload_too_large" => AcdpError::PayloadTooLarge(msg),
623 "embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
624 "key_resolution_failed" => AcdpError::KeyResolution(msg),
625 "key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
626 "key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
627 "unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
628 "unsupported_media_type" => AcdpError::UnsupportedMediaType(msg),
629 "not_implemented" => AcdpError::NotImplemented(msg),
630 "cursor_expired" => AcdpError::CursorExpired,
631 "invalid_cursor" => AcdpError::InvalidCursor(msg),
632 "duplicate_publish" => AcdpError::DuplicatePublish(msg),
633 "cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
634 "invalid_receipt" => AcdpError::InvalidReceipt(msg),
635 "invalid_log_proof" => AcdpError::InvalidLogProof(msg),
636 "invalid_witness_cosignature" => AcdpError::InvalidWitnessCosignature(msg),
637 "immutable_field" => AcdpError::ImmutableField(msg),
638 "invalid_lifecycle_transition" => AcdpError::InvalidLifecycleTransition(msg),
639 "internal_error" => AcdpError::RegistryInternal(msg),
640 "superseded_target" => {
641 let reason = wire
642 .error
643 .details
644 .as_ref()
645 .and_then(|d| d.get("reason"))
646 .and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
647 .unwrap_or(SupersessionReason::Other);
648 AcdpError::SupersededTarget {
649 reason,
650 message: msg,
651 }
652 }
653 // Unknown / future codes pass through as the catch-all variant
654 _ => AcdpError::Registry(wire),
655 }
656 }
657}
658
659impl From<serde_json::Error> for AcdpError {
660 fn from(e: serde_json::Error) -> Self {
661 AcdpError::Serialization(e.to_string())
662 }
663}
664
665impl From<std::io::Error> for AcdpError {
666 fn from(e: std::io::Error) -> Self {
667 AcdpError::Http(format!("io error: {e}"))
668 }
669}
670
671/// Typed marker for an SSRF-policy refusal raised while resolving a
672/// hostname (RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8 DNS-rebinding
673/// protection).
674///
675/// `acdp_safe_http::SafeDnsResolver` returns this as the error of a
676/// refused resolution. reqwest wraps it (as an `io::Error` /
677/// connector error) before it reaches the caller, so it surfaces only in
678/// the `source()` chain of the resulting `reqwest::Error`;
679/// [`is_ssrf_refusal`] finds it there by downcast. Its presence is what
680/// makes `From<reqwest::Error> for AcdpError` (and `WebResolver`)
681/// classify the failure as permanent instead of as a retryable
682/// transport error.
683///
684/// Not a wire code: this never appears in a registry response body.
685#[derive(Debug, Clone, PartialEq, Eq)]
686pub struct SsrfDnsRefusal {
687 message: String,
688}
689
690impl SsrfDnsRefusal {
691 /// Build a refusal carrying a human-readable explanation. The
692 /// message SHOULD start with [`SSRF_POLICY_PREFIX`] (every
693 /// `acdp-safe-http` refusal does) so it stays recognizable even after
694 /// a wrapper stringifies it.
695 pub fn new(message: impl Into<String>) -> Self {
696 Self {
697 message: message.into(),
698 }
699 }
700
701 /// The human-readable explanation.
702 pub fn message(&self) -> &str {
703 &self.message
704 }
705}
706
707impl std::fmt::Display for SsrfDnsRefusal {
708 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709 f.write_str(&self.message)
710 }
711}
712
713impl std::error::Error for SsrfDnsRefusal {}
714
715/// Prefix every SSRF-policy refusal message produced by this workspace
716/// starts with: the `acdp-safe-http` checks (`SsrfPolicy::check_url`,
717/// `check_ip`, `pin_resolved_ip`, `SafeDnsResolver`) and the
718/// [`AcdpError::SchemaViolation`] that `From<reqwest::Error>` builds for a
719/// refused request. Matched with `starts_with`, never `contains`, so a
720/// message that merely *mentions* the policy (a URL, an echoed registry
721/// error) does not classify.
722pub const SSRF_POLICY_PREFIX: &str = "SSRF policy:";
723
724/// Whether `err` — or any error in its `source()` chain — is an
725/// SSRF-policy refusal.
726///
727/// The single classification point shared by `From<reqwest::Error> for
728/// AcdpError`, `HttpsDataRefFetcher`, and `WebResolver` (issue #321), so
729/// the paths cannot drift. Primary detection is a downcast to the typed
730/// [`SsrfDnsRefusal`] marker; as a fallback, a chain link whose `Display`
731/// **starts with** [`SSRF_POLICY_PREFIX`] also counts (a wrapper that
732/// re-boxes the marker as a string still classifies the same way).
733pub fn is_ssrf_refusal(err: &(dyn std::error::Error + 'static)) -> bool {
734 let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err);
735 while let Some(e) = cur {
736 if e.downcast_ref::<SsrfDnsRefusal>().is_some()
737 || e.to_string().starts_with(SSRF_POLICY_PREFIX)
738 {
739 return true;
740 }
741 cur = e.source();
742 }
743 false
744}
745
746/// Render `err` followed by every error in its `source()` chain, joined by
747/// `": "`.
748///
749/// reqwest's own `Display` prints only its outermost frame (e.g. "error
750/// sending request for url (...)"); the cause it wraps — notably a
751/// `SafeDnsResolver` SSRF refusal — lives in the `source()` chain and
752/// would otherwise be silently dropped.
753pub fn error_chain_message(err: &(dyn std::error::Error + 'static)) -> String {
754 let mut msg = err.to_string();
755 let mut source = err.source();
756 while let Some(s) = source {
757 msg.push_str(": ");
758 msg.push_str(&s.to_string());
759 source = s.source();
760 }
761 msg
762}
763
764#[cfg(feature = "reqwest")]
765impl From<reqwest::Error> for AcdpError {
766 /// Classify a reqwest transport failure.
767 ///
768 /// - An SSRF-policy refusal anywhere in the `source()` chain
769 /// ([`is_ssrf_refusal`]) → [`AcdpError::SchemaViolation`]
770 /// (permanent; same variant as the URL-time `SsrfPolicy::check_url`
771 /// refusal).
772 /// - A connect / timeout failure → [`AcdpError::Http`] prefixed
773 /// `connection failed:` (transient).
774 /// - Anything else → [`AcdpError::Http`] (transient).
775 ///
776 /// In every case the message carries [`error_chain_message`], so the
777 /// underlying cause is never dropped. The SSRF message is prefixed
778 /// with [`SSRF_POLICY_PREFIX`] so [`AcdpError::is_ssrf_policy_refusal`]
779 /// recognizes it.
780 fn from(e: reqwest::Error) -> Self {
781 let msg = error_chain_message(&e);
782 if is_ssrf_refusal(&e) {
783 AcdpError::SchemaViolation(format!("{SSRF_POLICY_PREFIX} request refused: {msg}"))
784 } else if e.is_connect() || e.is_timeout() {
785 AcdpError::Http(format!("connection failed: {msg}"))
786 } else {
787 AcdpError::Http(msg)
788 }
789 }
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795 use crate::wire_error::{WireError, WireErrorBody};
796 use serde_json::json;
797
798 fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
799 WireError {
800 error: WireErrorBody {
801 code: code.into(),
802 message: message.into(),
803 details,
804 },
805 }
806 }
807
808 #[test]
809 fn all_26_wire_codes_round_trip() {
810 // Test-coverage matrix entry: "All 26 error codes parse from WireError".
811 // Every code enumerated by acdp-error.schema.json's enum MUST map to a
812 // typed AcdpError variant (or, for `superseded_target` with details,
813 // produce the right SupersessionReason).
814 type Check = fn(&AcdpError) -> bool;
815 let cases: &[(&str, Check)] = &[
816 ("invalid_signature", |e| {
817 matches!(e, AcdpError::InvalidSignature(_))
818 }),
819 ("hash_mismatch", |e| {
820 matches!(e, AcdpError::RemoteHashMismatch(_))
821 }),
822 ("data_ref_hash_mismatch", |e| {
823 matches!(e, AcdpError::DataRefHashMismatch(_))
824 }),
825 ("schema_violation", |e| {
826 matches!(e, AcdpError::SchemaViolation(_))
827 }),
828 ("not_authorized", |e| {
829 matches!(e, AcdpError::NotAuthorized(_))
830 }),
831 ("not_found", |e| matches!(e, AcdpError::NotFound(_))),
832 ("superseded_target", |e| {
833 matches!(e, AcdpError::SupersededTarget { .. })
834 }),
835 ("unsupported_algorithm", |e| {
836 matches!(e, AcdpError::UnsupportedAlgorithm(_))
837 }),
838 ("rate_limited", |e| matches!(e, AcdpError::RateLimited(_))),
839 ("payload_too_large", |e| {
840 matches!(e, AcdpError::PayloadTooLarge(_))
841 }),
842 ("embedded_too_large", |e| {
843 matches!(e, AcdpError::EmbeddedTooLarge(_))
844 }),
845 ("key_resolution_failed", |e| {
846 matches!(e, AcdpError::KeyResolution(_))
847 }),
848 ("key_resolution_unreachable", |e| {
849 matches!(e, AcdpError::KeyResolutionUnreachable(_))
850 }),
851 ("key_not_authorized", |e| {
852 matches!(e, AcdpError::KeyNotAuthorized(_))
853 }),
854 ("not_implemented", |e| {
855 matches!(e, AcdpError::NotImplemented(_))
856 }),
857 ("unsupported_media_type", |e| {
858 matches!(e, AcdpError::UnsupportedMediaType(_))
859 }),
860 ("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
861 ("invalid_cursor", |e| {
862 matches!(e, AcdpError::InvalidCursor(_))
863 }),
864 ("duplicate_publish", |e| {
865 matches!(e, AcdpError::DuplicatePublish(_))
866 }),
867 ("cross_registry_resolution_failed", |e| {
868 matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
869 }),
870 ("invalid_receipt", |e| {
871 matches!(e, AcdpError::InvalidReceipt(_))
872 }),
873 ("invalid_log_proof", |e| {
874 matches!(e, AcdpError::InvalidLogProof(_))
875 }),
876 ("invalid_witness_cosignature", |e| {
877 matches!(e, AcdpError::InvalidWitnessCosignature(_))
878 }),
879 ("immutable_field", |e| {
880 matches!(e, AcdpError::ImmutableField(_))
881 }),
882 ("invalid_lifecycle_transition", |e| {
883 matches!(e, AcdpError::InvalidLifecycleTransition(_))
884 }),
885 ("internal_error", |e| {
886 matches!(e, AcdpError::RegistryInternal(_))
887 }),
888 ];
889 // Schema enumerates exactly 26 codes (RFC-ACDP-0007 §5 + the
890 // RFC-ACDP-0010 `invalid_receipt` addition + the 0.3.0 codes:
891 // `invalid_log_proof` (RFC-0012), `immutable_field` and
892 // `invalid_lifecycle_transition` (RFC-0013) + the 0.4.0 code
893 // `invalid_witness_cosignature` (RFC-0015) + the 0.5.0 code
894 // `unsupported_media_type` (RFC-0007 §4.1, spec #68)).
895 //
896 // This count is hand-maintained and therefore only catches a code this
897 // repo forgot to *add*. The forcing function that catches the spec
898 // growing underneath us is `wire_error_codes_cover_the_spec_enum` in
899 // `tests/conformance.rs`, which reads the enum out of
900 // `acdp-error.schema.json` itself.
901 assert_eq!(cases.len(), 26);
902 for (code, expected) in cases {
903 let err = AcdpError::from_wire_error(wire(code, "msg", None));
904 assert!(
905 expected(&err),
906 "code '{code}' did not map to its typed variant: got {err:?}"
907 );
908 }
909 }
910
911 #[test]
912 fn superseded_target_with_reason_details() {
913 let w = wire(
914 "superseded_target",
915 "lineage mismatch",
916 Some(json!({"reason": "lineage_mismatch"})),
917 );
918 match AcdpError::from_wire_error(w) {
919 AcdpError::SupersededTarget { reason, .. } => {
920 assert_eq!(reason, SupersessionReason::LineageMismatch);
921 }
922 other => panic!("expected SupersededTarget, got {other:?}"),
923 }
924 }
925
926 #[test]
927 fn superseded_target_without_details_falls_back_to_other() {
928 let w = wire("superseded_target", "?", None);
929 match AcdpError::from_wire_error(w) {
930 AcdpError::SupersededTarget { reason, .. } => {
931 assert_eq!(reason, SupersessionReason::Other);
932 }
933 other => panic!("got {other:?}"),
934 }
935 }
936
937 #[test]
938 fn unknown_code_passes_through_as_registry() {
939 let w = wire("unsupported_embedding_model", "reserved future code", None);
940 assert!(matches!(
941 AcdpError::from_wire_error(w),
942 AcdpError::Registry(_)
943 ));
944 }
945
946 /// T4 — `lineage_walk_failed` reason round-trips via WireError
947 /// (RFC-ACDP-0001 §5.6.1).
948 #[test]
949 fn lineage_walk_failed_reason_roundtrip() {
950 let w = wire(
951 "superseded_target",
952 "intermediate not retrievable",
953 Some(json!({
954 "reason": "lineage_walk_failed",
955 "unreachable_ctx_id":
956 "acdp://r.example.com/12345678-1234-4321-8123-123456781234"
957 })),
958 );
959 match AcdpError::from_wire_error(w) {
960 AcdpError::SupersededTarget { reason, .. } => {
961 assert_eq!(reason, SupersessionReason::LineageWalkFailed);
962 }
963 other => panic!("got {other:?}"),
964 }
965 }
966
967 /// RFC-ACDP-0014 §4 — `revocation_type_mismatch` reason round-trips via
968 /// `WireError`, same shape as `lineage_walk_failed_reason_roundtrip`
969 /// above. This is also a direct serde round-trip of the
970 /// `SupersessionReason` variant itself (`to_value`/`from_value`), not
971 /// just an assertion on the value `from_wire_error` produces.
972 #[test]
973 fn revocation_type_mismatch_reason_roundtrip() {
974 assert_eq!(
975 serde_json::to_value(SupersessionReason::RevocationTypeMismatch).unwrap(),
976 json!("revocation_type_mismatch")
977 );
978 assert_eq!(
979 serde_json::from_value::<SupersessionReason>(json!("revocation_type_mismatch"))
980 .unwrap(),
981 SupersessionReason::RevocationTypeMismatch
982 );
983
984 let w = wire(
985 "superseded_target",
986 "non-revocation context cannot supersede a key-revocation target",
987 Some(json!({"reason": "revocation_type_mismatch"})),
988 );
989 match AcdpError::from_wire_error(w) {
990 AcdpError::SupersededTarget { reason, .. } => {
991 assert_eq!(reason, SupersessionReason::RevocationTypeMismatch);
992 }
993 other => panic!("got {other:?}"),
994 }
995 }
996
997 /// `is_transient` covers the wire codes the spec marks retryable.
998 #[test]
999 fn is_transient_for_known_retryables() {
1000 assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
1001 assert!(AcdpError::RateLimited("x".into()).is_transient());
1002 assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
1003 assert!(AcdpError::RegistryInternal("x".into()).is_transient());
1004 assert!(AcdpError::Http("x".into()).is_transient());
1005 assert!(AcdpError::DeadlineExceeded("x".into()).is_transient());
1006 assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
1007 assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
1008 assert!(!AcdpError::NotFound("x".into()).is_transient());
1009 // BUG-02: data-ref hash mismatch is a data-integrity failure;
1010 // retrying the SAME publish/fetch will return the same answer.
1011 // The spec marks it permanent, not retryable.
1012 assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
1013 // RFC-ACDP-0010: a failed receipt will not verify on retry.
1014 assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
1015 assert!(!AcdpError::InvalidLogProof("x".into()).is_transient());
1016 // RFC-ACDP-0015: a bad witness cosignature will not verify on retry.
1017 assert!(!AcdpError::InvalidWitnessCosignature("x".into()).is_transient());
1018 // RFC-ACDP-0007 §4.1: retrying with the same Content-Type gets the
1019 // same 415. The fix is a different request, not a later one.
1020 assert!(!AcdpError::UnsupportedMediaType("x".into()).is_transient());
1021 assert!(!AcdpError::ImmutableField("x".into()).is_transient());
1022 assert!(!AcdpError::InvalidLifecycleTransition("x".into()).is_transient());
1023 // Issue #189: context substitution is locally detected and permanent;
1024 // retrying against the same misbehaving registry will not fix it.
1025 assert!(!AcdpError::ContextIdMismatch {
1026 requested: "a".into(),
1027 served: "b".into(),
1028 }
1029 .is_transient());
1030 // Issue #226 Phase 3: an incomplete/mismatched lineage is a
1031 // locally-detected consumer-side binding failure, not a
1032 // transport hiccup — retrying will not change what the
1033 // registry serves for the same lineage_id.
1034 assert!(!AcdpError::IncompleteLineage {
1035 lineage_id: "lin:sha256:aa".into(),
1036 ctx_id: Some("acdp://r.example.com/x".into()),
1037 }
1038 .is_transient());
1039 // Issue #226 Phase 4: exhausting the search-page or lineage-walk
1040 // safety cap is a client-local safety-limit signal, not a
1041 // transport hiccup — retrying the identical query reproduces the
1042 // same truncation.
1043 assert!(!AcdpError::SearchTruncated("x".into()).is_transient());
1044 // Issue #258: exhausting a caller-configured discovery request/byte
1045 // budget is a client-local safety-limit signal, like
1046 // `SearchTruncated` above — never transient, and deliberately NOT
1047 // added to the `matches!` list in `is_transient`.
1048 assert!(!AcdpError::RevocationDiscoveryBudgetExceeded("x".into()).is_transient());
1049 assert!(!AcdpError::RevocationCandidateUnverifiable("x".into()).is_transient());
1050 }
1051
1052 /// A wrapper error with an optional source, standing in for the
1053 /// io/connector frames reqwest puts around a resolver error.
1054 #[derive(Debug)]
1055 struct Wrap(
1056 &'static str,
1057 Option<Box<dyn std::error::Error + Send + Sync>>,
1058 );
1059 impl std::fmt::Display for Wrap {
1060 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1061 f.write_str(self.0)
1062 }
1063 }
1064 impl std::error::Error for Wrap {
1065 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1066 self.1
1067 .as_deref()
1068 .map(|e| e as &(dyn std::error::Error + 'static))
1069 }
1070 }
1071
1072 /// Issue #321: the typed marker is found by downcast however deep it
1073 /// sits, independent of its message text.
1074 #[test]
1075 fn is_ssrf_refusal_finds_typed_marker_in_source_chain() {
1076 let marker = SsrfDnsRefusal::new("forbidden address (no marker text)");
1077 assert!(is_ssrf_refusal(&marker));
1078 let nested = Wrap(
1079 "error sending request",
1080 Some(Box::new(Wrap(
1081 "client error (Connect)",
1082 Some(Box::new(marker)),
1083 ))),
1084 );
1085 assert!(is_ssrf_refusal(&nested));
1086 assert_eq!(
1087 error_chain_message(&nested),
1088 "error sending request: client error (Connect): forbidden address (no marker text)"
1089 );
1090 }
1091
1092 /// Fallback: a wrapper that stringified the marker still classifies.
1093 #[test]
1094 fn is_ssrf_refusal_falls_back_to_message_text() {
1095 let stringified = Wrap(
1096 "outer",
1097 Some(Box::new(Wrap("SSRF policy: DNS answer is forbidden", None))),
1098 );
1099 assert!(is_ssrf_refusal(&stringified));
1100 }
1101
1102 /// The text fallback is anchored: a link that merely *mentions* the
1103 /// policy (e.g. an echoed URL or registry message) does not classify.
1104 #[test]
1105 fn is_ssrf_refusal_fallback_requires_prefix() {
1106 let mention = Wrap(
1107 "upstream said: SSRF policy: blocked",
1108 Some(Box::new(Wrap("see SSRF policy docs", None))),
1109 );
1110 assert!(!is_ssrf_refusal(&mention));
1111 }
1112
1113 /// `AcdpError::is_ssrf_policy_refusal` recognizes exactly the
1114 /// prefixed `SchemaViolation` shape every SSRF path produces.
1115 #[test]
1116 fn is_ssrf_policy_refusal_matches_prefixed_schema_violation_only() {
1117 assert!(
1118 AcdpError::SchemaViolation("SSRF policy: request refused: x".into())
1119 .is_ssrf_policy_refusal()
1120 );
1121 assert!(!AcdpError::SchemaViolation("title too long".into()).is_ssrf_policy_refusal());
1122 assert!(!AcdpError::SchemaViolation("note: SSRF policy: x".into()).is_ssrf_policy_refusal());
1123 assert!(!AcdpError::Http("SSRF policy: x".into()).is_ssrf_policy_refusal());
1124 assert!(!AcdpError::KeyResolution("SSRF policy: x".into()).is_ssrf_policy_refusal());
1125 }
1126
1127 /// An ordinary transport failure is NOT an SSRF refusal.
1128 #[test]
1129 fn is_ssrf_refusal_false_for_plain_transport_errors() {
1130 let plain = Wrap(
1131 "error sending request",
1132 Some(Box::new(Wrap(
1133 "tcp connect error: Connection refused",
1134 None,
1135 ))),
1136 );
1137 assert!(!is_ssrf_refusal(&plain));
1138 let io = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1139 assert!(!is_ssrf_refusal(&io));
1140 }
1141
1142 /// The variant an SSRF refusal classifies to is permanent; the
1143 /// generic transport variant stays transient.
1144 #[test]
1145 fn ssrf_refusal_classification_is_not_transient() {
1146 assert!(!AcdpError::SchemaViolation("SSRF policy: x".into()).is_transient());
1147 assert!(AcdpError::Http("connection failed: tcp reset".into()).is_transient());
1148 }
1149}