agentguard_core/decision/
canonical.rs

1//! Canonical JSON serialization (simplified RFC 8785).
2//!
3//! We don't implement the full RFC 8785 (which covers numeric edge cases);
4//! instead we sort object keys deterministically and emit the same shape as
5//! `serde_json::to_vec`. This is sufficient for hash-chain inputs where the
6//! producer and verifier are both Rust processes.
7
8use crate::error::Result;
9use serde::Serialize;
10use serde_json::{Map, Value};
11use std::io::Write;
12
13/// Serialize `value` as canonical JSON: object keys sorted lexicographically.
14pub fn canonical_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
15    let v = serde_json::to_value(value)?;
16    let mut buf = Vec::new();
17    write_canonical_value(&mut buf, &v)?;
18    Ok(buf)
19}
20
21/// Write a `Value` as canonical JSON into a `Write`.
22///
23/// Streams output to the writer rather than building an intermediate `String`.
24/// This is the hot path for hash-chain append: every byte written to disk
25/// flows through this function, so reducing allocations matters at scale.
26pub fn write_canonical_value<W: Write>(w: &mut W, v: &Value) -> Result<()> {
27    match v {
28        Value::Null => w.write_all(b"null").map_err(into_err)?,
29        Value::Bool(b) => {
30            let s = if *b { "true" } else { "false" };
31            w.write_all(s.as_bytes()).map_err(into_err)?;
32        }
33        Value::Number(n) => {
34            w.write_all(n.to_string().as_bytes()).map_err(into_err)?;
35        }
36        Value::String(s) => {
37            serde_json::to_writer(w, s)
38                .map_err(|e| Error::Other(format!("serialize string: {}", e)))?;
39        }
40        Value::Array(arr) => {
41            w.write_all(b"[").map_err(into_err)?;
42            for (i, item) in arr.iter().enumerate() {
43                if i > 0 {
44                    w.write_all(b",").map_err(into_err)?;
45                }
46                write_canonical_value(w, item)?;
47            }
48            w.write_all(b"]").map_err(into_err)?;
49        }
50        Value::Object(obj) => write_canonical_object(w, obj)?,
51    }
52    Ok(())
53}
54
55fn write_canonical_object<W: Write>(w: &mut W, obj: &Map<String, Value>) -> Result<()> {
56    // Sort keys lexicographically.
57    let mut keys: Vec<&String> = obj.keys().collect();
58    keys.sort();
59
60    w.write_all(b"{").map_err(into_err)?;
61    for (i, k) in keys.iter().enumerate() {
62        if i > 0 {
63            w.write_all(b",").map_err(into_err)?;
64        }
65        // Borrow the writer mutably for the json call.
66        serde_json::to_writer(&mut *w, k)
67            .map_err(|e| Error::Other(format!("serialize key: {}", e)))?;
68        w.write_all(b":").map_err(into_err)?;
69        write_canonical_value(w, &obj[*k])?;
70    }
71    w.write_all(b"}").map_err(into_err)?;
72    Ok(())
73}
74
75fn into_err(e: std::io::Error) -> Error {
76    Error::Io(e.to_string())
77}
78
79use crate::error::Error;
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use serde_json::json;
85
86    #[test]
87    fn empty_object_canonical() {
88        let v = json!({});
89        let mut buf = Vec::new();
90        write_canonical_value(&mut buf, &v).unwrap();
91        assert_eq!(buf, b"{}");
92    }
93
94    #[test]
95    fn array_order_preserved() {
96        let v = json!([3, 1, 2]);
97        let mut buf = Vec::new();
98        write_canonical_value(&mut buf, &v).unwrap();
99        assert_eq!(buf, b"[3,1,2]");
100    }
101
102    #[test]
103    fn object_keys_sorted() {
104        let v = json!({"b": 1, "a": 2});
105        let mut buf = Vec::new();
106        write_canonical_value(&mut buf, &v).unwrap();
107        assert_eq!(buf, b"{\"a\":2,\"b\":1}");
108    }
109
110    #[test]
111    fn streaming_matches_allocating() {
112        // The streaming API must produce identical output to the
113        // Vec<u8>-allocating API.
114        let v = json!({"a": 1, "b": [1, 2, 3], "c": null, "d": "x"});
115        let allocating = canonical_json(&v).unwrap();
116        let mut streaming = Vec::new();
117        write_canonical_value(&mut streaming, &v).unwrap();
118        assert_eq!(allocating, streaming);
119    }
120
121    #[test]
122    fn nested_keys_sorted_recursively() {
123        let v = json!({"outer": {"z": 1, "a": 2}, "first": 3});
124        let mut buf = Vec::new();
125        write_canonical_value(&mut buf, &v).unwrap();
126        // Both outer and inner keys are sorted.
127        assert!(buf.starts_with(b"{\"first\":3,\"outer\":{\"a\":2,\"z\":1}}"));
128    }
129
130    #[test]
131    fn primitives_render_canonically() {
132        let cases: Vec<(serde_json::Value, Vec<u8>)> = vec![
133            (json!(null), b"null".to_vec()),
134            (json!(true), b"true".to_vec()),
135            (json!(false), b"false".to_vec()),
136            (json!(42), b"42".to_vec()),
137            (json!("hello"), b"\"hello\"".to_vec()),
138        ];
139        for (val, expected) in cases {
140            let mut buf = Vec::new();
141            write_canonical_value(&mut buf, &val).unwrap();
142            assert_eq!(buf, expected, "value {val:?}");
143        }
144    }
145
146    // Property tests run in the normal test and fuzz/coverage jobs. They are
147    // intentionally excluded from Miri: the generated input volume makes
148    // interpreter execution exceed the CI safety budget, while the focused
149    // unit tests above still exercise this module under Miri.
150    #[cfg(all(test, not(miri)))]
151    mod proptests {
152        use super::*;
153        use proptest::prelude::*;
154
155        proptest! {
156            /// Canonicalization is deterministic: same input always produces
157            /// the same bytes.
158            #[test]
159            fn canonical_is_deterministic(
160                keys in proptest::collection::hash_set("[a-z]{1,4}", 1..6),
161                values in proptest::collection::vec(0i64..100, 1..6)
162            ) {
163                let mut obj = serde_json::Map::new();
164                for (i, k) in keys.iter().enumerate() {
165                    obj.insert(k.clone(), serde_json::json!(values.get(i).copied().unwrap_or(0)));
166                }
167                let v1 = canonical_json(&serde_json::Value::Object(obj.clone())).unwrap();
168                let v2 = canonical_json(&serde_json::Value::Object(obj.clone())).unwrap();
169                prop_assert_eq!(v1, v2);
170            }
171
172            /// Hashes are pure: same input → same hash (when the chain head
173            /// is the same).
174            #[test]
175            fn hash_chain_pure(
176                payload in "[a-zA-Z0-9]{1,32}"
177            ) {
178                use crate::decision::chain::HashChain;
179                let chain1 = HashChain::new(b"root");
180                let chain2 = HashChain::new(b"root");
181                let h1 = chain1.append(payload.as_bytes());
182                let h2 = chain2.append(payload.as_bytes());
183                prop_assert_eq!(h1, h2);
184            }
185
186            /// The streaming canonical writer must agree with the
187            /// allocating `canonical_json` for arbitrary inputs.
188            #[test]
189            fn streaming_equals_allocating(
190                keys in proptest::collection::hash_set("[a-z]{1,4}", 1..6),
191                values in proptest::collection::vec(any::<i64>(), 1..6)
192            ) {
193                let mut obj = serde_json::Map::new();
194                for (i, k) in keys.iter().enumerate() {
195                    obj.insert(k.clone(), serde_json::json!(values.get(i).copied().unwrap_or(0)));
196                }
197                let v = serde_json::Value::Object(obj);
198                let allocating = canonical_json(&v).unwrap();
199                let mut streaming = Vec::new();
200                write_canonical_value(&mut streaming, &v).unwrap();
201                prop_assert_eq!(allocating, streaming);
202            }
203        }
204    }
205}