Skip to main content

behavior_contracts/
fingerprint.rs

1//! fingerprint — deterministic fingerprint of a portable IR document (common
2//! Generator, bc#13 — Rust port of `ts/src/generator/fingerprint.ts`).
3//!
4//! Generated modules recompute the fingerprint over their embedded IR literal on
5//! first use and compare it against the constant baked in at generation time
6//! (skew is a LOUD reject — graphddb #208 prepared-artifact discipline).
7//!
8//! Cross-language identity discipline (must match TS / Python / Go / PHP exactly):
9//!  1. Lift the JSON document into the [`Value`] domain. Integral numbers become
10//!     int; only non-integral numbers stay float. JSON's data model (RFC 8259)
11//!     does not distinguish 1 from 1.0, so integral floats canonicalize to int
12//!     (TS loses the distinction at JSON.parse time — this specifies that
13//!     behavior so all languages agree). `-0.0` stays float (matches TS).
14//!  2. Serialize with [`canonical_json`] (all-level code-point key sort —
15//!     canonical-serialization.md §3).
16//!  3. FNV-1a 64 over the UTF-8 bytes. Representation: `"fnv1a64:" + 16 hex`.
17//!
18//! Constraint (fail-closed): integral numbers with |n| >= 2^53 are rejected
19//! (portable-IR big integers must use the Expression IR `{int:"..."}` wrapper —
20//! expression-ir.md §2.3. TS loses precision at JSON.parse, so allowing them
21//! here would let fingerprints diverge across languages).
22
23use crate::canonical::canonical_json;
24use crate::value::Value;
25use serde_json::Value as J;
26
27/// Fingerprint input invariant violation codes (fail-closed).
28#[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/// A fingerprint input invariant violation (fail-closed).
44#[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; // 2^53
59const 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                // > i64::MAX, so necessarily >= 2^53.
84                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            // Integral floats canonicalize to int; -0.0 stays float (matches TS/Python).
91            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
126/// Deterministic fingerprint (`"fnv1a64:<16hex>"`) of a portable IR document.
127///
128/// Structurally identical IR documents (modulo key order and int/integral-float
129/// spelling) produce the same fingerprint across all runtime languages. Generated
130/// modules use it for their first-use self-check; consumers can use it to compare
131/// a live IR against a generated module (#208 prepared-loader pattern).
132pub 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    // Pinned in ts/test/generator.test.ts — cross-language parity value.
147    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    // Pinned in ts/test/generator.test.ts — typed IR (bc#44 B0) cross-language parity.
168    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        // bc#44 B0: type annotations are hashed by the canonical-JSON walk → the typed
198        // IR pins a distinct, cross-language-stable fingerprint, and it differs from the
199        // untyped MINI_IR (tamper detection).
200        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}