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}