behavior_contracts/
fingerprint.rs1use crate::canonical::canonical_json;
24use crate::value::Value;
25use serde_json::Value as J;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum FingerprintFailureCode {
30 UnsafeNumber,
31 InvalidValue,
32}
33
34impl FingerprintFailureCode {
35 pub fn as_str(self) -> &'static str {
36 match self {
37 FingerprintFailureCode::UnsafeNumber => "UNSAFE_NUMBER",
38 FingerprintFailureCode::InvalidValue => "INVALID_VALUE",
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct FingerprintFailure {
46 pub code: FingerprintFailureCode,
47 pub message: String,
48}
49
50impl std::fmt::Display for FingerprintFailure {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 write!(f, "{}: {}", self.code.as_str(), self.message)
53 }
54}
55
56impl std::error::Error for FingerprintFailure {}
57
58const FLOAT64_EXACT: f64 = 9_007_199_254_740_992.0; const I64_EXACT: i64 = 1 << 53;
60
61fn unsafe_number(text: &str, path: &str) -> FingerprintFailure {
62 FingerprintFailure {
63 code: FingerprintFailureCode::UnsafeNumber,
64 message: format!(
65 "integral number {text} at {path} exceeds float64-exact range; portable IR must use {{int:\"...\"}} (fail-closed)"
66 ),
67 }
68}
69
70fn to_value_domain(doc: &J, path: &str) -> Result<Value, FingerprintFailure> {
71 match doc {
72 J::Null => Ok(Value::Null),
73 J::Bool(b) => Ok(Value::Bool(*b)),
74 J::String(s) => Ok(Value::Str(s.clone())),
75 J::Number(n) => {
76 if let Some(i) = n.as_i64() {
77 if i <= -I64_EXACT || i >= I64_EXACT {
78 return Err(unsafe_number(&n.to_string(), path));
79 }
80 return Ok(Value::Int(i));
81 }
82 if n.as_u64().is_some() {
83 return Err(unsafe_number(&n.to_string(), path));
85 }
86 let f = n.as_f64().ok_or_else(|| FingerprintFailure {
87 code: FingerprintFailureCode::InvalidValue,
88 message: format!("bad number literal {n} at {path} (fail-closed)"),
89 })?;
90 if f.fract() == 0.0 && f.is_finite() && !(f == 0.0 && f.is_sign_negative()) {
92 if f.abs() >= FLOAT64_EXACT {
93 return Err(unsafe_number(&n.to_string(), path));
94 }
95 return Ok(Value::Int(f as i64));
96 }
97 Ok(Value::Float(f))
98 }
99 J::Array(items) => {
100 let mut out = Vec::with_capacity(items.len());
101 for (i, e) in items.iter().enumerate() {
102 out.push(to_value_domain(e, &format!("{path}[{i}]"))?);
103 }
104 Ok(Value::Arr(out))
105 }
106 J::Object(map) => {
107 let mut out = Vec::with_capacity(map.len());
108 for (k, v) in map {
109 out.push((k.clone(), to_value_domain(v, &format!("{path}.{k}"))?));
110 }
111 Ok(Value::Obj(out))
112 }
113 }
114}
115
116fn fnv1a64(data: &[u8]) -> String {
117 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
118 const PRIME: u64 = 0x0000_0100_0000_01b3;
119 for b in data {
120 h ^= u64::from(*b);
121 h = h.wrapping_mul(PRIME);
122 }
123 format!("{h:016x}")
124}
125
126pub fn fingerprint_component_graph(doc: &J) -> Result<String, FingerprintFailure> {
133 let lifted = to_value_domain(doc, "$")?;
134 let s = canonical_json(&lifted).map_err(|e| FingerprintFailure {
135 code: FingerprintFailureCode::InvalidValue,
136 message: format!("canonical serialization failed: {e} (fail-closed)"),
137 })?;
138 Ok(format!("fnv1a64:{}", fnv1a64(s.as_bytes())))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use serde_json::json;
145
146 const MINI_IR_FP: &str = "fnv1a64:9421ef4d0840926a";
148
149 fn mini_ir() -> J {
150 json!({
151 "irVersion": 1,
152 "exprVersion": 2,
153 "components": [
154 {
155 "name": "getA",
156 "inputPorts": { "id": { "type": "string", "required": true } },
157 "body": [
158 { "id": "a", "component": "GetItem", "ports": { "PK": { "concat": ["A#", { "ref": ["id"] }] } } }
159 ],
160 "output": { "ref": ["a"] },
161 "plan": { "groups": [[0]], "concurrency": 16 }
162 }
163 ]
164 })
165 }
166
167 const TYPED_MINI_IR_FP: &str = "fnv1a64:c4d20ffa450dc313";
169
170 fn typed_mini_ir() -> J {
171 json!({
172 "irVersion": 1,
173 "exprVersion": 2,
174 "components": [
175 {
176 "name": "getA",
177 "inputPorts": { "id": { "type": "string", "required": true } },
178 "body": [
179 { "id": "a", "component": "GetItem", "ports": { "PK": { "concat": ["A#", { "ref": ["id"] }] } },
180 "outType": { "obj": { "id": "string", "n": "int", "tags": { "arr": "string" }, "note": { "opt": "string" } } } }
181 ],
182 "output": { "ref": ["a"] },
183 "outputType": { "obj": { "id": "string", "n": "int", "tags": { "arr": "string" }, "note": { "opt": "string" } } },
184 "plan": { "groups": [[0]], "concurrency": 16 }
185 }
186 ]
187 })
188 }
189
190 #[test]
191 fn pinned_cross_language_value() {
192 assert_eq!(fingerprint_component_graph(&mini_ir()).unwrap(), MINI_IR_FP);
193 }
194
195 #[test]
196 fn typed_ir_pinned_cross_language_value() {
197 assert_eq!(
201 fingerprint_component_graph(&typed_mini_ir()).unwrap(),
202 TYPED_MINI_IR_FP
203 );
204 assert_ne!(
205 fingerprint_component_graph(&typed_mini_ir()).unwrap(),
206 fingerprint_component_graph(&mini_ir()).unwrap()
207 );
208 }
209
210 #[test]
211 fn key_order_insensitive_and_int_float_canonicalization() {
212 let a = fingerprint_component_graph(&json!({"a": 1, "b": [1, 2]})).unwrap();
213 let b = fingerprint_component_graph(&json!({"b": [1.0, 2.0], "a": 1.0})).unwrap();
214 assert_eq!(a, b);
215 let c = fingerprint_component_graph(&json!({"a": 1.5, "b": [1, 2]})).unwrap();
216 assert_ne!(a, c);
217 }
218
219 #[test]
220 fn unsafe_number_fail_closed() {
221 for doc in [json!({"n": 9007199254740993_i64}), json!({"n": 1e300})] {
222 let err = fingerprint_component_graph(&doc).unwrap_err();
223 assert_eq!(err.code, FingerprintFailureCode::UnsafeNumber);
224 assert!(err.message.contains("fail-closed"));
225 }
226 }
227
228 #[test]
229 fn negative_zero_stays_float() {
230 let neg = fingerprint_component_graph(&json!({"n": -0.0})).unwrap();
231 let zero = fingerprint_component_graph(&json!({"n": 0})).unwrap();
232 assert_ne!(neg, zero);
233 }
234}