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//! Rows added since the freeze (the additive path this note reserves):
16//! [`from_canonical_dagcbor_checked`](crate::canonical::from_canonical_dagcbor_checked)
17//! and
18//! [`ContentAddressable::from_canonical_form`](crate::ContentAddressable::from_canonical_form)
19//! in `0.1.2` (issue #90), which is also what introduced
20//! [`LossyDecode`](ContentError::LossyDecode) — the first exercise of the
21//! `#[non_exhaustive]` decision below, and exactly the case it was kept for.
22//! ([`from_canonical_form`](crate::ContentAddressable::from_canonical_form) is additionally the one deliberate stability exception
23//! `0.1.2` makes to the frozen `0.1.x` Rust API; see `docs/STABILITY.md`. The
24//! error surface itself is unchanged apart from the added variant.)
25//!
26//! | Operation | Variant(s) it can return |
27//! |-----------|--------------------------|
28//! | [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor) | [`EncodingError`](ContentError::EncodingError) |
29//! | [`from_canonical_dagcbor`](crate::canonical::from_canonical_dagcbor) (**deprecated** `0.1.2`) | [`DecodingError`](ContentError::DecodingError) — and nothing else, which is the defect that deprecated it: it verifies neither canonicality nor the typed round trip |
30//! | [`from_canonical_dagcbor_checked`](crate::canonical::from_canonical_dagcbor_checked) | [`DecodingError`](ContentError::DecodingError) (not dag-cbor, or not a `T`), [`NonCanonical`](ContentError::NonCanonical) (valid but non-canonical bytes), [`LossyDecode`](ContentError::LossyDecode) (the decoded value's [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor) differs from the input), [`EncodingError`](ContentError::EncodingError) (a re-encode failed) |
31//! | [`ContentAddressable::from_canonical_form`](crate::ContentAddressable::from_canonical_form) | the same four, with the last two arising from [`canonical_form`](crate::ContentAddressable::canonical_form) rather than from [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor) — plus anything else a custom [`canonical_form`](crate::ContentAddressable::canonical_form) returns, propagated verbatim |
32//! | [`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) |
33//! | [`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) |
34//! | [`content_id`](crate::ContentAddressable::content_id) | propagates [`canonical_form`](crate::ContentAddressable::canonical_form)'s error only (typically [`EncodingError`](ContentError::EncodingError)) |
35//! | [`verify`](crate::ContentAddressable::verify) | propagates [`content_id`](crate::ContentAddressable::content_id) only; a *mismatch* is `Ok(false)`, never an `Err` |
36//! | [`ensure_content_id`](crate::ContentAddressable::ensure_content_id) | propagates [`content_id`](crate::ContentAddressable::content_id), plus [`VerificationFailed`](ContentError::VerificationFailed) on mismatch |
37//! | [`from_canonical_bytes`](crate::ContentId::from_canonical_bytes) / [`from_blake3_content_digest`](crate::ContentId::from_blake3_content_digest) | infallible — never returns an error |
38//!
39//! # Frozen freeze decisions (0.1.0)
40//!
41//! - **`#[non_exhaustive]` is retained on purpose** so a future variant (e.g. an
42//!   `UnsupportedCodec` / `DigestLength`) can be *added* without a major version
43//!   bump. Callers already must carry a `_ => …` arm. Do **not** remove it to
44//!   chase exhaustive matching — that would forfeit additive evolution.
45//! - **No `#[from]` impls.** Auto-`From` conversions are deliberately *not*
46//!   provided: a `#[from]` bakes the source type into the public contract just as
47//!   firmly as a field, and the two codec sources are exactly the unstable
48//!   `serde_ipld_dagcbor` generics we hide behind a boxed `dyn Error` (see
49//!   below). Construction stays explicit via `map_err` at the call sites. Do not
50//!   "helpfully" add a `#[from]` — it re-leaks the generics into the frozen API.
51//! - **The codec source types are boxed.** `EncodingError`/`DecodingError` carry
52//!   their `#[source]` as a `Box<dyn std::error::Error + Send + Sync + 'static>`
53//!   rather than the concrete `serde_ipld_dagcbor::EncodeError<…>` /
54//!   `DecodeError<…>`. This keeps `.source()`/`{source}` diagnostics while
55//!   decoupling the frozen signature from a codec-crate patch bump that changes
56//!   those generics.
57
58use thiserror::Error;
59
60/// The error type returned by all fallible operations in this crate.
61///
62/// Variants are intentionally coarse: they describe *which stage* failed
63/// (serialize, deserialize, verify, non-canonical, parse) rather than mirroring
64/// every underlying library error one-to-one. Where a lower-level error is
65/// available it is preserved in the `source` field for diagnostics.
66///
67/// # Stability (FROZEN at 0.1.0)
68///
69/// This enum is `#[non_exhaustive]` as a **frozen decision**: it lets a new
70/// variant be *added* in a future `0.1.x` release without a major version bump,
71/// while the **seven** names and their fields below are a stability contract.
72/// (Six were frozen at `0.1.0`; [`LossyDecode`](ContentError::LossyDecode) was
73/// added in `0.1.2` through exactly that additive path.) See the [module docs](self) for the full freeze rationale (no
74/// `#[from]`, boxed codec sources, the operation→variant map) and a compile-time
75/// `Send + Sync + 'static` guard locking `ContentError`'s thread-portability.
76#[derive(Debug, Error)]
77#[non_exhaustive]
78pub enum ContentError {
79    /// Canonical dag-cbor encoding of a value failed.
80    ///
81    /// Returned by
82    /// [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor) (and so
83    /// propagated by [`content_id`](crate::ContentAddressable::content_id) /
84    /// [`verify`](crate::ContentAddressable::verify) via [`canonical_form`](crate::ContentAddressable::canonical_form)). The
85    /// `source` is boxed as a `dyn Error` so the concrete
86    /// `serde_ipld_dagcbor::EncodeError<…>` generic does not leak into this
87    /// frozen signature.
88    #[error("failed to encode value as canonical dag-cbor: {source}")]
89    EncodingError {
90        /// The underlying codec error, type-erased.
91        #[source]
92        source: Box<dyn std::error::Error + Send + Sync + 'static>,
93    },
94
95    /// Decoding dag-cbor bytes back into a value failed.
96    ///
97    /// Returned by **every** decode path in the crate, checked or not:
98    /// [`from_canonical_dagcbor`](crate::canonical::from_canonical_dagcbor) (the
99    /// deprecated unchecked door),
100    /// [`from_canonical_dagcbor_checked`](crate::canonical::from_canonical_dagcbor_checked),
101    /// [`ContentAddressable::from_canonical_form`](crate::ContentAddressable::from_canonical_form),
102    /// and [`from_canonical_bytes_checked`](crate::ContentId::from_canonical_bytes_checked).
103    ///
104    /// It covers two distinct situations, and the checked doors reach both: the
105    /// bytes are not dag-cbor at all, or they are dag-cbor but do not decode as
106    /// the target type. For a type whose [`canonical_form`](crate::ContentAddressable::canonical_form) is deliberately not its
107    /// serde representation, the second is the *expected* answer for its own
108    /// canonical bytes — see
109    /// [`from_canonical_form`](crate::ContentAddressable::from_canonical_form).
110    ///
111    /// The `source` is boxed as a `dyn Error` so the concrete
112    /// `serde_ipld_dagcbor::DecodeError<…>` generic does not leak into this
113    /// frozen signature.
114    #[error("failed to decode value from canonical dag-cbor: {source}")]
115    DecodingError {
116        /// The underlying codec error, type-erased.
117        #[source]
118        source: Box<dyn std::error::Error + Send + Sync + 'static>,
119    },
120
121    /// A [`crate::ContentId`] did not match the expected one.
122    ///
123    /// This is the heart of the doctrine: the data did not carry the proof it
124    /// claimed to. The recomputed identity differs from the expected identity.
125    ///
126    /// # Frozen [`verify`](crate::ContentAddressable::verify) ruling (0.1.0)
127    ///
128    /// [`verify`](crate::ContentAddressable::verify) returns `Ok(false)` on a
129    /// mismatch — a mismatch is **not** an `Err` (a negative answer to "do these
130    /// match?" is a successful, expected result, not a failure to check). This
131    /// is a **frozen** part of the `0.1.0` API surface (README gate item #8).
132    /// This variant is the *escalation* of that boolean: it is constructed by
133    /// [`ensure_content_id`](crate::ContentAddressable::ensure_content_id), the
134    /// strict helper that turns a mismatch into an `Err` for `?`-propagation.
135    /// Its `expected` / `computed` fields are the two ids' [`Display`] strings
136    /// (multibase base32-lower `b…`).
137    ///
138    /// [`Display`]: core::fmt::Display
139    #[error("content verification failed: expected {expected}, computed {computed}")]
140    VerificationFailed {
141        /// The identity the caller expected, as a [`ContentId`](crate::ContentId)
142        /// [`Display`](core::fmt::Display) string.
143        expected: String,
144        /// The identity actually computed from the bytes, as a
145        /// [`ContentId`](crate::ContentId) [`Display`](core::fmt::Display) string.
146        computed: String,
147    },
148
149    /// Bytes presented as canonical dag-cbor were valid CBOR but **not
150    /// canonical** dag-cbor.
151    ///
152    /// Returned by every door that verifies canonicality, all of which run one
153    /// shared generic gate:
154    /// [`from_canonical_bytes_checked`](crate::ContentId::from_canonical_bytes_checked),
155    /// [`from_canonical_dagcbor_checked`](crate::canonical::from_canonical_dagcbor_checked),
156    /// and
157    /// [`ContentAddressable::from_canonical_form`](crate::ContentAddressable::from_canonical_form)
158    /// — plus the `unstable-store` seam's strict ingest and identity-preserving
159    /// read, which are those doors under other names.
160    ///
161    /// It means the input decoded as an [`Ipld`](ipld_core::ipld::Ipld) value but
162    /// re-encoding it did **not** reproduce the input bytes (wrong map-key order,
163    /// indefinite-length items, non-smallest integers, …). No target type is
164    /// involved: the verdict is about the bytes alone, which is why it is
165    /// established *before* any type is constructed. The fast
166    /// [`from_canonical_bytes`](crate::ContentId::from_canonical_bytes) primitive
167    /// never produces this — it trusts its precondition and only hashes.
168    #[error(
169        "bytes are valid CBOR but not canonical dag-cbor (re-encoding differs from the input)"
170    )]
171    NonCanonical,
172
173    /// A **typed round-trip mismatch**: the value decoded, but re-encoding it
174    /// does not reproduce the input bytes.
175    ///
176    /// Produced at the third stage of the two checked doors —
177    /// [`canonical::from_canonical_dagcbor_checked`](crate::canonical::from_canonical_dagcbor_checked),
178    /// which re-encodes with
179    /// [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor), and
180    /// [`ContentAddressable::from_canonical_form`](crate::ContentAddressable::from_canonical_form),
181    /// which re-encodes with the type's own
182    /// [`canonical_form`](crate::ContentAddressable::canonical_form). Those two
183    /// are the only **crate-owned** stage-three construction sites — a user's own
184    /// [`canonical_form`](crate::ContentAddressable::canonical_form) may of course
185    /// return this public variant, and when it does the crate propagates it
186    /// verbatim rather than reinterpreting it (see below).
187    ///
188    /// The bytes were already proven canonical by the time this can fire, so they
189    /// are *not* at fault: the mismatch is between the input and **the target
190    /// type's canonical representation of what it decoded**.
191    ///
192    /// Information loss is the usual cause and the one that motivated the
193    /// variant: `serde` ignores unknown map keys by default, so a record carrying
194    /// a field the type does not name decodes cleanly with the field gone, and
195    /// for a protocol record that field may have been a required demand.
196    /// `#[serde(default)]` and aliases land in the same place. It is **not the
197    /// only cause**, though, and a `LossyDecode` is not by itself proof that a
198    /// field was dropped: a type whose [`canonical_form`](crate::ContentAddressable::canonical_form) intentionally differs
199    /// from its serde representation can decode faithfully and still re-encode to
200    /// different bytes. Either way the conclusion the caller needs is the same —
201    /// *these bytes are not the canonical form of this value* — which is why one
202    /// variant covers both, and why the display message speaks of the mismatch
203    /// rather than naming a missing field.
204    ///
205    /// Distinct from [`NonCanonical`](ContentError::NonCanonical) on purpose:
206    /// `NonCanonical` blames the bytes (re-encode them), `LossyDecode` blames the
207    /// bytes/type *pairing* (widen the type, name every field, or give the custom
208    /// canonical form the custom decoder it needs). Like `NonCanonical` it is a
209    /// policy rejection and carries no underlying `source` — nothing failed
210    /// underneath; the comparison simply came out unequal.
211    ///
212    /// Two things it is **not**. It is not what a user's own [`canonical_form`](crate::ContentAddressable::canonical_form)
213    /// returning an error produces — that error propagates verbatim, whatever it
214    /// is. And under the `unstable-store` seam it is not what a mismatched typed
215    /// read reports: `NodeStoreExt::get_node` returns its own
216    /// `StoreError::RepresentationMismatch`, which names the id that lied, so a
217    /// `LossyDecode` surfacing from that seam came from the user's encoder.
218    ///
219    /// Added in `0.1.2` (issue #90) under the enum's `#[non_exhaustive]`
220    /// contract.
221    #[error("typed round-trip mismatch: re-encoding the decoded value differs from the input")]
222    LossyDecode,
223
224    /// A CID could not be parsed from a string or from bytes.
225    ///
226    /// Returned by [`ContentId::from_bytes`](crate::ContentId::from_bytes) and
227    /// the [`FromStr`](core::str::FromStr) impl. `reason` is a stable
228    /// human-readable string; the underlying `cid::Error` is preserved in
229    /// `source` so the error chain (`.source()`) is not stripped at this
230    /// boundary.
231    #[error("invalid CID: {reason}")]
232    InvalidCid {
233        /// Human-readable description of why the CID is invalid.
234        reason: String,
235        /// The underlying CID parse error, preserved for `.source()` chaining.
236        #[source]
237        source: Box<dyn std::error::Error + Send + Sync + 'static>,
238    },
239
240    /// A syntactically-valid CID was parsed, but it is **not** this crate's frozen
241    /// profile: CIDv1 + dag-cbor (`0x71`) + BLAKE3 (`0x1e`) + a 32-byte digest.
242    ///
243    /// Every ingress path — [`from_bytes`](crate::ContentId::from_bytes), the
244    /// [`FromStr`](core::str::FromStr) impl, [`TryFrom<Cid>`](crate::ContentId), and
245    /// the binary/IPLD `Deserialize` — rejects a foreign CID with this, so *every*
246    /// [`ContentId`](crate::ContentId) (however it entered) carries the fixed profile the presentation
247    /// accessors ([`digest_bytes`](crate::ContentId::digest_bytes) etc.) rely on.
248    /// A policy rejection, so — like [`NonCanonical`](ContentError::NonCanonical) —
249    /// it carries no underlying `source`; `reason` names the parameter that was off.
250    #[error("CID is not the content-addressable profile: {reason}")]
251    InvalidCidProfile {
252        /// Which profile parameter was wrong (version / codec / hash / digest length).
253        reason: String,
254    },
255}
256
257#[cfg(test)]
258mod tests {
259    use super::ContentError;
260
261    /// Compile-time lock: `ContentError` is `Send + Sync + 'static`, so it stays
262    /// thread-portable across the freeze (README gate item #7). If a future field
263    /// (e.g. a non-`Send` source) broke this, the crate would fail to compile
264    /// here rather than silently narrowing the public contract.
265    #[test]
266    fn content_error_is_send_sync_static() {
267        fn assert_send_sync_static<T: Send + Sync + 'static>() {}
268        assert_send_sync_static::<ContentError>();
269    }
270
271    /// Regression guard against future source-stripping (README gate item #7):
272    /// `EncodingError`, `DecodingError`, and `InvalidCid` must each carry a live
273    /// `#[source]`, so `std::error::Error::source` returns `Some(_)` and the
274    /// error chain is walkable. `VerificationFailed` / `NonCanonical` /
275    /// `LossyDecode` / `InvalidCidProfile` carry no source by design — they are
276    /// policy rejections, with nothing underneath that failed.
277    #[test]
278    fn source_chain_is_preserved_for_sourced_variants() {
279        use std::error::Error as _;
280
281        // A genuine decode error (empty input is not a complete dag-cbor item).
282        let decode_err = crate::canonical::from_canonical_dagcbor_checked::<u64>(&[])
283            .expect_err("empty input must fail to decode");
284        assert!(
285            matches!(decode_err, ContentError::DecodingError { .. }),
286            "empty bytes must decode-fail"
287        );
288        assert!(
289            decode_err.source().is_some(),
290            "DecodingError must preserve its underlying codec source"
291        );
292
293        // A genuine encode error: dag-cbor forbids non-finite floats, so NaN
294        // fails to encode.
295        let encode_err = crate::canonical::to_canonical_dagcbor(&f64::NAN)
296            .expect_err("dag-cbor must reject NaN");
297        assert!(
298            matches!(encode_err, ContentError::EncodingError { .. }),
299            "NaN must encode-fail"
300        );
301        assert!(
302            encode_err.source().is_some(),
303            "EncodingError must preserve its underlying codec source"
304        );
305
306        // A genuine CID parse error keeps the underlying cid::Error as a source.
307        let cid_err = crate::ContentId::from_bytes(&[0xff, 0xff, 0xff])
308            .expect_err("garbage must not parse as a CID");
309        assert!(
310            matches!(cid_err, ContentError::InvalidCid { .. }),
311            "garbage must CID-parse-fail"
312        );
313        assert!(
314            cid_err.source().is_some(),
315            "InvalidCid must preserve the underlying cid::Error as a source"
316        );
317    }
318}