Skip to main content

stack_ids/
digest.rs

1//! Shared domain-separated content digest computation.
2//!
3//! Canonical JSON delegates to boundary-compiler so the stack has one RFC
4//! 8785 implementation and one JSON digest law.
5
6use schemars::{
7    r#gen::SchemaGenerator,
8    schema::{
9        InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, StringValidation,
10        SubschemaValidation,
11    },
12    JsonSchema, Map, Set,
13};
14use serde::{de, Deserialize, Deserializer, Serialize};
15
16/// Domain separator for non-JSON raw byte digests.
17pub const RAW_BYTES_DOMAIN: &str = "recursiveintell:raw-bytes:v1";
18
19/// Identity context cryptographically bound into a content digest.
20#[derive(
21    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
22)]
23pub struct DigestMetadata {
24    /// Hash algorithm.
25    pub algorithm: String,
26    /// Canonicalization algorithm/profile.
27    pub canonicalization_profile: String,
28    /// Schema identifier for the hashed value.
29    pub schema_id: String,
30    /// Schema version for the hashed value.
31    pub schema_version: String,
32    /// Domain separator preventing cross-protocol reuse.
33    pub domain_separator: String,
34}
35
36impl DigestMetadata {
37    fn raw_bytes() -> Self {
38        Self {
39            algorithm: boundary_compiler::DIGEST_ALGORITHM.to_owned(),
40            canonicalization_profile: "raw-bytes".to_owned(),
41            schema_id: "application/octet-stream".to_owned(),
42            schema_version: "1".to_owned(),
43            domain_separator: RAW_BYTES_DOMAIN.to_owned(),
44        }
45    }
46
47    fn from_boundary(metadata: &boundary_compiler::digest::DigestMetadata) -> Self {
48        Self {
49            algorithm: metadata.algorithm.clone(),
50            canonicalization_profile: metadata.canonicalization_profile.clone(),
51            schema_id: metadata.schema_id.clone(),
52            schema_version: metadata.schema_version.clone(),
53            domain_separator: metadata.domain_separator.clone(),
54        }
55    }
56}
57
58/// A BLAKE3 content digest with explicit identity context.
59#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
60pub struct ContentDigest {
61    hex: String,
62    metadata: DigestMetadata,
63}
64
65impl<'de> Deserialize<'de> for ContentDigest {
66    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
67    where
68        D: Deserializer<'de>,
69    {
70        #[derive(Deserialize)]
71        #[serde(untagged)]
72        enum ContentDigestWire {
73            Structured {
74                hex: String,
75                metadata: DigestMetadata,
76            },
77            LegacyHex(String),
78        }
79
80        match ContentDigestWire::deserialize(deserializer)? {
81            ContentDigestWire::Structured { hex, metadata } => {
82                let hex = validate_hex(hex).map_err(de::Error::custom)?;
83                Ok(Self { hex, metadata })
84            }
85            ContentDigestWire::LegacyHex(hex) => Self::from_hex(hex).map_err(de::Error::custom),
86        }
87    }
88}
89
90impl JsonSchema for ContentDigest {
91    fn schema_name() -> String {
92        "ContentDigest".to_owned()
93    }
94
95    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
96        let structured = SchemaObject {
97            instance_type: Some(InstanceType::Object.into()),
98            object: Some(Box::new(ObjectValidation {
99                properties: Map::from([
100                    ("hex".to_owned(), generator.subschema_for::<String>()),
101                    (
102                        "metadata".to_owned(),
103                        generator.subschema_for::<DigestMetadata>(),
104                    ),
105                ]),
106                required: Set::from(["hex".to_owned(), "metadata".to_owned()]),
107                ..Default::default()
108            })),
109            ..Default::default()
110        };
111        let legacy = SchemaObject {
112            instance_type: Some(InstanceType::String.into()),
113            string: Some(Box::new(StringValidation {
114                min_length: Some(64),
115                max_length: Some(64),
116                pattern: Some("^[0-9A-Fa-f]{64}$".to_owned()),
117            })),
118            ..Default::default()
119        };
120
121        Schema::Object(SchemaObject {
122            metadata: Some(Box::new(Metadata {
123                description: Some(
124                    "A BLAKE3 content digest with explicit identity context.".to_owned(),
125                ),
126                ..Default::default()
127            })),
128            subschemas: Some(Box::new(SubschemaValidation {
129                any_of: Some(vec![Schema::Object(structured), Schema::Object(legacy)]),
130                ..Default::default()
131            })),
132            ..Default::default()
133        })
134    }
135}
136
137impl ContentDigest {
138    /// Computes a domain-separated digest from raw bytes.
139    pub fn compute(data: &[u8]) -> Self {
140        let metadata = DigestMetadata::raw_bytes();
141        Self {
142            hex: hash_with_metadata(&metadata, data),
143            metadata,
144        }
145    }
146
147    /// Computes a domain-separated digest from a UTF-8 string.
148    pub fn compute_str(data: &str) -> Self {
149        Self::compute(data.as_bytes())
150    }
151
152    /// Computes an RFC 8785 digest using the default JSON schema identity.
153    pub fn compute_json<T: Serialize + ?Sized>(value: &T) -> Result<Self, DigestError> {
154        Self::compute_json_with_schema(
155            value,
156            boundary_compiler::DEFAULT_SCHEMA_ID,
157            boundary_compiler::DEFAULT_SCHEMA_VERSION,
158        )
159    }
160
161    /// Computes an RFC 8785 digest bound to a schema ID and version.
162    pub fn compute_json_with_schema<T: Serialize + ?Sized>(
163        value: &T,
164        schema_id: impl Into<String>,
165        schema_version: impl Into<String>,
166    ) -> Result<Self, DigestError> {
167        let value = serde_json::to_value(value).map_err(serialization_failed)?;
168        let digest = boundary_compiler::ContentDigest::compute_with_schema(
169            &value,
170            schema_id,
171            schema_version,
172        )
173        .map_err(|error| DigestError::SerializationFailed {
174            reason: error.to_string(),
175        })?;
176        Ok(Self {
177            hex: digest.hex(),
178            metadata: DigestMetadata::from_boundary(digest.metadata()),
179        })
180    }
181
182    /// Returns the hex representation.
183    pub fn hex(&self) -> &str {
184        &self.hex
185    }
186
187    /// Returns the raw 32-byte digest.
188    pub fn as_bytes(&self) -> [u8; 32] {
189        let mut bytes = [0_u8; 32];
190        for (index, byte) in bytes.iter_mut().enumerate() {
191            *byte = u8::from_str_radix(&self.hex[index * 2..index * 2 + 2], 16)
192                .expect("ContentDigest maintains validated hexadecimal");
193        }
194        bytes
195    }
196
197    /// Returns the identity context bound into the digest.
198    pub fn metadata(&self) -> &DigestMetadata {
199        &self.metadata
200    }
201
202    /// Creates a raw-byte digest wrapper from validated hex.
203    pub fn from_hex(hex: impl Into<String>) -> Result<Self, DigestError> {
204        let hex = validate_hex(hex.into())?;
205        Ok(Self {
206            hex,
207            metadata: DigestMetadata::raw_bytes(),
208        })
209    }
210
211    /// Creates a raw-byte digest wrapper without validating its hex.
212    pub fn from_hex_unchecked(hex: impl Into<String>) -> Self {
213        Self {
214            hex: hex.into(),
215            metadata: DigestMetadata::raw_bytes(),
216        }
217    }
218}
219
220impl std::fmt::Display for ContentDigest {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.write_str(&self.hex)
223    }
224}
225
226/// Incremental raw-byte digest builder.
227pub struct DigestBuilder {
228    hasher: blake3::Hasher,
229    metadata: DigestMetadata,
230}
231
232impl DigestBuilder {
233    /// Creates a domain-separated raw-byte digest builder.
234    pub fn new() -> Self {
235        let metadata = DigestMetadata::raw_bytes();
236        let mut hasher = blake3::Hasher::new();
237        seed_metadata(&mut hasher, &metadata);
238        Self { hasher, metadata }
239    }
240
241    /// Feeds raw bytes into the digest.
242    pub fn update(&mut self, data: &[u8]) -> &mut Self {
243        self.hasher.update(data);
244        self
245    }
246
247    /// Feeds a UTF-8 string into the digest.
248    pub fn update_str(&mut self, data: &str) -> &mut Self {
249        self.update(data.as_bytes())
250    }
251
252    /// Feeds a field separator.
253    pub fn separator(&mut self) -> &mut Self {
254        self.update(b"\0")
255    }
256
257    /// Feeds RFC 8785 bytes for a serializable JSON value.
258    pub fn update_json<T: Serialize + ?Sized>(
259        &mut self,
260        value: &T,
261    ) -> Result<&mut Self, DigestError> {
262        let value = serde_json::to_value(value).map_err(serialization_failed)?;
263        let bytes = boundary_compiler::Canonicalizer::new()
264            .canonicalize_bytes(&value)
265            .map_err(|error| DigestError::SerializationFailed {
266                reason: error.to_string(),
267            })?;
268        self.update(&bytes);
269        Ok(self)
270    }
271
272    /// Finalizes the digest.
273    pub fn finalize(self) -> ContentDigest {
274        ContentDigest {
275            hex: self.hasher.finalize().to_hex().to_string(),
276            metadata: self.metadata,
277        }
278    }
279}
280
281impl Default for DigestBuilder {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287fn seed_metadata(hasher: &mut blake3::Hasher, metadata: &DigestMetadata) {
288    for field in [
289        metadata.algorithm.as_bytes(),
290        metadata.canonicalization_profile.as_bytes(),
291        metadata.schema_id.as_bytes(),
292        metadata.schema_version.as_bytes(),
293        metadata.domain_separator.as_bytes(),
294    ] {
295        hasher.update(&(field.len() as u64).to_be_bytes());
296        hasher.update(field);
297    }
298}
299
300fn hash_with_metadata(metadata: &DigestMetadata, data: &[u8]) -> String {
301    let mut hasher = blake3::Hasher::new();
302    seed_metadata(&mut hasher, metadata);
303    hasher.update(&(data.len() as u64).to_be_bytes());
304    hasher.update(data);
305    hasher.finalize().to_hex().to_string()
306}
307
308fn validate_hex(hex: String) -> Result<String, DigestError> {
309    if hex.len() != 64 {
310        return Err(DigestError::InvalidDigest {
311            reason: format!("expected 64 hex chars, got {}", hex.len()),
312        });
313    }
314    if !hex.chars().all(|character| character.is_ascii_hexdigit()) {
315        return Err(DigestError::InvalidDigest {
316            reason: "digest must contain only hex characters".to_owned(),
317        });
318    }
319    Ok(hex)
320}
321
322fn serialization_failed(error: serde_json::Error) -> DigestError {
323    DigestError::SerializationFailed {
324        reason: error.to_string(),
325    }
326}
327
328/// Errors from digest operations.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum DigestError {
331    /// Serialization to canonical JSON failed.
332    SerializationFailed { reason: String },
333    /// The provided digest string is invalid.
334    InvalidDigest { reason: String },
335}
336
337impl DigestError {
338    /// Stable error kind.
339    pub fn kind(&self) -> &'static str {
340        match self {
341            Self::SerializationFailed { .. } => "serialization_failed",
342            Self::InvalidDigest { .. } => "invalid_digest",
343        }
344    }
345}
346
347impl std::fmt::Display for DigestError {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        match self {
350            Self::SerializationFailed { reason } => {
351                write!(f, "digest serialization failed: {reason}")
352            }
353            Self::InvalidDigest { reason } => write!(f, "invalid digest: {reason}"),
354        }
355    }
356}
357
358impl std::error::Error for DigestError {}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::collections::HashMap;
364
365    #[test]
366    fn raw_digest_is_deterministic_and_domain_bound() {
367        let first = ContentDigest::compute(b"test");
368        assert_eq!(first, ContentDigest::compute(b"test"));
369        assert_ne!(first, ContentDigest::compute(b"other"));
370        assert_eq!(first.hex().len(), 64);
371        assert_eq!(first.metadata().domain_separator, RAW_BYTES_DOMAIN);
372    }
373
374    #[test]
375    fn json_digest_normalizes_maps() {
376        let left = HashMap::from([("b", 2), ("a", 1)]);
377        let right = HashMap::from([("a", 1), ("b", 2)]);
378        assert_eq!(
379            ContentDigest::compute_json(&left).unwrap(),
380            ContentDigest::compute_json(&right).unwrap()
381        );
382    }
383
384    #[test]
385    fn schema_identity_changes_json_digest() {
386        let value = serde_json::json!({"a": 1});
387        let first = ContentDigest::compute_json_with_schema(&value, "claim", "1").unwrap();
388        let second = ContentDigest::compute_json_with_schema(&value, "claim", "2").unwrap();
389        assert_ne!(first, second);
390    }
391
392    #[test]
393    fn from_hex_validates_and_serde_roundtrips_metadata() {
394        assert!(ContentDigest::from_hex("abc").is_err());
395        assert!(ContentDigest::from_hex("g".repeat(64)).is_err());
396        let digest = ContentDigest::compute(b"test");
397        let encoded = serde_json::to_string(&digest).unwrap();
398        let decoded: ContentDigest = serde_json::from_str(&encoded).unwrap();
399        assert_eq!(decoded, digest);
400    }
401
402    #[test]
403    fn deserializes_legacy_hex_string_as_raw_bytes() {
404        let hex = "a".repeat(64);
405        let encoded = serde_json::to_string(&hex).unwrap();
406
407        let digest: ContentDigest = serde_json::from_str(&encoded).unwrap();
408
409        assert_eq!(digest.hex(), hex);
410        assert_eq!(digest.metadata(), &DigestMetadata::raw_bytes());
411    }
412
413    #[test]
414    fn rejects_invalid_legacy_hex_strings() {
415        for hex in ["a".repeat(63), "g".repeat(64)] {
416            let encoded = serde_json::to_string(&hex).unwrap();
417            assert!(serde_json::from_str::<ContentDigest>(&encoded).is_err());
418        }
419    }
420
421    #[test]
422    fn builder_separator_prevents_collision() {
423        let first = {
424            let mut builder = DigestBuilder::new();
425            builder.update_str("ab").separator().update_str("c");
426            builder.finalize()
427        };
428        let second = {
429            let mut builder = DigestBuilder::new();
430            builder.update_str("a").separator().update_str("bc");
431            builder.finalize()
432        };
433        assert_ne!(first, second);
434    }
435}