Skip to main content

salvor_runtime/
hash.rs

1//! Canonical JSON serialization and content hashing.
2//!
3//! Two recorded event fields are content hashes: `agent_def_hash` on
4//! `RunStarted` and `request_hash` on `ModelCallRequested`. Both must be
5//! reproducible bit for bit on replay, because the replay cursor compares
6//! them against the recorded values, so both are computed over a *canonical*
7//! serialization rather than whatever byte order `serde_json` happens to
8//! produce.
9//!
10//! # The canonical form
11//!
12//! [`canonical_json`] renders a [`Value`] as compact JSON with one added
13//! rule: **object keys are emitted in ascending byte order**. Everything else
14//! matches `serde_json`'s compact output: arrays keep their order, strings
15//! are JSON-escaped by `serde_json`, numbers use `serde_json::Number`'s own
16//! display, and `null`/`true`/`false` are literals. The sort is applied
17//! recursively.
18//!
19//! The explicit sort exists because `serde_json`'s map ordering is a *build*
20//! property, not a guarantee: its `preserve_order` cargo feature swaps the
21//! sorted `BTreeMap` for an insertion-ordered map, and any dependency
22//! anywhere in the graph can switch that feature on through feature
23//! unification. A hash that changed because an unrelated crate toggled a
24//! feature would break every stored run, so this module never relies on map
25//! iteration order.
26//!
27//! # The hash form
28//!
29//! [`hash_value`] is `"sha256:" + hex(sha256(canonical_json(value)))`. The
30//! prefix names the algorithm in the stored string, so a future algorithm
31//! change is detectable in old logs instead of silently colliding.
32
33use serde_json::Value;
34use sha2::{Digest, Sha256};
35
36/// Renders a JSON value in the canonical form documented at module level:
37/// compact, with object keys recursively sorted in ascending byte order.
38#[must_use]
39pub fn canonical_json(value: &Value) -> String {
40    let mut out = String::new();
41    write_canonical(value, &mut out);
42    out
43}
44
45/// The recursive writer behind [`canonical_json`].
46fn write_canonical(value: &Value, out: &mut String) {
47    match value {
48        Value::Null => out.push_str("null"),
49        Value::Bool(true) => out.push_str("true"),
50        Value::Bool(false) => out.push_str("false"),
51        // serde_json::Number's Display is its wire form; reusing it keeps
52        // number formatting identical to serde_json's own output.
53        Value::Number(number) => out.push_str(&number.to_string()),
54        Value::String(text) => {
55            out.push_str(
56                &serde_json::to_string(text).expect("serializing a string to JSON cannot fail"),
57            );
58        }
59        Value::Array(items) => {
60            out.push('[');
61            for (index, item) in items.iter().enumerate() {
62                if index > 0 {
63                    out.push(',');
64                }
65                write_canonical(item, out);
66            }
67            out.push(']');
68        }
69        Value::Object(map) => {
70            // Collect and sort the keys explicitly instead of trusting map
71            // iteration order; see the module docs for why.
72            let mut keys: Vec<&String> = map.keys().collect();
73            keys.sort_unstable();
74            out.push('{');
75            for (index, key) in keys.iter().enumerate() {
76                if index > 0 {
77                    out.push(',');
78                }
79                out.push_str(
80                    &serde_json::to_string(key).expect("serializing a string to JSON cannot fail"),
81                );
82                out.push(':');
83                write_canonical(&map[key.as_str()], out);
84            }
85            out.push('}');
86        }
87    }
88}
89
90/// Lowercase hex of the SHA-256 digest of `bytes`.
91#[must_use]
92pub fn sha256_hex(bytes: &[u8]) -> String {
93    let digest = Sha256::digest(bytes);
94    let mut out = String::with_capacity(digest.len() * 2);
95    for byte in digest {
96        out.push_str(&format!("{byte:02x}"));
97    }
98    out
99}
100
101/// The content hash of a JSON value: `sha256:` plus the hex SHA-256 of its
102/// canonical serialization. This is the exact string recorded in
103/// `agent_def_hash` and `request_hash` event fields.
104#[must_use]
105pub fn hash_value(value: &Value) -> String {
106    format!("sha256:{}", sha256_hex(canonical_json(value).as_bytes()))
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use serde_json::json;
113
114    /// Key order in the input must not affect the canonical form.
115    #[test]
116    fn canonical_json_sorts_object_keys_recursively() {
117        let a: Value =
118            serde_json::from_str(r#"{"b": {"z": 1, "a": 2}, "a": [3, {"y": 4, "x": 5}]}"#).unwrap();
119        let b: Value =
120            serde_json::from_str(r#"{"a": [3, {"x": 5, "y": 4}], "b": {"a": 2, "z": 1}}"#).unwrap();
121        assert_eq!(canonical_json(&a), canonical_json(&b));
122        assert_eq!(
123            canonical_json(&a),
124            r#"{"a":[3,{"x":5,"y":4}],"b":{"a":2,"z":1}}"#
125        );
126    }
127
128    /// Strings are escaped exactly as serde_json escapes them.
129    #[test]
130    fn canonical_json_escapes_strings() {
131        let value = json!({"text": "line\none \"two\""});
132        assert_eq!(canonical_json(&value), r#"{"text":"line\none \"two\""}"#);
133    }
134
135    /// The hash string carries its algorithm prefix and is stable.
136    #[test]
137    fn hash_value_is_prefixed_and_stable() {
138        let hash = hash_value(&json!({"a": 1}));
139        assert!(hash.starts_with("sha256:"));
140        assert_eq!(hash, hash_value(&json!({"a": 1})));
141        assert_ne!(hash, hash_value(&json!({"a": 2})));
142    }
143}