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}