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}