1use serde::{Deserialize, Deserializer, Serialize};
16
17use crate::error::ScrybeError;
18
19const DIGEST_BYTES: usize = 32;
21
22const DIGEST_HEX_LEN: usize = DIGEST_BYTES * 2;
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
38pub struct ContentDigest(String);
39
40impl ContentDigest {
41 pub fn of(content: &[u8]) -> Self {
43 let hash = blake3::hash(content);
44 Self(hex::encode(hash.as_bytes()))
45 }
46
47 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 pub fn as_hex(&self) -> &str {
70 &self.0
71 }
72
73 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
93impl<'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(note = "renamed to `ContentDigest`: this is a BLAKE3 hex digest, not a CID")]
112pub type ContentId = ContentDigest;
113
114pub trait ContentAddressable {
116 fn content_digest(&self) -> ContentDigest;
118
119 #[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 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 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 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 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}