boundary_compiler/
digest.rs1use crate::{Canonicalizer, JcsError};
4use blake3::Hash;
5use serde::{Deserialize, Serialize};
6
7pub const CANONICAL_JSON_DOMAIN: &str = "recursiveintell:canonical-json:v1";
9pub const DIGEST_ALGORITHM: &str = "blake3-256";
11pub const CANONICALIZATION_PROFILE: &str = "rfc8785";
13pub const DEFAULT_SCHEMA_ID: &str = "application/json";
15pub const DEFAULT_SCHEMA_VERSION: &str = "rfc8785-ijson";
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct DigestMetadata {
21 pub algorithm: String,
23 pub canonicalization_profile: String,
25 pub schema_id: String,
27 pub schema_version: String,
29 pub domain_separator: String,
31}
32
33impl DigestMetadata {
34 pub fn canonical_json(schema_id: impl Into<String>, schema_version: impl Into<String>) -> Self {
36 Self {
37 algorithm: DIGEST_ALGORITHM.to_owned(),
38 canonicalization_profile: CANONICALIZATION_PROFILE.to_owned(),
39 schema_id: schema_id.into(),
40 schema_version: schema_version.into(),
41 domain_separator: CANONICAL_JSON_DOMAIN.to_owned(),
42 }
43 }
44}
45
46impl Default for DigestMetadata {
47 fn default() -> Self {
48 Self::canonical_json(DEFAULT_SCHEMA_ID, DEFAULT_SCHEMA_VERSION)
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ContentDigest {
55 hash: Hash,
56 metadata: DigestMetadata,
57}
58
59impl ContentDigest {
60 pub fn compute(value: &serde_json::Value) -> Result<Self, JcsError> {
62 Self::compute_with_schema(value, DEFAULT_SCHEMA_ID, DEFAULT_SCHEMA_VERSION)
63 }
64
65 pub fn compute_with_schema(
67 value: &serde_json::Value,
68 schema_id: impl Into<String>,
69 schema_version: impl Into<String>,
70 ) -> Result<Self, JcsError> {
71 let canonical_bytes = Canonicalizer::new().canonicalize_bytes(value)?;
72 let metadata = DigestMetadata::canonical_json(schema_id, schema_version);
73 let hash = hash_with_metadata(&metadata, &canonical_bytes);
74 Ok(Self { hash, metadata })
75 }
76
77 pub fn hex(&self) -> String {
79 self.hash.to_hex().to_string()
80 }
81
82 pub fn as_bytes(&self) -> [u8; 32] {
84 *self.hash.as_bytes()
85 }
86
87 pub fn metadata(&self) -> &DigestMetadata {
89 &self.metadata
90 }
91}
92
93fn hash_with_metadata(metadata: &DigestMetadata, bytes: &[u8]) -> Hash {
94 let mut hasher = blake3::Hasher::new();
95 for field in [
96 metadata.algorithm.as_bytes(),
97 metadata.canonicalization_profile.as_bytes(),
98 metadata.schema_id.as_bytes(),
99 metadata.schema_version.as_bytes(),
100 metadata.domain_separator.as_bytes(),
101 bytes,
102 ] {
103 hasher.update(&(field.len() as u64).to_be_bytes());
104 hasher.update(field);
105 }
106 hasher.finalize()
107}
108
109impl std::fmt::Display for ContentDigest {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 write!(f, "{}", self.hex())
112 }
113}
114
115impl std::fmt::LowerHex for ContentDigest {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 write!(f, "{}", self.hex())
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use serde_json::json;
125
126 #[test]
127 fn digest_binds_value_and_schema_identity() {
128 let value = json!({"b": 1, "a": 2});
129 let first = ContentDigest::compute_with_schema(&value, "claim", "1").unwrap();
130 let same = ContentDigest::compute_with_schema(&value, "claim", "1").unwrap();
131 let other_value =
132 ContentDigest::compute_with_schema(&json!({"a": 3}), "claim", "1").unwrap();
133 let other_schema = ContentDigest::compute_with_schema(&value, "claim", "2").unwrap();
134 assert_eq!(first, same);
135 assert_ne!(first, other_value);
136 assert_ne!(first, other_schema);
137 assert_eq!(first.metadata().algorithm, DIGEST_ALGORITHM);
138 assert_eq!(
139 first.metadata().canonicalization_profile,
140 CANONICALIZATION_PROFILE
141 );
142 assert_eq!(first.metadata().domain_separator, CANONICAL_JSON_DOMAIN);
143 }
144}