Skip to main content

content_addressable/
classified.rs

1//! [`ClassifiedCid`] — a structurally-valid CID classified by profile: one of
2//! the two identities this crate **mints**, or a validated [`ForeignCid`] it can
3//! carry and compare but will not mint.
4//!
5//! ```text
6//!                  CIDv1 (or v0)
7//!                       │
8//!            ┌──────────┴──────────┐
9//!         mintable              classified only
10//!            │                       │
11//!      ┌─────┴─────┐                 │
12//!   dag-cbor      raw          any other codec /
13//!   BLAKE3       BLAKE3        hash combination
14//!      │           │                 │
15//!  ContentId  RawContentId      ForeignCid — sha2-256, REAPI, CIDv0, …
16//! ```
17//!
18//! This is *algorithm agility in the classifier without algorithm ambiguity in
19//! the minter* (decision record `docs/adr/0003`, question 2). A boundary that
20//! receives identifiers from the world — a store index, a provenance record, a
21//! capability grant — can hold any well-formed CID, know exactly which profile
22//! it is, and refuse to *treat* a foreign one as something it minted.
23//!
24//! # Why `Classified`, not `Verified`
25//!
26//! This type performs **structural validation** (the bytes are a well-formed
27//! CID) and **profile classification** (which of the three kinds it is). It does
28//! **not** verify content against a digest — that is
29//! [`RawContentId::verify`](crate::RawContentId::verify) /
30//! [`ContentAddressable::verify`](crate::ContentAddressable::verify), which need
31//! the bytes. Naming it "verified" would overclaim exactly the property a
32//! security consumer must not assume. Renamed before the surface froze.
33//!
34//! # Canonical form (the invariant)
35//!
36//! **Every CID has exactly one representation here**, and no variant can hold a
37//! CID outside its own profile:
38//!
39//! - [`ContentId`] admits only CIDv1 · dag-cbor `0x71` · BLAKE3 `0x1e` · 32;
40//! - [`RawContentId`] admits only CIDv1 · raw `0x55` · BLAKE3 `0x1e` · 32;
41//! - [`ForeignCid`] admits **only** CIDs that neither of the above accepts.
42//!
43//! The three profiles are pairwise disjoint and jointly total over well-formed
44//! CIDs, so `ClassifiedCid::from_cid` is a bijection onto the representable
45//! values: there is no way — including hand-writing an enum variant, or feeding
46//! a crafted link to `Deserialize` — to build a `Foreign` that wraps a
47//! recognized profile, or a `Content`/`Raw` that wraps a foreign one. Equality
48//! is therefore equality of the underlying CID, and comparison is by **typed CID
49//! bytes**, never by digest bytes alone: `Raw(x) != Content(x)` (law 6).
50//!
51//! # Serde
52//!
53//! Serializes as the inner CID — a dag-cbor tag-42 link in binary/IPLD formats,
54//! the multibase string in human-readable ones — so a `ClassifiedCid` field is a
55//! *real link*. Deserialization re-classifies from the codec/hash, so the
56//! variant is never trusted from the wire; it is **derived**, which is what keeps
57//! the canonical-form invariant true across a round trip.
58
59use crate::content_id::ContentId;
60use crate::error::ContentError;
61use crate::raw_id::RawContentId;
62use core::fmt;
63use core::str::FromStr;
64use ipld_core::cid::Cid;
65use serde::{Deserialize, Deserializer, Serialize, Serializer};
66
67/// A CID that is **not** one of this crate's two mintable profiles.
68///
69/// The inner [`Cid`] is private and the only constructors
70/// ([`new`](Self::new), [`TryFrom<Cid>`], [`FromStr`], [`from_bytes`](Self::from_bytes),
71/// `Deserialize`) reject a recognized profile, so a `ForeignCid` can never
72/// alias a [`ContentId`] or [`RawContentId`]. That is what makes
73/// [`ClassifiedCid`] canonical.
74///
75/// A `ForeignCid` can be carried, compared, linked and rendered. It cannot be
76/// minted here and never becomes a [`ContentId`]/[`RawContentId`] without re-hashing
77/// the actual content under a profile this crate does mint.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
79pub struct ForeignCid(Cid);
80
81impl ForeignCid {
82    /// Wrap a CID that neither mintable profile accepts.
83    ///
84    /// # Errors
85    ///
86    /// [`ContentError::InvalidCidProfile`] if `cid` **is** a recognized profile
87    /// (dag-cbor/BLAKE3 or raw/BLAKE3). Such a CID is not foreign; classify it
88    /// with [`ClassifiedCid::from_cid`], which returns the right variant.
89    pub fn new(cid: Cid) -> Result<Self, ContentError> {
90        if ContentId::try_from(cid).is_ok() {
91            return Err(ContentError::InvalidCidProfile {
92                reason: "CID is the dag-cbor/BLAKE3 profile (a ContentId), not foreign".into(),
93            });
94        }
95        if RawContentId::try_from(cid).is_ok() {
96            return Err(ContentError::InvalidCidProfile {
97                reason: "CID is the raw/BLAKE3 profile (a RawContentId), not foreign".into(),
98            });
99        }
100        Ok(ForeignCid(cid))
101    }
102
103    /// Borrow the underlying [`Cid`].
104    #[must_use]
105    pub fn as_cid(&self) -> &Cid {
106        &self.0
107    }
108
109    /// The full CID binary envelope.
110    #[must_use]
111    pub fn to_bytes(&self) -> Vec<u8> {
112        self.0.to_bytes()
113    }
114
115    /// Parse a CID binary envelope that is not a recognized profile.
116    ///
117    /// # Errors
118    ///
119    /// [`ContentError::InvalidCid`] if the bytes are not a CID;
120    /// [`ContentError::InvalidCidProfile`] if they are a recognized profile.
121    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ContentError> {
122        let cid = Cid::read_bytes(bytes).map_err(|e| ContentError::InvalidCid {
123            reason: e.to_string(),
124            source: Box::new(e),
125        })?;
126        Self::new(cid)
127    }
128
129    /// The multicodec code this foreign CID declares.
130    #[must_use]
131    pub fn codec(&self) -> u64 {
132        self.0.codec()
133    }
134
135    /// The multihash code this foreign CID declares.
136    #[must_use]
137    pub fn hash_code(&self) -> u64 {
138        self.0.hash().code()
139    }
140}
141
142impl TryFrom<Cid> for ForeignCid {
143    type Error = ContentError;
144
145    fn try_from(cid: Cid) -> Result<Self, Self::Error> {
146        Self::new(cid)
147    }
148}
149
150impl From<ForeignCid> for Cid {
151    fn from(id: ForeignCid) -> Self {
152        id.0
153    }
154}
155
156impl fmt::Display for ForeignCid {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        fmt::Display::fmt(&self.0, f)
159    }
160}
161
162impl FromStr for ForeignCid {
163    type Err = ContentError;
164
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        let cid = Cid::from_str(s).map_err(|e| ContentError::InvalidCid {
167            reason: e.to_string(),
168            source: Box::new(e),
169        })?;
170        Self::new(cid)
171    }
172}
173
174impl Serialize for ForeignCid {
175    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
176        if serializer.is_human_readable() {
177            serializer.serialize_str(&self.to_string())
178        } else {
179            self.0.serialize(serializer)
180        }
181    }
182}
183
184impl<'de> Deserialize<'de> for ForeignCid {
185    /// Validating: a link that *is* a recognized profile is rejected here rather
186    /// than admitted as a foreign alias.
187    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
188        if deserializer.is_human_readable() {
189            let s = String::deserialize(deserializer)?;
190            s.parse::<ForeignCid>()
191                .map_err(<D::Error as serde::de::Error>::custom)
192        } else {
193            let cid = Cid::deserialize(deserializer)?;
194            ForeignCid::new(cid).map_err(<D::Error as serde::de::Error>::custom)
195        }
196    }
197}
198
199/// A CID classified by profile. See the [module docs](self) for the canonical
200/// form invariant.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
202pub enum ClassifiedCid {
203    /// This crate's dag-cbor profile: the identity of a canonical structured
204    /// value. Mintable.
205    Content(ContentId),
206    /// This crate's raw profile: the identity of an opaque byte string.
207    /// Mintable.
208    Raw(RawContentId),
209    /// A well-formed CID of any other profile (another codec, another hash such
210    /// as `sha2-256` `0x12`, another digest length, or CIDv0), validated by
211    /// [`ForeignCid`] so it can never alias a recognized profile.
212    Foreign(ForeignCid),
213}
214
215impl ClassifiedCid {
216    /// Classify an already-parsed [`Cid`]. Infallible: every well-formed CID is
217    /// exactly one of the three kinds.
218    #[must_use]
219    pub fn from_cid(cid: Cid) -> Self {
220        if let Ok(id) = ContentId::try_from(cid) {
221            return ClassifiedCid::Content(id);
222        }
223        if let Ok(id) = RawContentId::try_from(cid) {
224            return ClassifiedCid::Raw(id);
225        }
226        ClassifiedCid::Foreign(ForeignCid(cid))
227    }
228
229    /// Parse the CID binary envelope and classify.
230    ///
231    /// # Errors
232    ///
233    /// [`ContentError::InvalidCid`] if the bytes are not a CID at all. A CID of
234    /// a foreign profile is **not** an error — it is [`Foreign`](Self::Foreign).
235    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ContentError> {
236        let cid = Cid::read_bytes(bytes).map_err(|e| ContentError::InvalidCid {
237            reason: e.to_string(),
238            source: Box::new(e),
239        })?;
240        Ok(Self::from_cid(cid))
241    }
242
243    /// Borrow the underlying [`Cid`], whichever variant.
244    #[must_use]
245    pub fn as_cid(&self) -> &Cid {
246        match self {
247            ClassifiedCid::Content(id) => id.as_cid(),
248            ClassifiedCid::Raw(id) => id.as_cid(),
249            ClassifiedCid::Foreign(id) => id.as_cid(),
250        }
251    }
252
253    /// The full CID binary envelope, whichever variant.
254    #[must_use]
255    pub fn to_bytes(&self) -> Vec<u8> {
256        self.as_cid().to_bytes()
257    }
258
259    /// `true` for the two profiles this crate mints (`Content`, `Raw`).
260    #[must_use]
261    pub fn is_mintable(&self) -> bool {
262        !matches!(self, ClassifiedCid::Foreign(_))
263    }
264
265    /// The multicodec code of the CID (`0x71` dag-cbor, `0x55` raw, or whatever
266    /// a foreign CID declares).
267    #[must_use]
268    pub fn codec(&self) -> u64 {
269        self.as_cid().codec()
270    }
271
272    /// The multihash code of the CID (`0x1e` BLAKE3, `0x12` sha2-256, …).
273    #[must_use]
274    pub fn hash_code(&self) -> u64 {
275        self.as_cid().hash().code()
276    }
277
278    /// The [`ContentId`] if this is the dag-cbor profile.
279    #[must_use]
280    pub fn as_content(&self) -> Option<&ContentId> {
281        match self {
282            ClassifiedCid::Content(id) => Some(id),
283            _ => None,
284        }
285    }
286
287    /// The [`RawContentId`] if this is the raw profile.
288    #[must_use]
289    pub fn as_raw(&self) -> Option<&RawContentId> {
290        match self {
291            ClassifiedCid::Raw(id) => Some(id),
292            _ => None,
293        }
294    }
295
296    /// The [`ForeignCid`] if this is neither mintable profile.
297    #[must_use]
298    pub fn as_foreign(&self) -> Option<&ForeignCid> {
299        match self {
300            ClassifiedCid::Foreign(id) => Some(id),
301            _ => None,
302        }
303    }
304}
305
306impl From<ContentId> for ClassifiedCid {
307    fn from(id: ContentId) -> Self {
308        ClassifiedCid::Content(id)
309    }
310}
311
312impl From<RawContentId> for ClassifiedCid {
313    fn from(id: RawContentId) -> Self {
314        ClassifiedCid::Raw(id)
315    }
316}
317
318impl From<ForeignCid> for ClassifiedCid {
319    fn from(id: ForeignCid) -> Self {
320        ClassifiedCid::Foreign(id)
321    }
322}
323
324impl From<ClassifiedCid> for Cid {
325    fn from(v: ClassifiedCid) -> Self {
326        *v.as_cid()
327    }
328}
329
330impl fmt::Display for ClassifiedCid {
331    /// The inner CID's canonical multibase string (base32-lower for CIDv1; a
332    /// foreign CIDv0 renders as its base58btc form, per the CID spec).
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        fmt::Display::fmt(self.as_cid(), f)
335    }
336}
337
338impl FromStr for ClassifiedCid {
339    type Err = ContentError;
340
341    /// Parse any multibase CID string and classify. Legacy dialects (kyln
342    /// envelope-hex, `blake3:<hex>`, bare digest hex) are **not** accepted —
343    /// use the explicit `legacy` adapters.
344    fn from_str(s: &str) -> Result<Self, Self::Err> {
345        let cid = Cid::from_str(s).map_err(|e| ContentError::InvalidCid {
346            reason: e.to_string(),
347            source: Box::new(e),
348        })?;
349        Ok(Self::from_cid(cid))
350    }
351}
352
353impl Serialize for ClassifiedCid {
354    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
355        if serializer.is_human_readable() {
356            serializer.serialize_str(&self.to_string())
357        } else {
358            self.as_cid().serialize(serializer)
359        }
360    }
361}
362
363impl<'de> Deserialize<'de> for ClassifiedCid {
364    /// Re-classifies from the wire bytes, so the variant is derived rather than
365    /// trusted — a crafted link cannot land in the wrong variant.
366    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
367        if deserializer.is_human_readable() {
368            let s = String::deserialize(deserializer)?;
369            s.parse::<ClassifiedCid>()
370                .map_err(<D::Error as serde::de::Error>::custom)
371        } else {
372            let cid = Cid::deserialize(deserializer)?;
373            Ok(ClassifiedCid::from_cid(cid))
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use ipld_core::cid::multihash::Multihash;
382
383    fn digest() -> [u8; 32] {
384        *blake3::hash(b"classify me").as_bytes()
385    }
386
387    fn sha_cid(d: [u8; 32]) -> Cid {
388        Cid::new_v1(0x55, Multihash::wrap(0x12, &d).unwrap())
389    }
390
391    #[test]
392    fn classifies_both_mintable_profiles() {
393        let c = ContentId::from_dag_cbor_digest(digest());
394        let r = RawContentId::from_blake3_digest(digest());
395        assert_eq!(
396            ClassifiedCid::from_cid(*c.as_cid()),
397            ClassifiedCid::Content(c)
398        );
399        assert_eq!(ClassifiedCid::from_cid(*r.as_cid()), ClassifiedCid::Raw(r));
400        assert!(ClassifiedCid::from(c).is_mintable());
401        assert!(ClassifiedCid::from(r).is_mintable());
402        assert_eq!(ClassifiedCid::from(c).as_content(), Some(&c));
403        assert_eq!(ClassifiedCid::from(r).as_raw(), Some(&r));
404        assert!(ClassifiedCid::from(c).as_foreign().is_none());
405    }
406
407    #[test]
408    fn foreign_profiles_are_carried_not_minted() {
409        let sha = sha_cid([7u8; 32]);
410        let v = ClassifiedCid::from_cid(sha);
411        assert_eq!(v, ClassifiedCid::Foreign(ForeignCid::new(sha).unwrap()));
412        assert!(!v.is_mintable());
413        assert_eq!(v.codec(), 0x55);
414        assert_eq!(v.hash_code(), 0x12);
415        assert!(v.as_content().is_none() && v.as_raw().is_none());
416        assert_eq!(v.as_foreign().unwrap().hash_code(), 0x12);
417        assert_eq!(ClassifiedCid::from_bytes(&v.to_bytes()).unwrap(), v);
418        assert_eq!(v.to_string().parse::<ClassifiedCid>().unwrap(), v);
419    }
420
421    /// ADVERSARIAL (canonical form): a `ForeignCid` can never be built from a
422    /// CID that either mintable profile accepts — through ANY door. This is the
423    /// invariant that makes `ClassifiedCid` canonical: `Foreign` cannot alias
424    /// `Content`/`Raw`, so one CID has exactly one representation.
425    #[test]
426    fn foreign_cannot_alias_a_recognized_profile_through_any_door() {
427        let c = ContentId::from_dag_cbor_digest(digest());
428        let r = RawContentId::from_blake3_digest(digest());
429        for cid in [*c.as_cid(), *r.as_cid()] {
430            assert!(matches!(
431                ForeignCid::new(cid),
432                Err(ContentError::InvalidCidProfile { .. })
433            ));
434            assert!(matches!(
435                ForeignCid::try_from(cid),
436                Err(ContentError::InvalidCidProfile { .. })
437            ));
438            assert!(matches!(
439                ForeignCid::from_bytes(&cid.to_bytes()),
440                Err(ContentError::InvalidCidProfile { .. })
441            ));
442            assert!(matches!(
443                cid.to_string().parse::<ForeignCid>(),
444                Err(ContentError::InvalidCidProfile { .. })
445            ));
446            // serde, both flavors: a crafted link/string must not deserialize
447            // into a foreign alias of a recognized profile.
448            let json = serde_json::to_string(&cid.to_string()).unwrap();
449            assert!(serde_json::from_str::<ForeignCid>(&json).is_err());
450            let cbor = crate::canonical::to_canonical_dagcbor(&cid).unwrap();
451            assert!(crate::canonical::from_canonical_dagcbor_checked::<ForeignCid>(&cbor).is_err());
452        }
453    }
454
455    /// ADVERSARIAL: a foreign CID cannot enter a mintable variant either, so no
456    /// variant can hold a CID outside its profile.
457    #[test]
458    fn mintable_variants_cannot_hold_a_foreign_cid() {
459        let sha = sha_cid([3u8; 32]);
460        assert!(ContentId::try_from(sha).is_err());
461        assert!(RawContentId::try_from(sha).is_err());
462        assert!(sha.to_string().parse::<ContentId>().is_err());
463        assert!(sha.to_string().parse::<RawContentId>().is_err());
464        assert!(matches!(
465            ClassifiedCid::from_cid(sha),
466            ClassifiedCid::Foreign(_)
467        ));
468    }
469
470    /// Law 6 at the enum level: same digest bytes, three different identities.
471    #[test]
472    fn same_digest_three_identities() {
473        let d = digest();
474        let content = ClassifiedCid::from(ContentId::from_dag_cbor_digest(d));
475        let raw = ClassifiedCid::from(RawContentId::from_blake3_digest(d));
476        let foreign = ClassifiedCid::from_cid(sha_cid(d));
477        assert_ne!(content, raw);
478        assert_ne!(raw, foreign);
479        assert_ne!(content, foreign);
480        assert_ne!(content.to_bytes(), raw.to_bytes());
481    }
482
483    #[test]
484    fn serde_link_reclassifies_from_the_wire() {
485        let r = ClassifiedCid::from(RawContentId::from_content(b"link me"));
486        let cbor = crate::canonical::to_canonical_dagcbor(&r).unwrap();
487        assert_eq!(&cbor[..2], &[0xd8, 0x2a], "must be a tag-42 link");
488        let back: ClassifiedCid = crate::canonical::from_canonical_dagcbor_checked(&cbor).unwrap();
489        assert_eq!(back, r);
490        let json = serde_json::to_string(&r).unwrap();
491        assert_eq!(serde_json::from_str::<ClassifiedCid>(&json).unwrap(), r);
492
493        // A foreign link round-trips as Foreign, and a recognized link never
494        // deserializes into Foreign — the variant is derived, not trusted.
495        let f = ClassifiedCid::from_cid(sha_cid([9u8; 32]));
496        let f_cbor = crate::canonical::to_canonical_dagcbor(&f).unwrap();
497        assert_eq!(
498            crate::canonical::from_canonical_dagcbor_checked::<ClassifiedCid>(&f_cbor).unwrap(),
499            f
500        );
501        let c_cbor = crate::canonical::to_canonical_dagcbor(&ClassifiedCid::from(
502            ContentId::from_dag_cbor_digest(digest()),
503        ))
504        .unwrap();
505        assert!(matches!(
506            crate::canonical::from_canonical_dagcbor_checked::<ClassifiedCid>(&c_cbor).unwrap(),
507            ClassifiedCid::Content(_)
508        ));
509    }
510
511    #[test]
512    fn garbage_is_an_error_not_foreign() {
513        assert!(matches!(
514            ClassifiedCid::from_bytes(b"nope"),
515            Err(ContentError::InvalidCid { .. })
516        ));
517        assert!(matches!(
518            "nope".parse::<ClassifiedCid>(),
519            Err(ContentError::InvalidCid { .. })
520        ));
521        assert!(matches!(
522            ForeignCid::from_bytes(b"nope"),
523            Err(ContentError::InvalidCid { .. })
524        ));
525    }
526
527    /// A CIDv0 (sha2-256, dag-pb, base58btc text) is foreign, not an error.
528    #[test]
529    fn cidv0_is_foreign() {
530        let v0 = Cid::new_v0(Multihash::wrap(0x12, &[4u8; 32]).unwrap()).unwrap();
531        let c = ClassifiedCid::from_cid(v0);
532        assert!(matches!(c, ClassifiedCid::Foreign(_)));
533        assert!(ForeignCid::new(v0).is_ok());
534        assert_eq!(c.to_string().parse::<ClassifiedCid>().unwrap(), c);
535    }
536}