Skip to main content

content_addressable/
error.rs

1//! Typed errors for content-addressing operations.
2//!
3//! Every fallible operation in this crate returns [`ContentError`]. We use
4//! [`thiserror`] (not `anyhow`) so callers can match on the exact failure mode
5//! — encoding, decoding, verification, non-canonical input, or CID parsing —
6//! and so the error type stays part of the crate's public contract.
7//!
8//! # Operation → variant map (FROZEN at 0.1.0)
9//!
10//! This table is the legible contract: which operation can produce which
11//! variant. It is part of the frozen `0.1.0` error surface (README gate item
12//! #7) — adding a *new* operation/variant pair later is allowed (the enum is
13//! `#[non_exhaustive]`), but the rows below will not change meaning.
14//!
15//! | Operation | Variant(s) it can return |
16//! |-----------|--------------------------|
17//! | [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor) | [`EncodingError`](ContentError::EncodingError) |
18//! | [`from_canonical_dagcbor`](crate::canonical::from_canonical_dagcbor) | [`DecodingError`](ContentError::DecodingError) |
19//! | [`ContentId::from_canonical_bytes_checked`](crate::ContentId::from_canonical_bytes_checked) | [`DecodingError`](ContentError::DecodingError) (not dag-cbor), [`NonCanonical`](ContentError::NonCanonical) (valid but non-canonical), [`EncodingError`](ContentError::EncodingError) (re-encode failed) |
20//! | [`ContentId::from_bytes`](crate::ContentId::from_bytes) / [`FromStr`](core::str::FromStr) / [`TryFrom<Cid>`](crate::ContentId) / binary `Deserialize` | [`InvalidCid`](ContentError::InvalidCid) (not a CID at all) or [`InvalidCidProfile`](ContentError::InvalidCidProfile) (a valid CID that is not the frozen profile) |
21//! | [`content_id`](crate::ContentAddressable::content_id) | propagates `canonical_form`'s error only (typically [`EncodingError`](ContentError::EncodingError)) |
22//! | [`verify`](crate::ContentAddressable::verify) | propagates [`content_id`](crate::ContentAddressable::content_id) only; a *mismatch* is `Ok(false)`, never an `Err` |
23//! | [`ensure_content_id`](crate::ContentAddressable::ensure_content_id) | propagates [`content_id`](crate::ContentAddressable::content_id), plus [`VerificationFailed`](ContentError::VerificationFailed) on mismatch |
24//! | [`from_canonical_bytes`](crate::ContentId::from_canonical_bytes) / [`from_blake3_content_digest`](crate::ContentId::from_blake3_content_digest) | infallible — never returns an error |
25//!
26//! # Frozen freeze decisions (0.1.0)
27//!
28//! - **`#[non_exhaustive]` is retained on purpose** so a future variant (e.g. an
29//!   `UnsupportedCodec` / `DigestLength`) can be *added* without a major version
30//!   bump. Callers already must carry a `_ => …` arm. Do **not** remove it to
31//!   chase exhaustive matching — that would forfeit additive evolution.
32//! - **No `#[from]` impls.** Auto-`From` conversions are deliberately *not*
33//!   provided: a `#[from]` bakes the source type into the public contract just as
34//!   firmly as a field, and the two codec sources are exactly the unstable
35//!   `serde_ipld_dagcbor` generics we hide behind a boxed `dyn Error` (see
36//!   below). Construction stays explicit via `map_err` at the call sites. Do not
37//!   "helpfully" add a `#[from]` — it re-leaks the generics into the frozen API.
38//! - **The codec source types are boxed.** `EncodingError`/`DecodingError` carry
39//!   their `#[source]` as a `Box<dyn std::error::Error + Send + Sync + 'static>`
40//!   rather than the concrete `serde_ipld_dagcbor::EncodeError<…>` /
41//!   `DecodeError<…>`. This keeps `.source()`/`{source}` diagnostics while
42//!   decoupling the frozen signature from a codec-crate patch bump that changes
43//!   those generics.
44
45use thiserror::Error;
46
47/// The error type returned by all fallible operations in this crate.
48///
49/// Variants are intentionally coarse: they describe *which stage* failed
50/// (serialize, deserialize, verify, non-canonical, parse) rather than mirroring
51/// every underlying library error one-to-one. Where a lower-level error is
52/// available it is preserved in the `source` field for diagnostics.
53///
54/// # Stability (FROZEN at 0.1.0)
55///
56/// This enum is `#[non_exhaustive]` as a **frozen decision**: it lets a new
57/// variant be *added* in a future `0.1.x` release without a major version bump,
58/// while the four (now five) names and their fields below are a stability
59/// contract. See the [module docs](self) for the full freeze rationale (no
60/// `#[from]`, boxed codec sources, the operation→variant map) and a compile-time
61/// `Send + Sync + 'static` guard locking `ContentError`'s thread-portability.
62#[derive(Debug, Error)]
63#[non_exhaustive]
64pub enum ContentError {
65    /// Canonical dag-cbor encoding of a value failed.
66    ///
67    /// Returned by
68    /// [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor) (and so
69    /// propagated by [`content_id`](crate::ContentAddressable::content_id) /
70    /// [`verify`](crate::ContentAddressable::verify) via `canonical_form`). The
71    /// `source` is boxed as a `dyn Error` so the concrete
72    /// `serde_ipld_dagcbor::EncodeError<…>` generic does not leak into this
73    /// frozen signature.
74    #[error("failed to encode value as canonical dag-cbor: {source}")]
75    EncodingError {
76        /// The underlying codec error, type-erased.
77        #[source]
78        source: Box<dyn std::error::Error + Send + Sync + 'static>,
79    },
80
81    /// Decoding canonical dag-cbor bytes back into a value failed.
82    ///
83    /// Returned by
84    /// [`from_canonical_dagcbor`](crate::canonical::from_canonical_dagcbor) and
85    /// by [`from_canonical_bytes_checked`](crate::ContentId::from_canonical_bytes_checked)
86    /// when the input bytes are not dag-cbor at all. The `source` is boxed as a
87    /// `dyn Error` so the concrete `serde_ipld_dagcbor::DecodeError<…>` generic
88    /// does not leak into this frozen signature.
89    #[error("failed to decode value from canonical dag-cbor: {source}")]
90    DecodingError {
91        /// The underlying codec error, type-erased.
92        #[source]
93        source: Box<dyn std::error::Error + Send + Sync + 'static>,
94    },
95
96    /// A [`crate::ContentId`] did not match the expected one.
97    ///
98    /// This is the heart of the doctrine: the data did not carry the proof it
99    /// claimed to. The recomputed identity differs from the expected identity.
100    ///
101    /// # Frozen `verify` ruling (0.1.0)
102    ///
103    /// [`verify`](crate::ContentAddressable::verify) returns `Ok(false)` on a
104    /// mismatch — a mismatch is **not** an `Err` (a negative answer to "do these
105    /// match?" is a successful, expected result, not a failure to check). This
106    /// is a **frozen** part of the `0.1.0` API surface (README gate item #8).
107    /// This variant is the *escalation* of that boolean: it is constructed by
108    /// [`ensure_content_id`](crate::ContentAddressable::ensure_content_id), the
109    /// strict helper that turns a mismatch into an `Err` for `?`-propagation.
110    /// Its `expected` / `computed` fields are the two ids' [`Display`] strings
111    /// (multibase base32-lower `b…`).
112    ///
113    /// [`Display`]: core::fmt::Display
114    #[error("content verification failed: expected {expected}, computed {computed}")]
115    VerificationFailed {
116        /// The identity the caller expected, as a [`ContentId`](crate::ContentId)
117        /// [`Display`](core::fmt::Display) string.
118        expected: String,
119        /// The identity actually computed from the bytes, as a
120        /// [`ContentId`](crate::ContentId) [`Display`](core::fmt::Display) string.
121        computed: String,
122    },
123
124    /// Bytes presented as canonical dag-cbor were valid CBOR but **not
125    /// canonical** dag-cbor.
126    ///
127    /// Returned only by the opt-in
128    /// [`from_canonical_bytes_checked`](crate::ContentId::from_canonical_bytes_checked)
129    /// when the input decodes as an [`Ipld`](ipld_core::ipld::Ipld) value but
130    /// re-encoding it does **not** reproduce the input bytes (wrong map-key
131    /// order, indefinite-length items, non-smallest integers, …). The fast
132    /// [`from_canonical_bytes`](crate::ContentId::from_canonical_bytes) primitive
133    /// never produces this — it trusts its precondition and only hashes.
134    #[error(
135        "bytes are valid CBOR but not canonical dag-cbor (re-encoding differs from the input)"
136    )]
137    NonCanonical,
138
139    /// A CID could not be parsed from a string or from bytes.
140    ///
141    /// Returned by [`ContentId::from_bytes`](crate::ContentId::from_bytes) and
142    /// the [`FromStr`](core::str::FromStr) impl. `reason` is a stable
143    /// human-readable string; the underlying `cid::Error` is preserved in
144    /// `source` so the error chain (`.source()`) is not stripped at this
145    /// boundary.
146    #[error("invalid CID: {reason}")]
147    InvalidCid {
148        /// Human-readable description of why the CID is invalid.
149        reason: String,
150        /// The underlying CID parse error, preserved for `.source()` chaining.
151        #[source]
152        source: Box<dyn std::error::Error + Send + Sync + 'static>,
153    },
154
155    /// A syntactically-valid CID was parsed, but it is **not** this crate's frozen
156    /// profile: CIDv1 + dag-cbor (`0x71`) + BLAKE3 (`0x1e`) + a 32-byte digest.
157    ///
158    /// Every ingress path — [`from_bytes`](crate::ContentId::from_bytes), the
159    /// [`FromStr`](core::str::FromStr) impl, [`TryFrom<Cid>`](crate::ContentId), and
160    /// the binary/IPLD `Deserialize` — rejects a foreign CID with this, so *every*
161    /// `ContentId` (however it entered) carries the fixed profile the presentation
162    /// accessors ([`digest_bytes`](crate::ContentId::digest_bytes) etc.) rely on.
163    /// A policy rejection, so — like [`NonCanonical`](ContentError::NonCanonical) —
164    /// it carries no underlying `source`; `reason` names the parameter that was off.
165    #[error("CID is not the content-addressable profile: {reason}")]
166    InvalidCidProfile {
167        /// Which profile parameter was wrong (version / codec / hash / digest length).
168        reason: String,
169    },
170}
171
172#[cfg(test)]
173mod tests {
174    use super::ContentError;
175
176    /// Compile-time lock: `ContentError` is `Send + Sync + 'static`, so it stays
177    /// thread-portable across the freeze (README gate item #7). If a future field
178    /// (e.g. a non-`Send` source) broke this, the crate would fail to compile
179    /// here rather than silently narrowing the public contract.
180    #[test]
181    fn content_error_is_send_sync_static() {
182        fn assert_send_sync_static<T: Send + Sync + 'static>() {}
183        assert_send_sync_static::<ContentError>();
184    }
185
186    /// Regression guard against future source-stripping (README gate item #7):
187    /// `EncodingError`, `DecodingError`, and `InvalidCid` must each carry a live
188    /// `#[source]`, so `std::error::Error::source` returns `Some(_)` and the
189    /// error chain is walkable. `VerificationFailed` / `NonCanonical` carry no
190    /// source by design.
191    #[test]
192    fn source_chain_is_preserved_for_sourced_variants() {
193        use std::error::Error as _;
194
195        // A genuine decode error (empty input is not a complete dag-cbor item).
196        let decode_err = crate::canonical::from_canonical_dagcbor::<u64>(&[])
197            .expect_err("empty input must fail to decode");
198        assert!(
199            matches!(decode_err, ContentError::DecodingError { .. }),
200            "empty bytes must decode-fail"
201        );
202        assert!(
203            decode_err.source().is_some(),
204            "DecodingError must preserve its underlying codec source"
205        );
206
207        // A genuine encode error: dag-cbor forbids non-finite floats, so NaN
208        // fails to encode.
209        let encode_err = crate::canonical::to_canonical_dagcbor(&f64::NAN)
210            .expect_err("dag-cbor must reject NaN");
211        assert!(
212            matches!(encode_err, ContentError::EncodingError { .. }),
213            "NaN must encode-fail"
214        );
215        assert!(
216            encode_err.source().is_some(),
217            "EncodingError must preserve its underlying codec source"
218        );
219
220        // A genuine CID parse error keeps the underlying cid::Error as a source.
221        let cid_err = crate::ContentId::from_bytes(&[0xff, 0xff, 0xff])
222            .expect_err("garbage must not parse as a CID");
223        assert!(
224            matches!(cid_err, ContentError::InvalidCid { .. }),
225            "garbage must CID-parse-fail"
226        );
227        assert!(
228            cid_err.source().is_some(),
229            "InvalidCid must preserve the underlying cid::Error as a source"
230        );
231    }
232}