1use 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
16pub const RAW_BYTES_DOMAIN: &str = "recursiveintell:raw-bytes:v1";
18
19#[derive(
21 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
22)]
23pub struct DigestMetadata {
24 pub algorithm: String,
26 pub canonicalization_profile: String,
28 pub schema_id: String,
30 pub schema_version: String,
32 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#[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 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 pub fn compute_str(data: &str) -> Self {
149 Self::compute(data.as_bytes())
150 }
151
152 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 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 pub fn hex(&self) -> &str {
184 &self.hex
185 }
186
187 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 pub fn metadata(&self) -> &DigestMetadata {
199 &self.metadata
200 }
201
202 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 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
226pub struct DigestBuilder {
228 hasher: blake3::Hasher,
229 metadata: DigestMetadata,
230}
231
232impl DigestBuilder {
233 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 pub fn update(&mut self, data: &[u8]) -> &mut Self {
243 self.hasher.update(data);
244 self
245 }
246
247 pub fn update_str(&mut self, data: &str) -> &mut Self {
249 self.update(data.as_bytes())
250 }
251
252 pub fn separator(&mut self) -> &mut Self {
254 self.update(b"\0")
255 }
256
257 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 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#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum DigestError {
331 SerializationFailed { reason: String },
333 InvalidDigest { reason: String },
335}
336
337impl DigestError {
338 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}