1use schemars::JsonSchema;
30use serde::{Deserialize, Deserializer, Serialize, Serializer};
31use serde_json::Value;
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, JsonSchema)]
37pub struct ContentDigest(String);
38
39impl Serialize for ContentDigest {
40 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
41 serializer.serialize_str(self.hex())
42 }
43}
44impl<'de> Deserialize<'de> for ContentDigest {
45 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
46 let value = String::deserialize(deserializer)?;
47 Self::from_hex(value).map_err(serde::de::Error::custom)
48 }
49}
50
51impl ContentDigest {
52 pub fn compute(data: &[u8]) -> Self {
54 let hash = blake3::hash(data);
55 Self(hash.to_hex().to_string())
56 }
57
58 pub fn compute_str(data: &str) -> Self {
60 Self::compute(data.as_bytes())
61 }
62
63 pub fn compute_json<T: Serialize>(value: &T) -> Result<Self, DigestError> {
72 let canonical = canonicalize_json_value(serde_json::to_value(value).map_err(|e| {
73 DigestError::SerializationFailed {
74 reason: e.to_string(),
75 }
76 })?);
77 let canonical =
78 serde_json::to_string(&canonical).map_err(|e| DigestError::SerializationFailed {
79 reason: e.to_string(),
80 })?;
81 Ok(Self::compute_str(&canonical))
82 }
83
84 pub fn hex(&self) -> &str {
86 &self.0
87 }
88
89 pub fn from_hex(hex: impl Into<String>) -> Result<Self, DigestError> {
93 let hex = hex.into();
94 if hex.len() != 64 {
95 return Err(DigestError::InvalidDigest {
96 reason: format!("expected 64 hex chars, got {}", hex.len()),
97 });
98 }
99 if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
100 return Err(DigestError::InvalidDigest {
101 reason: "digest must contain only hex characters".into(),
102 });
103 }
104 Ok(Self(hex))
105 }
106
107 pub fn from_hex_unchecked(hex: impl Into<String>) -> Self {
111 Self(hex.into())
112 }
113}
114
115impl std::fmt::Display for ContentDigest {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.write_str(&self.0)
118 }
119}
120
121pub struct DigestBuilder {
126 hasher: blake3::Hasher,
127}
128
129impl DigestBuilder {
130 pub fn new() -> Self {
132 Self {
133 hasher: blake3::Hasher::new(),
134 }
135 }
136
137 pub fn update(&mut self, data: &[u8]) -> &mut Self {
139 self.hasher.update(&(data.len() as u64).to_be_bytes());
140 self.hasher.update(data);
141 self
142 }
143
144 pub fn update_str(&mut self, data: &str) -> &mut Self {
146 self.hasher.update(data.as_bytes());
147 self
148 }
149
150 pub fn separator(&mut self) -> &mut Self {
152 self.update(&[]);
153 self
154 }
155
156 pub fn update_json<T: Serialize + ?Sized>(
158 &mut self,
159 value: &T,
160 ) -> Result<&mut Self, DigestError> {
161 let canonical = canonicalize_json_value(serde_json::to_value(value).map_err(|e| {
162 DigestError::SerializationFailed {
163 reason: e.to_string(),
164 }
165 })?);
166 let canonical =
167 serde_json::to_string(&canonical).map_err(|e| DigestError::SerializationFailed {
168 reason: e.to_string(),
169 })?;
170 self.update(canonical.as_bytes());
171 Ok(self)
172 }
173
174 pub fn finalize(self) -> ContentDigest {
176 let hash = self.hasher.finalize();
177 ContentDigest(hash.to_hex().to_string())
178 }
179}
180
181fn canonicalize_json_value(value: Value) -> Value {
182 match value {
183 Value::Object(map) => {
184 let mut entries = map
185 .into_iter()
186 .map(|(key, value)| (key, canonicalize_json_value(value)))
187 .collect::<Vec<(String, Value)>>();
188 entries.sort_by(|a, b| a.0.cmp(&b.0));
189 let mut ordered = serde_json::Map::new();
190 for (key, value) in entries {
191 ordered.insert(key, value);
192 }
193 Value::Object(ordered)
194 }
195 Value::Array(items) => {
196 Value::Array(items.into_iter().map(canonicalize_json_value).collect())
197 }
198 other => other,
199 }
200}
201
202impl Default for DigestBuilder {
203 fn default() -> Self {
204 Self::new()
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum DigestError {
211 SerializationFailed { reason: String },
213 InvalidDigest { reason: String },
215}
216
217impl DigestError {
218 pub fn kind(&self) -> &'static str {
219 match self {
220 Self::SerializationFailed { .. } => "serialization_failed",
221 Self::InvalidDigest { .. } => "invalid_digest",
222 }
223 }
224}
225
226impl std::fmt::Display for DigestError {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 match self {
229 Self::SerializationFailed { reason } => {
230 write!(f, "digest serialization failed: {reason}")
231 }
232 Self::InvalidDigest { reason } => {
233 write!(f, "invalid digest: {reason}")
234 }
235 }
236 }
237}
238
239impl std::error::Error for DigestError {}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use std::collections::{BTreeMap, HashMap};
245
246 #[test]
247 fn compute_and_verify_length() {
248 let digest = ContentDigest::compute(b"hello world");
249 assert_eq!(digest.hex().len(), 64);
250 assert!(digest.hex().chars().all(|c| c.is_ascii_hexdigit()));
251 }
252
253 #[test]
254 fn deterministic_same_input() {
255 let a = ContentDigest::compute(b"test data");
256 let b = ContentDigest::compute(b"test data");
257 assert_eq!(a, b);
258 }
259
260 #[test]
261 fn different_input_different_digest() {
262 let a = ContentDigest::compute(b"input A");
263 let b = ContentDigest::compute(b"input B");
264 assert_ne!(a, b);
265 }
266
267 #[test]
268 fn compute_json_deterministic() {
269 let mut map = BTreeMap::new();
270 map.insert("b", "two");
271 map.insert("a", "one");
272 let d1 = ContentDigest::compute_json(&map).unwrap();
273
274 let mut map2 = BTreeMap::new();
275 map2.insert("a", "one");
276 map2.insert("b", "two");
277 let d2 = ContentDigest::compute_json(&map2).unwrap();
278
279 assert_eq!(d1, d2);
281 }
282
283 #[test]
284 fn compute_json_normalizes_hash_map_key_order() {
285 let mut unsorted = HashMap::new();
286 unsorted.insert("b", "two");
287 unsorted.insert("a", "one");
288
289 let mut reordered = HashMap::new();
290 reordered.insert("a", "one");
291 reordered.insert("b", "two");
292
293 let left = ContentDigest::compute_json(&unsorted).unwrap();
294 let right = ContentDigest::compute_json(&reordered).unwrap();
295
296 assert_eq!(left, right);
297 }
298
299 #[test]
300 fn compute_json_matches_pinned_golden_digest() {
301 let mut ordered = BTreeMap::new();
302 ordered.insert("a", serde_json::json!({ "z": 1, "y": [3, 2, 1] }));
303 ordered.insert("b", serde_json::json!("two"));
304
305 let digest = ContentDigest::compute_json(&ordered).unwrap();
306
307 assert_eq!(
308 digest.hex(),
309 "5359182562bfb1083acba7077061a75d451f373026ae4a79c28118403f58cb1f"
310 );
311 }
312
313 #[test]
314 fn from_hex_valid() {
315 let digest = ContentDigest::compute(b"test");
316 let restored = ContentDigest::from_hex(digest.hex()).unwrap();
317 assert_eq!(restored, digest);
318 }
319
320 #[test]
321 fn from_hex_wrong_length() {
322 let err = ContentDigest::from_hex("abc").unwrap_err();
323 assert!(matches!(err, DigestError::InvalidDigest { .. }));
324 }
325
326 #[test]
327 fn from_hex_non_hex_chars() {
328 let err = ContentDigest::from_hex("g".repeat(64)).unwrap_err();
329 assert!(matches!(err, DigestError::InvalidDigest { .. }));
330 }
331
332 #[test]
333 fn builder_deterministic() {
334 let d1 = {
335 let mut b = DigestBuilder::new();
336 b.update_str("field1").separator().update_str("field2");
337 b.finalize()
338 };
339 let d2 = {
340 let mut b = DigestBuilder::new();
341 b.update_str("field1").separator().update_str("field2");
342 b.finalize()
343 };
344 assert_eq!(d1, d2);
345 }
346
347 #[test]
348 fn builder_separator_prevents_collision() {
349 let d1 = {
351 let mut b = DigestBuilder::new();
352 b.update_str("ab").separator().update_str("c");
353 b.finalize()
354 };
355 let d2 = {
356 let mut b = DigestBuilder::new();
357 b.update_str("a").separator().update_str("bc");
358 b.finalize()
359 };
360 assert_ne!(d1, d2);
361 }
362
363 #[test]
364 fn serde_roundtrip() {
365 let digest = ContentDigest::compute(b"test");
366 let json = serde_json::to_string(&digest).unwrap();
367 let back: ContentDigest = serde_json::from_str(&json).unwrap();
368 assert_eq!(back, digest);
369 }
370}