Skip to main content

stack_ids/
digest.rs

1//! Canonical content digest computation.
2//!
3//! ## Digest law (from MASTER_SUPPORTING_DELTA §7)
4//!
5//! The digest algorithm for export/import idempotency is:
6//! - Deterministic canonical serialization using normalized JSON object keys
7//!   and deterministic recursive traversal
8//! - UTF-8 encoding
9//! - BLAKE3 hash
10//! - Hex-encoded output (64 chars)
11//!
12//! The digest domain (which fields are included) is defined per envelope type.
13//! Bridge and importer must agree exactly on which fields are digested.
14//!
15//! `compute_json()` and `DigestBuilder::update_json()` do not trust map key order from
16//! transient serializer internals. Inputs that serialize to JSON objects are normalized
17//! before hashing so callers may use map-like structures without silently introducing
18//! unstable key-order behavior.
19//!
20//! ## Usage
21//!
22//! ```
23//! use stack_ids::ContentDigest;
24//!
25//! let digest = ContentDigest::compute(b"hello world");
26//! assert_eq!(digest.hex().len(), 64);
27//! ```
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Deserializer, Serialize, Serializer};
31use serde_json::Value;
32
33/// A BLAKE3 content digest for idempotent deduplication.
34///
35/// The inner value is a 64-character hex string representing the BLAKE3 hash.
36#[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    /// Compute a BLAKE3 digest from raw bytes.
53    pub fn compute(data: &[u8]) -> Self {
54        let hash = blake3::hash(data);
55        Self(hash.to_hex().to_string())
56    }
57
58    /// Compute a BLAKE3 digest from a UTF-8 string.
59    pub fn compute_str(data: &str) -> Self {
60        Self::compute(data.as_bytes())
61    }
62
63    /// Compute a digest from a JSON-serializable value using canonical serialization.
64    ///
65    /// Canonical serialization means:
66    /// - JSON object key ordering is normalized recursively.
67    /// - `serde_json::to_string()` (compact, no trailing whitespace).
68    ///
69    /// For structured data with guaranteed field order (structs with named fields),
70    /// serde_json produces deterministic output by default.
71    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    /// Get the hex representation.
85    pub fn hex(&self) -> &str {
86        &self.0
87    }
88
89    /// Create from a pre-computed hex string.
90    ///
91    /// Validates that the string is exactly 64 hex characters.
92    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    /// Create from a pre-computed hex string without validation.
108    ///
109    /// Use only when the digest is known to be valid (e.g. loaded from DB).
110    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
121/// Incremental digest builder for computing digests over multiple fields.
122///
123/// Use this when the digest domain spans multiple fields and you want to
124/// hash them incrementally without allocating a single concatenated buffer.
125pub struct DigestBuilder {
126    hasher: blake3::Hasher,
127}
128
129impl DigestBuilder {
130    /// Create a new builder.
131    pub fn new() -> Self {
132        Self {
133            hasher: blake3::Hasher::new(),
134        }
135    }
136
137    /// Feed raw bytes into the digest.
138    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    /// Feed a UTF-8 string into the digest.
145    pub fn update_str(&mut self, data: &str) -> &mut Self {
146        self.hasher.update(data.as_bytes());
147        self
148    }
149
150    /// Feed a field separator. Use between fields to prevent ambiguity.
151    pub fn separator(&mut self) -> &mut Self {
152        self.update(&[]);
153        self
154    }
155
156    /// Feed a JSON-serializable value using canonical serialization.
157    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    /// Finalize and return the digest.
175    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/// Errors from digest operations.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum DigestError {
211    /// Serialization to canonical JSON failed.
212    SerializationFailed { reason: String },
213    /// The provided digest string is invalid.
214    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        // BTreeMap ensures sorted keys → same digest regardless of insertion order
280        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        // "ab" + "c" should differ from "a" + "bc"
350        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}