Skip to main content

scrybe_core/
content.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Content addressing — BLAKE3 content digests and the [`ContentAddressable`]
5//! trait.
6//!
7//! A [`ContentDigest`] is the BLAKE3 hash (32 bytes) of raw content bytes,
8//! encoded as 64 lowercase hexadecimal characters. It is a *bare digest*:
9//! there is no multibase prefix, no multicodec, and no multihash framing,
10//! so it is **not** an IPFS/IPLD CID and must not be advertised as one.
11//! The digest covers only the bytes passed to [`ContentDigest::of`] —
12//! for a [`Document`](crate::Document) that is the raw Markdown source
13//! bytes, never the path, title, or any other metadata.
14
15use serde::{Deserialize, Deserializer, Serialize};
16
17use crate::error::ScrybeError;
18
19/// Size of a BLAKE3 digest in bytes.
20const DIGEST_BYTES: usize = 32;
21
22/// Length of the canonical lowercase-hex encoding.
23const DIGEST_HEX_LEN: usize = DIGEST_BYTES * 2;
24
25/// A bare BLAKE3 content digest, encoded as lowercase hex.
26///
27/// - **What is hashed:** exactly the raw bytes handed to
28///   [`ContentDigest::of`] (for documents: the source bytes). Path, title,
29///   and metadata are never included.
30/// - **Algorithm:** BLAKE3, 32-byte output.
31/// - **Encoding:** 64 lowercase hexadecimal characters. This is the
32///   serialized representation everywhere (Display, JSON, CBOR).
33/// - **Not a CID:** no multibase/multicodec/multihash structure.
34///
35/// Stable across serialization formats (JSON, CBOR). Two `ContentDigest`s
36/// are equal iff the content they identify is byte-for-byte identical.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
38pub struct ContentDigest(String);
39
40impl ContentDigest {
41    /// Computes the BLAKE3 digest of *content* (the raw bytes, nothing else).
42    pub fn of(content: &[u8]) -> Self {
43        let hash = blake3::hash(content);
44        Self(hex::encode(hash.as_bytes()))
45    }
46
47    /// Parses a digest from its hex encoding.
48    ///
49    /// Accepts exactly 64 ASCII hex characters; uppercase
50    /// input is normalized to the canonical lowercase form. Anything else
51    /// (wrong length, non-hex characters) is rejected with
52    /// [`ScrybeError::InvalidDigest`].
53    pub fn from_hex(s: &str) -> Result<Self, ScrybeError> {
54        if s.len() != DIGEST_HEX_LEN {
55            return Err(ScrybeError::InvalidDigest(format!(
56                "expected {DIGEST_HEX_LEN} hex characters, got {}",
57                s.len()
58            )));
59        }
60        if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
61            return Err(ScrybeError::InvalidDigest(
62                "expected only hexadecimal characters [0-9a-f]".to_string(),
63            ));
64        }
65        Ok(Self(s.to_ascii_lowercase()))
66    }
67
68    /// Returns the hex-encoded BLAKE3 digest (64 lowercase hex characters).
69    pub fn as_hex(&self) -> &str {
70        &self.0
71    }
72
73    /// Verifies that *content* hashes to this digest.
74    pub fn verify(&self, content: &[u8]) -> bool {
75        Self::of(content) == *self
76    }
77}
78
79impl std::fmt::Display for ContentDigest {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.write_str(&self.0)
82    }
83}
84
85impl std::str::FromStr for ContentDigest {
86    type Err = ScrybeError;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        Self::from_hex(s)
90    }
91}
92
93// Manual Deserialize so that decoding also goes through validation. The wire
94// representation is unchanged: a plain hex string, exactly as the derived
95// implementation produced.
96impl<'de> Deserialize<'de> for ContentDigest {
97    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98    where
99        D: Deserializer<'de>,
100    {
101        let s = String::deserialize(deserializer)?;
102        Self::from_hex(&s).map_err(serde::de::Error::custom)
103    }
104}
105
106/// Deprecated name for [`ContentDigest`].
107///
108/// The old name over-promised: a real CID (content identifier in the
109/// IPFS/IPLD sense) carries multibase/multicodec/multihash structure,
110/// which this value never had.
111#[deprecated(note = "renamed to `ContentDigest`: this is a BLAKE3 hex digest, not a CID")]
112pub type ContentId = ContentDigest;
113
114/// Trait for types that can produce a stable content digest.
115pub trait ContentAddressable {
116    /// Returns the BLAKE3 content digest for this value.
117    fn content_digest(&self) -> ContentDigest;
118
119    /// Deprecated name for [`ContentAddressable::content_digest`].
120    #[deprecated(note = "renamed to `content_digest`: this is a BLAKE3 hex digest, not a CID")]
121    fn content_id(&self) -> ContentDigest {
122        self.content_digest()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    /// BLAKE3 of the empty input — a published test vector. Pins the
131    /// serialized representation: renaming `ContentId` to `ContentDigest`
132    /// must not change the digest encoding.
133    const EMPTY_B3: &str = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
134
135    #[test]
136    fn test_digest_deterministic() {
137        let a = ContentDigest::of(b"hello scrybe");
138        let b = ContentDigest::of(b"hello scrybe");
139        assert_eq!(a, b);
140    }
141
142    #[test]
143    fn test_digest_differs_for_different_content() {
144        let a = ContentDigest::of(b"foo");
145        let b = ContentDigest::of(b"bar");
146        assert_ne!(a, b);
147    }
148
149    #[test]
150    fn test_verify_roundtrip() {
151        let content = b"verifiable content";
152        let digest = ContentDigest::of(content);
153        assert!(digest.verify(content));
154        assert!(!digest.verify(b"different content"));
155    }
156
157    #[test]
158    fn test_representation_unchanged_known_vector() {
159        // 32-byte BLAKE3, lowercase hex — same bytes-in, same string-out as
160        // before the rename.
161        let digest = ContentDigest::of(b"");
162        assert_eq!(digest.as_hex(), EMPTY_B3);
163        assert_eq!(digest.to_string(), EMPTY_B3);
164        assert_eq!(digest.as_hex().len(), 64);
165    }
166
167    #[test]
168    fn test_from_hex_accepts_canonical() {
169        let digest = ContentDigest::from_hex(EMPTY_B3).expect("valid digest");
170        assert_eq!(digest.as_hex(), EMPTY_B3);
171        assert_eq!(digest, ContentDigest::of(b""));
172    }
173
174    #[test]
175    fn test_from_hex_normalizes_uppercase() {
176        let digest = ContentDigest::from_hex(&EMPTY_B3.to_ascii_uppercase()).expect("valid hex");
177        assert_eq!(digest.as_hex(), EMPTY_B3);
178    }
179
180    #[test]
181    fn test_from_hex_rejects_wrong_length() {
182        let err = ContentDigest::from_hex("abc123").unwrap_err();
183        assert!(matches!(err, ScrybeError::InvalidDigest(_)));
184        let err = ContentDigest::from_hex(&format!("{EMPTY_B3}00")).unwrap_err();
185        assert!(matches!(err, ScrybeError::InvalidDigest(_)));
186        let err = ContentDigest::from_hex("").unwrap_err();
187        assert!(matches!(err, ScrybeError::InvalidDigest(_)));
188    }
189
190    #[test]
191    fn test_from_hex_rejects_non_hex() {
192        // Right length, wrong alphabet.
193        let bogus = "z".repeat(64);
194        let err = ContentDigest::from_hex(&bogus).unwrap_err();
195        assert!(matches!(err, ScrybeError::InvalidDigest(_)));
196    }
197
198    #[test]
199    fn test_from_str_parses() {
200        let digest: ContentDigest = EMPTY_B3.parse().expect("valid digest");
201        assert_eq!(digest.as_hex(), EMPTY_B3);
202        assert!("not-hex".parse::<ContentDigest>().is_err());
203    }
204
205    #[test]
206    fn test_serde_json_representation_is_plain_hex_string() {
207        let digest = ContentDigest::of(b"");
208        let json = serde_json::to_string(&digest).expect("serialize");
209        assert_eq!(json, format!("\"{EMPTY_B3}\""));
210        let back: ContentDigest = serde_json::from_str(&json).expect("deserialize");
211        assert_eq!(back, digest);
212    }
213
214    #[test]
215    fn test_serde_deserialize_rejects_invalid() {
216        assert!(serde_json::from_str::<ContentDigest>("\"nope\"").is_err());
217    }
218
219    #[test]
220    #[allow(deprecated)]
221    fn test_deprecated_content_id_alias_still_works() {
222        // Compat shim: old vocabulary keeps compiling (with warnings) and
223        // produces identical values.
224        let old = ContentId::of(b"hello scrybe");
225        let new = ContentDigest::of(b"hello scrybe");
226        assert_eq!(old, new);
227
228        struct Probe;
229        impl ContentAddressable for Probe {
230            fn content_digest(&self) -> ContentDigest {
231                ContentDigest::of(b"probe")
232            }
233        }
234        assert_eq!(Probe.content_id(), Probe.content_digest());
235    }
236}