Skip to main content

turnframe_core/
hash.rs

1//! Canonical JSON hashing used for payload hashes, idempotency keys, plan hashes
2//! and schema fingerprints.
3//!
4//! "Canonical" here means: the value is serialized with `serde`, every object
5//! has its keys sorted lexicographically (recursively) and the result is
6//! rendered with `serde_json`'s compact writer. It is **not** full RFC 8785; in
7//! particular floating point formatting follows `serde_json`. Domain types that
8//! participate in hashes should avoid floats or accept that rule.
9
10use std::fmt;
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16/// Error raised when a value cannot be rendered as canonical JSON.
17///
18/// The `Display` output never contains the offending value.
19#[derive(Debug, thiserror::Error)]
20#[non_exhaustive]
21pub enum HashError {
22    /// `serde` failed while serializing the value.
23    #[error("value could not be serialized to canonical JSON")]
24    Serialization(#[source] serde_json::Error),
25}
26
27/// A lowercase hexadecimal BLAKE3 digest (64 characters).
28#[derive(
29    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
30)]
31#[serde(transparent)]
32pub struct Digest(pub String);
33
34impl Digest {
35    /// Borrows the hexadecimal representation.
36    #[must_use]
37    pub fn as_str(&self) -> &str {
38        &self.0
39    }
40
41    /// Computes the digest of raw bytes.
42    #[must_use]
43    pub fn of_bytes(bytes: &[u8]) -> Self {
44        Self(digest_hex(bytes))
45    }
46
47    /// Computes the digest of the canonical JSON rendering of `value`.
48    pub fn of_canonical<T: Serialize + ?Sized>(value: &T) -> Result<Self, HashError> {
49        canonical_digest(value)
50    }
51}
52
53impl fmt::Display for Digest {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str(&self.0)
56    }
57}
58
59impl From<Digest> for String {
60    fn from(value: Digest) -> Self {
61        value.0
62    }
63}
64
65/// Renders `value` as canonical JSON (sorted keys, compact).
66pub fn canonical_json<T: Serialize + ?Sized>(value: &T) -> Result<String, HashError> {
67    let mut json = serde_json::to_value(value).map_err(HashError::Serialization)?;
68    json.sort_all_objects();
69    serde_json::to_string(&json).map_err(HashError::Serialization)
70}
71
72/// Renders `value` as a canonical [`serde_json::Value`] (sorted keys).
73pub fn canonical_value<T: Serialize + ?Sized>(value: &T) -> Result<serde_json::Value, HashError> {
74    let mut json = serde_json::to_value(value).map_err(HashError::Serialization)?;
75    json.sort_all_objects();
76    Ok(json)
77}
78
79/// Returns the lowercase hexadecimal BLAKE3 digest of `bytes`.
80#[must_use]
81pub fn digest_hex(bytes: &[u8]) -> String {
82    blake3::hash(bytes).to_hex().to_string()
83}
84
85/// Returns the BLAKE3 digest of the canonical JSON rendering of `value`.
86pub fn canonical_digest<T: Serialize + ?Sized>(value: &T) -> Result<Digest, HashError> {
87    let json = canonical_json(value)?;
88    Ok(Digest(digest_hex(json.as_bytes())))
89}
90
91/// Derives a stable, opaque UUID from domain-separated `parts`.
92///
93/// The material is `domain`, then every part, joined by a NUL byte, hashed with
94/// BLAKE3; the first 16 bytes become a version 8 (custom) UUID. Two processes
95/// running the same library version derive the same identifier from the same
96/// parts, which is what makes a replayed turn reproduce its plan (I20).
97///
98/// Parts must be identifiers, digests or enumerations — never free user text —
99/// and must not contain a NUL byte, or the separation is not injective.
100#[must_use]
101pub fn derive_uuid(domain: &str, parts: &[&str]) -> Uuid {
102    let mut hasher = blake3::Hasher::new();
103    hasher.update(domain.as_bytes());
104    for part in parts {
105        hasher.update(b"\0");
106        hasher.update(part.as_bytes());
107    }
108    let mut bytes = [0_u8; 16];
109    bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
110    uuid::Builder::from_custom_bytes(bytes).into_uuid()
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use serde_json::json;
117
118    #[test]
119    fn canonical_json_sorts_keys_recursively() {
120        let value = json!({"b": {"z": 1, "a": [{"y": 1, "x": 2}]}, "a": 1});
121        assert_eq!(
122            canonical_json(&value).unwrap(),
123            r#"{"a":1,"b":{"a":[{"x":2,"y":1}],"z":1}}"#
124        );
125    }
126
127    #[test]
128    fn derive_uuid_is_stable_domain_separated_and_injective() {
129        let a = derive_uuid("turnframe.test.v1", &["x", "y"]);
130        assert_eq!(a, derive_uuid("turnframe.test.v1", &["x", "y"]));
131        assert_ne!(a, derive_uuid("turnframe.other.v1", &["x", "y"]));
132        assert_ne!(a, derive_uuid("turnframe.test.v1", &["xy"]));
133        assert_ne!(a, derive_uuid("turnframe.test.v1", &["x", "y", "z"]));
134        assert_eq!(a.get_version_num(), 8, "custom, not a time-ordered v7");
135    }
136
137    #[test]
138    fn digest_is_stable_and_order_independent() {
139        let a = canonical_digest(&json!({"x": 1, "y": 2})).unwrap();
140        let b = canonical_digest(&json!({"y": 2, "x": 1})).unwrap();
141        let c = canonical_digest(&json!({"y": 2, "x": 3})).unwrap();
142        assert_eq!(a, b);
143        assert_ne!(a, c);
144        assert_eq!(a.as_str().len(), 64);
145    }
146}