use crate::{Canonicalizer, JcsError};
use blake3::Hash;
use serde::{Deserialize, Serialize};
pub const CANONICAL_JSON_DOMAIN: &str = "recursiveintell:canonical-json:v1";
pub const DIGEST_ALGORITHM: &str = "blake3-256";
pub const CANONICALIZATION_PROFILE: &str = "rfc8785";
pub const DEFAULT_SCHEMA_ID: &str = "application/json";
pub const DEFAULT_SCHEMA_VERSION: &str = "rfc8785-ijson";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DigestMetadata {
pub algorithm: String,
pub canonicalization_profile: String,
pub schema_id: String,
pub schema_version: String,
pub domain_separator: String,
}
impl DigestMetadata {
pub fn canonical_json(schema_id: impl Into<String>, schema_version: impl Into<String>) -> Self {
Self {
algorithm: DIGEST_ALGORITHM.to_owned(),
canonicalization_profile: CANONICALIZATION_PROFILE.to_owned(),
schema_id: schema_id.into(),
schema_version: schema_version.into(),
domain_separator: CANONICAL_JSON_DOMAIN.to_owned(),
}
}
}
impl Default for DigestMetadata {
fn default() -> Self {
Self::canonical_json(DEFAULT_SCHEMA_ID, DEFAULT_SCHEMA_VERSION)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentDigest {
hash: Hash,
metadata: DigestMetadata,
}
impl ContentDigest {
pub fn compute(value: &serde_json::Value) -> Result<Self, JcsError> {
Self::compute_with_schema(value, DEFAULT_SCHEMA_ID, DEFAULT_SCHEMA_VERSION)
}
pub fn compute_with_schema(
value: &serde_json::Value,
schema_id: impl Into<String>,
schema_version: impl Into<String>,
) -> Result<Self, JcsError> {
let canonical_bytes = Canonicalizer::new().canonicalize_bytes(value)?;
let metadata = DigestMetadata::canonical_json(schema_id, schema_version);
let hash = hash_with_metadata(&metadata, &canonical_bytes);
Ok(Self { hash, metadata })
}
pub fn hex(&self) -> String {
self.hash.to_hex().to_string()
}
pub fn as_bytes(&self) -> [u8; 32] {
*self.hash.as_bytes()
}
pub fn metadata(&self) -> &DigestMetadata {
&self.metadata
}
}
fn hash_with_metadata(metadata: &DigestMetadata, bytes: &[u8]) -> Hash {
let mut hasher = blake3::Hasher::new();
for field in [
metadata.algorithm.as_bytes(),
metadata.canonicalization_profile.as_bytes(),
metadata.schema_id.as_bytes(),
metadata.schema_version.as_bytes(),
metadata.domain_separator.as_bytes(),
bytes,
] {
hasher.update(&(field.len() as u64).to_be_bytes());
hasher.update(field);
}
hasher.finalize()
}
impl std::fmt::Display for ContentDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.hex())
}
}
impl std::fmt::LowerHex for ContentDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.hex())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn digest_binds_value_and_schema_identity() {
let value = json!({"b": 1, "a": 2});
let first = ContentDigest::compute_with_schema(&value, "claim", "1").unwrap();
let same = ContentDigest::compute_with_schema(&value, "claim", "1").unwrap();
let other_value =
ContentDigest::compute_with_schema(&json!({"a": 3}), "claim", "1").unwrap();
let other_schema = ContentDigest::compute_with_schema(&value, "claim", "2").unwrap();
assert_eq!(first, same);
assert_ne!(first, other_value);
assert_ne!(first, other_schema);
assert_eq!(first.metadata().algorithm, DIGEST_ALGORITHM);
assert_eq!(
first.metadata().canonicalization_profile,
CANONICALIZATION_PROFILE
);
assert_eq!(first.metadata().domain_separator, CANONICAL_JSON_DOMAIN);
}
}