Skip to main content

spec_driven_docs/plan/
fingerprint.rs

1//! What a plan's identity is, and what it deliberately ignores.
2//!
3//! An approval binds to a fingerprint over the semantic inputs, and an
4//! apply regenerates it and refuses on any difference. So the fingerprint
5//! has to answer one question exactly: would this plan still do the same
6//! thing to the same target? Anything that changes the answer is in.
7//! Anything that cannot is out, or every rerun would invalidate itself.
8//!
9//! The encoding is RFC 8785 JSON canonicalization, restricted to the value
10//! kinds a plan's inputs need: integers, strings, booleans, nulls, arrays,
11//! and string-keyed objects. Floating point is excluded by the type rather
12//! than handled, because the one genuinely subtle part of the RFC is the
13//! number grammar and a plan has no reason to carry a float.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::domain::ownership::Sha256;
20
21/// The value kinds a fingerprint input may be.
22///
23/// Closed on purpose. A kind added here is a kind the canonical encoding
24/// has to be correct for, and the restriction is what keeps that provable.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(untagged)]
27pub enum Value {
28    /// A JSON null.
29    Null,
30    /// A JSON boolean.
31    Bool(bool),
32    /// A JSON integer. No float, ever.
33    Int(i64),
34    /// A JSON string.
35    Text(String),
36    /// A JSON array, in the order the plan produced it.
37    List(Vec<Self>),
38    /// A JSON object, keyed by string and sorted at encoding time.
39    Map(BTreeMap<String, Self>),
40}
41
42impl Value {
43    /// A map built from pairs, for the planner's own construction.
44    #[must_use]
45    pub fn map<I: IntoIterator<Item = (&'static str, Self)>>(pairs: I) -> Self {
46        Self::Map(
47            pairs
48                .into_iter()
49                .map(|(key, value)| (key.to_string(), value))
50                .collect(),
51        )
52    }
53
54    /// A string value.
55    #[must_use]
56    pub fn text(value: impl Into<String>) -> Self {
57        Self::Text(value.into())
58    }
59
60    /// A string value, or null where there is none.
61    #[must_use]
62    pub fn maybe(value: Option<impl Into<String>>) -> Self {
63        value.map_or(Self::Null, |held| Self::Text(held.into()))
64    }
65}
66
67/// Encode one value as RFC 8785 canonical JSON.
68///
69/// Keys sort by their UTF-16 code units, which is what the RFC says and
70/// what a plain byte sort gets wrong for anything outside the basic
71/// multilingual plane.
72#[must_use]
73pub fn canonical(value: &Value) -> String {
74    let mut out = String::new();
75    encode(value, &mut out);
76    out
77}
78
79fn encode(value: &Value, out: &mut String) {
80    match value {
81        Value::Null => out.push_str("null"),
82        Value::Bool(true) => out.push_str("true"),
83        Value::Bool(false) => out.push_str("false"),
84        Value::Int(number) => out.push_str(&number.to_string()),
85        Value::Text(text) => encode_string(text, out),
86        Value::List(items) => {
87            out.push('[');
88            for (index, item) in items.iter().enumerate() {
89                if index > 0 {
90                    out.push(',');
91                }
92                encode(item, out);
93            }
94            out.push(']');
95        }
96        Value::Map(entries) => {
97            let mut keys: Vec<&String> = entries.keys().collect();
98            keys.sort_by_key(|left| utf16_units(left));
99            out.push('{');
100            for (index, key) in keys.iter().enumerate() {
101                if index > 0 {
102                    out.push(',');
103                }
104                encode_string(key, out);
105                out.push(':');
106                if let Some(held) = entries.get(*key) {
107                    encode(held, out);
108                }
109            }
110            out.push('}');
111        }
112    }
113}
114
115fn utf16_units(text: &str) -> Vec<u16> {
116    text.encode_utf16().collect()
117}
118
119/// Serialize a string as the RFC's escaping rules require.
120///
121/// The shortest form wins: the two-character escapes where one exists, a
122/// `\u` escape for every other control character, and the character itself
123/// everywhere else.
124fn encode_string(text: &str, out: &mut String) {
125    out.push('"');
126    for character in text.chars() {
127        match character {
128            '"' => out.push_str("\\\""),
129            '\\' => out.push_str("\\\\"),
130            '\u{8}' => out.push_str("\\b"),
131            '\u{c}' => out.push_str("\\f"),
132            '\n' => out.push_str("\\n"),
133            '\r' => out.push_str("\\r"),
134            '\t' => out.push_str("\\t"),
135            control if control < '\u{20}' => {
136                use std::fmt::Write as _;
137                let _ = write!(out, "\\u{:04x}", control as u32);
138            }
139            ordinary => out.push(ordinary),
140        }
141    }
142    out.push('"');
143}
144
145/// The digest of one canonical encoding.
146#[must_use]
147pub fn fingerprint(value: &Value) -> Sha256 {
148    Sha256::of(canonical(value).as_bytes())
149}
150
151#[cfg(test)]
152mod tests {
153    #![allow(
154        clippy::unwrap_used,
155        reason = "a test panics as its failure signal, not as control flow"
156    )]
157
158    use super::*;
159
160    #[test]
161    fn the_scalar_forms_encode_as_the_rfc_writes_them() {
162        assert_eq!(canonical(&Value::Null), "null");
163        assert_eq!(canonical(&Value::Bool(true)), "true");
164        assert_eq!(canonical(&Value::Bool(false)), "false");
165        assert_eq!(canonical(&Value::Int(0)), "0");
166        assert_eq!(canonical(&Value::Int(-1)), "-1");
167        assert_eq!(
168            canonical(&Value::Int(9_007_199_254_740_991)),
169            "9007199254740991"
170        );
171    }
172
173    /// The RFC's own string vectors: the two-character escapes, a control
174    /// character with no short form, and a character that must stay as it
175    /// is rather than being escaped.
176    #[test]
177    fn strings_escape_exactly_what_the_rfc_escapes() {
178        assert_eq!(canonical(&Value::text("")), "\"\"");
179        assert_eq!(canonical(&Value::text("a\"b")), "\"a\\\"b\"");
180        assert_eq!(canonical(&Value::text("a\\b")), "\"a\\\\b\"");
181        assert_eq!(
182            canonical(&Value::text("\u{8}\u{c}\n\r\t")),
183            "\"\\b\\f\\n\\r\\t\""
184        );
185        assert_eq!(canonical(&Value::text("\u{1}")), "\"\\u0001\"");
186        assert_eq!(canonical(&Value::text("\u{7f}")), "\"\u{7f}\"");
187        assert_eq!(canonical(&Value::text("é")), "\"é\"");
188        assert_eq!(canonical(&Value::text("😀")), "\"😀\"");
189    }
190
191    /// The RFC sorts keys by UTF-16 code unit, which puts a character
192    /// above the basic multilingual plane below one that a byte sort would
193    /// place after it.
194    #[test]
195    fn keys_sort_by_utf16_code_unit() {
196        let held = Value::map([("\u{fb33}", Value::Int(1)), ("😀", Value::Int(2))]);
197        // U+1F600 encodes as the surrogate pair D83D DE00, whose first
198        // unit is below U+FB33, so the emoji sorts first.
199        assert_eq!(canonical(&held), "{\"😀\":2,\"\u{fb33}\":1}");
200    }
201
202    #[test]
203    fn map_insertion_order_cannot_change_the_digest() {
204        let one = Value::map([
205            ("b", Value::Int(2)),
206            ("a", Value::Int(1)),
207            ("c", Value::text("three")),
208        ]);
209        let two = Value::map([
210            ("c", Value::text("three")),
211            ("a", Value::Int(1)),
212            ("b", Value::Int(2)),
213        ]);
214        assert_eq!(canonical(&one), canonical(&two));
215        assert_eq!(fingerprint(&one), fingerprint(&two));
216        assert_eq!(canonical(&one), "{\"a\":1,\"b\":2,\"c\":\"three\"}");
217    }
218
219    #[test]
220    fn list_order_does_change_the_digest() {
221        let one = Value::List(vec![Value::Int(1), Value::Int(2)]);
222        let two = Value::List(vec![Value::Int(2), Value::Int(1)]);
223        assert_ne!(fingerprint(&one), fingerprint(&two));
224    }
225
226    #[test]
227    fn nesting_encodes_without_whitespace() {
228        let held = Value::map([(
229            "outer",
230            Value::map([("inner", Value::List(vec![Value::Null]))]),
231        )]);
232        assert_eq!(canonical(&held), "{\"outer\":{\"inner\":[null]}}");
233    }
234
235    #[test]
236    fn a_maybe_value_is_null_where_there_is_nothing() {
237        assert_eq!(canonical(&Value::maybe(None::<String>)), "null");
238        assert_eq!(canonical(&Value::maybe(Some("x"))), "\"x\"");
239    }
240}