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#[derive(Debug, Error)]
15pub enum AcdpError {
16    // ── Cryptography ─────────────────────────────────────────────────────────
17    /// JCS canonicalization failed (input not serializable).
18    #[error("JCS canonicalization failed: {0}")]
19    Canonicalization(String),
20
21    /// Stored `content_hash` did not match the recomputed value
22    /// (locally detected during signature verification).
23    #[error("content_hash mismatch\n  stored:     {stored}\n  recomputed: {recomputed}")]
24    HashMismatch {
25        /// The hash claimed by the body or request.
26        stored: ContentHash,
27        /// The hash recomputed by the verifier.
28        recomputed: ContentHash,
29    },
30
31    /// Wire code: `hash_mismatch`. The remote registry rejected a
32    /// publish request because its independent hash recomputation did
33    /// not match the producer-supplied `content_hash`. Distinct from
34    /// the local [`AcdpError::HashMismatch`] variant: this one carries
35    /// the registry's message verbatim and indicates a *producer-side*
36    /// bug (most often canonicalization divergence — see RFC-ACDP-0001
37    /// §5.7 and the `can-001` conformance fixture).
38    #[error("registry rejected hash_mismatch: {0}")]
39    RemoteHashMismatch(String),
40
41    /// Wire code: `data_ref_hash_mismatch`. A DataRef's fetched or decoded
42    /// bytes do not match the producer-declared `data_ref.content_hash`.
43    /// The body itself remains cryptographically valid — only the
44    /// referenced data has diverged. Distinct from
45    /// [`AcdpError::RemoteHashMismatch`] / [`AcdpError::HashMismatch`]
46    /// (body-level ProducerContent failure — the whole body is untrusted)
47    /// and [`AcdpError::InvalidSignature`] (a key / key-binding problem).
48    /// RFC-ACDP-0002 §6.5–6.6, RFC-ACDP-0007 §5.
49    #[error("data_ref hash mismatch: {0}")]
50    DataRefHashMismatch(String),
51
52    /// Signature verification failed or signature was malformed.
53    /// Wire code: `invalid_signature`.
54    #[error("invalid signature: {0}")]
55    InvalidSignature(String),
56
57    // ── DID / key resolution ─────────────────────────────────────────────
58    /// Wire code: `key_resolution_failed` (HTTP 400).
59    #[error("key resolution failed: {0}")]
60    KeyResolution(String),
61
62    /// Wire code: `key_resolution_unreachable` (HTTP 502) — transient, may retry.
63    #[error("key resolution unreachable (transient): {0}")]
64    KeyResolutionUnreachable(String),
65
66    /// Wire code: `key_not_authorized` (HTTP 403).
67    #[error("key not authorized: {0}")]
68    KeyNotAuthorized(String),
69
70    // ── Input validation ─────────────────────────────────────────────────
71    /// Producer body could not be parsed.
72    #[error("invalid body: {0}")]
73    InvalidBody(String),
74
75    /// A required field was missing.
76    #[error("missing required field: {0}")]
77    MissingField(&'static str),
78
79    /// Schema validation failed (string length, array uniqueness, oneOf, etc).
80    /// Wire code: `schema_violation`.
81    #[error("schema violation: {0}")]
82    SchemaViolation(String),
83
84    /// Wire code: `payload_too_large` — request body exceeds the registry limit.
85    #[error("payload too large: {0}")]
86    PayloadTooLarge(String),
87
88    /// Wire code: `embedded_too_large` — a single `DataRef.embedded.content`
89    /// exceeds the 64 KB cap.
90    #[error("embedded data reference too large: {0}")]
91    EmbeddedTooLarge(String),
92
93    /// Wire code: `unsupported_algorithm` — the producer used a signature
94    /// algorithm the registry does not accept.
95    #[error("unsupported algorithm: {0}")]
96    UnsupportedAlgorithm(String),
97
98    /// Wire code: `not_implemented` — endpoint or feature not supported by
99    /// this registry.
100    #[error("not implemented: {0}")]
101    NotImplemented(String),
102
103    // ── Retrieval / authorization ────────────────────────────────────────
104    /// Wire code: `not_found`.
105    #[error("not found: {0}")]
106    NotFound(String),
107
108    /// Wire code: `not_authorized` — the caller is not permitted to access
109    /// this resource.
110    #[error("not authorized: {0}")]
111    NotAuthorized(String),
112
113    /// Wire code: `rate_limited`.
114    #[error("rate limited: {0}")]
115    RateLimited(String),
116
117    // ── Pagination ───────────────────────────────────────────────────────
118    /// Wire code: `cursor_expired`.
119    #[error("search cursor expired")]
120    CursorExpired,
121
122    /// Wire code: `invalid_cursor`.
123    #[error("invalid cursor: {0}")]
124    InvalidCursor(String),
125
126    // ── Publication ──────────────────────────────────────────────────────
127    /// Wire code: `superseded_target`. The supersession target was rejected;
128    /// the [`SupersessionReason`] disambiguates the cause.
129    #[error("superseded target rejected ({reason:?}): {message}")]
130    SupersededTarget {
131        /// Why the target was rejected.
132        reason: SupersessionReason,
133        /// Human-readable message from the registry.
134        message: String,
135    },
136
137    /// Wire code: `duplicate_publish` — an Idempotency-Key replay produced
138    /// a different request body than the original.
139    #[error("duplicate publish: {0}")]
140    DuplicatePublish(String),
141
142    // ── Cross-registry ───────────────────────────────────────────────────
143    /// Wire code: `cross_registry_resolution_failed`.
144    #[error("cross-registry resolution failed: {0}")]
145    CrossRegistryResolutionFailed(String),
146
147    // ── Registry receipts (ACDP 0.2, RFC-ACDP-0010) ─────────────────────
148    /// Wire code: `invalid_receipt`. A `registry_receipt` failed
149    /// verification: bad signature, a cross-check mismatch (`ctx_id`,
150    /// `content_hash`, `key_fingerprint`, serving authority), a
151    /// malformed shape, or a receipt required by policy but absent.
152    /// Permanent — the receipt will not verify on retry.
153    #[error("invalid registry receipt: {0}")]
154    InvalidReceipt(String),
155
156    // ── Wire / transport ─────────────────────────────────────────────────
157    /// Wire code: `internal_error`.
158    #[error("registry internal error: {0}")]
159    RegistryInternal(String),
160
161    /// Catch-all for `WireError` codes that have no typed variant in this
162    /// version of the library. Forward-compatible: registries may emit
163    /// reserved codes (`immutable_field`, `unsupported_embedding_model`)
164    /// that future ACDP versions add.
165    #[error("registry returned error: {0:?}")]
166    Registry(crate::wire_error::WireError),
167
168    /// JSON (de)serialization failed.
169    #[error("serialization failed: {0}")]
170    Serialization(String),
171
172    /// HTTP transport error.
173    #[error("HTTP error: {0}")]
174    Http(String),
175}
176
177/// Sub-reason for [`AcdpError::SupersededTarget`]. Mirrors the
178/// `details.reason` values defined by `acdp-error.schema.json`.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum SupersessionReason {
182    /// The supersedes target context does not exist on this registry.
183    NotFound,
184    /// The target's lineage_id differs from the new publication's lineage.
185    LineageMismatch,
186    /// The new version is not exactly `previous.version + 1`.
187    VersionMismatch,
188    /// The target has already been superseded by a different version.
189    AlreadySuperseded,
190    /// The target lives on a different registry; v0.1.0 only allows
191    /// same-registry supersession.
192    CrossRegistrySupersessionUnsupported,
193    /// The lineage walk through `supersedes` failed because an
194    /// intermediate context could not be retrieved (RFC-ACDP-0001 §5.6.1).
195    LineageWalkFailed,
196    /// A reason this version of the library does not recognize.
197    #[serde(other)]
198    Other,
199}
200
201impl AcdpError {
202    /// Whether this error is plausibly transient and worth retrying
203    /// with the same request body (and, if applicable, the same
204    /// `Idempotency-Key`).
205    ///
206    /// Returned by [`AcdpError::is_transient`] only for variants whose
207    /// wire codes the spec marks retryable: `key_resolution_unreachable`
208    /// (RFC-ACDP-0001 §5.11), `rate_limited` (RFC-ACDP-0008 §4.3),
209    /// `cross_registry_resolution_failed` (RFC-ACDP-0006 §7), and
210    /// `internal_error` (RFC-ACDP-0007 §5). Generic `Http` transport
211    /// errors are conservatively treated as transient since they
212    /// usually mean DNS or TCP-level glitches.
213    ///
214    /// All cryptographic, schema, and authorization errors are NOT
215    /// transient: a malformed body or invalid signature will not
216    /// magically validate on retry.
217    pub fn is_transient(&self) -> bool {
218        matches!(
219            self,
220            AcdpError::KeyResolutionUnreachable(_)
221                | AcdpError::RateLimited(_)
222                | AcdpError::CrossRegistryResolutionFailed(_)
223                | AcdpError::RegistryInternal(_)
224                | AcdpError::Http(_)
225        )
226    }
227
228    /// Map a wire-protocol [`crate::wire_error::WireError`] into a typed
229    /// [`AcdpError`].
230    ///
231    /// Codes the library does not yet recognize are returned as
232    /// [`AcdpError::Registry`] for forward compatibility.
233    pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
234        let code = wire.error.code.as_str();
235        let msg = wire.error.message.clone();
236
237        match code {
238            "invalid_signature" => AcdpError::InvalidSignature(msg),
239            "hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
240            "data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
241            "schema_violation" => AcdpError::SchemaViolation(msg),
242            "not_authorized" => AcdpError::NotAuthorized(msg),
243            "not_found" => AcdpError::NotFound(msg),
244            "rate_limited" => AcdpError::RateLimited(msg),
245            "payload_too_large" => AcdpError::PayloadTooLarge(msg),
246            "embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
247            "key_resolution_failed" => AcdpError::KeyResolution(msg),
248            "key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
249            "key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
250            "unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
251            "not_implemented" => AcdpError::NotImplemented(msg),
252            "cursor_expired" => AcdpError::CursorExpired,
253            "invalid_cursor" => AcdpError::InvalidCursor(msg),
254            "duplicate_publish" => AcdpError::DuplicatePublish(msg),
255            "cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
256            "invalid_receipt" => AcdpError::InvalidReceipt(msg),
257            "internal_error" => AcdpError::RegistryInternal(msg),
258            "superseded_target" => {
259                let reason = wire
260                    .error
261                    .details
262                    .as_ref()
263                    .and_then(|d| d.get("reason"))
264                    .and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
265                    .unwrap_or(SupersessionReason::Other);
266                AcdpError::SupersededTarget {
267                    reason,
268                    message: msg,
269                }
270            }
271            // Unknown / future codes pass through as the catch-all variant
272            _ => AcdpError::Registry(wire),
273        }
274    }
275}
276
277impl From<serde_json::Error> for AcdpError {
278    fn from(e: serde_json::Error) -> Self {
279        AcdpError::Serialization(e.to_string())
280    }
281}
282
283impl From<std::io::Error> for AcdpError {
284    fn from(e: std::io::Error) -> Self {
285        AcdpError::Http(format!("io error: {e}"))
286    }
287}
288
289#[cfg(feature = "reqwest")]
290impl From<reqwest::Error> for AcdpError {
291    fn from(e: reqwest::Error) -> Self {
292        if e.is_connect() || e.is_timeout() {
293            AcdpError::Http(format!("connection failed: {e}"))
294        } else {
295            AcdpError::Http(e.to_string())
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::wire_error::{WireError, WireErrorBody};
304    use serde_json::json;
305
306    fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
307        WireError {
308            error: WireErrorBody {
309                code: code.into(),
310                message: message.into(),
311                details,
312            },
313        }
314    }
315
316    #[test]
317    fn all_21_wire_codes_round_trip() {
318        // Test-coverage matrix entry: "All 21 error codes parse from WireError".
319        // Every code enumerated by acdp-error.schema.json's enum MUST map to a
320        // typed AcdpError variant (or, for `superseded_target` with details,
321        // produce the right SupersessionReason).
322        type Check = fn(&AcdpError) -> bool;
323        let cases: &[(&str, Check)] = &[
324            ("invalid_signature", |e| {
325                matches!(e, AcdpError::InvalidSignature(_))
326            }),
327            ("hash_mismatch", |e| {
328                matches!(e, AcdpError::RemoteHashMismatch(_))
329            }),
330            ("data_ref_hash_mismatch", |e| {
331                matches!(e, AcdpError::DataRefHashMismatch(_))
332            }),
333            ("schema_violation", |e| {
334                matches!(e, AcdpError::SchemaViolation(_))
335            }),
336            ("not_authorized", |e| {
337                matches!(e, AcdpError::NotAuthorized(_))
338            }),
339            ("not_found", |e| matches!(e, AcdpError::NotFound(_))),
340            ("superseded_target", |e| {
341                matches!(e, AcdpError::SupersededTarget { .. })
342            }),
343            ("unsupported_algorithm", |e| {
344                matches!(e, AcdpError::UnsupportedAlgorithm(_))
345            }),
346            ("rate_limited", |e| matches!(e, AcdpError::RateLimited(_))),
347            ("payload_too_large", |e| {
348                matches!(e, AcdpError::PayloadTooLarge(_))
349            }),
350            ("embedded_too_large", |e| {
351                matches!(e, AcdpError::EmbeddedTooLarge(_))
352            }),
353            ("key_resolution_failed", |e| {
354                matches!(e, AcdpError::KeyResolution(_))
355            }),
356            ("key_resolution_unreachable", |e| {
357                matches!(e, AcdpError::KeyResolutionUnreachable(_))
358            }),
359            ("key_not_authorized", |e| {
360                matches!(e, AcdpError::KeyNotAuthorized(_))
361            }),
362            ("not_implemented", |e| {
363                matches!(e, AcdpError::NotImplemented(_))
364            }),
365            ("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
366            ("invalid_cursor", |e| {
367                matches!(e, AcdpError::InvalidCursor(_))
368            }),
369            ("duplicate_publish", |e| {
370                matches!(e, AcdpError::DuplicatePublish(_))
371            }),
372            ("cross_registry_resolution_failed", |e| {
373                matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
374            }),
375            ("invalid_receipt", |e| {
376                matches!(e, AcdpError::InvalidReceipt(_))
377            }),
378            ("internal_error", |e| {
379                matches!(e, AcdpError::RegistryInternal(_))
380            }),
381        ];
382        // Schema enumerates exactly 21 codes (RFC-ACDP-0007 §5 + the
383        // RFC-ACDP-0010 `invalid_receipt` addition).
384        assert_eq!(cases.len(), 21);
385        for (code, expected) in cases {
386            let err = AcdpError::from_wire_error(wire(code, "msg", None));
387            assert!(
388                expected(&err),
389                "code '{code}' did not map to its typed variant: got {err:?}"
390            );
391        }
392    }
393
394    #[test]
395    fn superseded_target_with_reason_details() {
396        let w = wire(
397            "superseded_target",
398            "lineage mismatch",
399            Some(json!({"reason": "lineage_mismatch"})),
400        );
401        match AcdpError::from_wire_error(w) {
402            AcdpError::SupersededTarget { reason, .. } => {
403                assert_eq!(reason, SupersessionReason::LineageMismatch);
404            }
405            other => panic!("expected SupersededTarget, got {other:?}"),
406        }
407    }
408
409    #[test]
410    fn superseded_target_without_details_falls_back_to_other() {
411        let w = wire("superseded_target", "?", None);
412        match AcdpError::from_wire_error(w) {
413            AcdpError::SupersededTarget { reason, .. } => {
414                assert_eq!(reason, SupersessionReason::Other);
415            }
416            other => panic!("got {other:?}"),
417        }
418    }
419
420    #[test]
421    fn unknown_code_passes_through_as_registry() {
422        let w = wire("immutable_field", "reserved future code", None);
423        assert!(matches!(
424            AcdpError::from_wire_error(w),
425            AcdpError::Registry(_)
426        ));
427    }
428
429    /// T4 — `lineage_walk_failed` reason round-trips via WireError
430    /// (RFC-ACDP-0001 §5.6.1).
431    #[test]
432    fn lineage_walk_failed_reason_roundtrip() {
433        let w = wire(
434            "superseded_target",
435            "intermediate not retrievable",
436            Some(json!({
437                "reason": "lineage_walk_failed",
438                "unreachable_ctx_id":
439                    "acdp://r.example.com/12345678-1234-4321-8123-123456781234"
440            })),
441        );
442        match AcdpError::from_wire_error(w) {
443            AcdpError::SupersededTarget { reason, .. } => {
444                assert_eq!(reason, SupersessionReason::LineageWalkFailed);
445            }
446            other => panic!("got {other:?}"),
447        }
448    }
449
450    /// `is_transient` covers the wire codes the spec marks retryable.
451    #[test]
452    fn is_transient_for_known_retryables() {
453        assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
454        assert!(AcdpError::RateLimited("x".into()).is_transient());
455        assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
456        assert!(AcdpError::RegistryInternal("x".into()).is_transient());
457        assert!(AcdpError::Http("x".into()).is_transient());
458        assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
459        assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
460        assert!(!AcdpError::NotFound("x".into()).is_transient());
461        // BUG-02: data-ref hash mismatch is a data-integrity failure;
462        // retrying the SAME publish/fetch will return the same answer.
463        // The spec marks it permanent, not retryable.
464        assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
465        // RFC-ACDP-0010: a failed receipt will not verify on retry.
466        assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
467    }
468}