use crate::canonical::canonical_json;
use crate::value::Value;
use serde_json::Value as J;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FingerprintFailureCode {
UnsafeNumber,
InvalidValue,
}
impl FingerprintFailureCode {
pub fn as_str(self) -> &'static str {
match self {
FingerprintFailureCode::UnsafeNumber => "UNSAFE_NUMBER",
FingerprintFailureCode::InvalidValue => "INVALID_VALUE",
}
}
}
#[derive(Debug, Clone)]
pub struct FingerprintFailure {
pub code: FingerprintFailureCode,
pub message: String,
}
impl std::fmt::Display for FingerprintFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code.as_str(), self.message)
}
}
impl std::error::Error for FingerprintFailure {}
const FLOAT64_EXACT: f64 = 9_007_199_254_740_992.0; const I64_EXACT: i64 = 1 << 53;
fn unsafe_number(text: &str, path: &str) -> FingerprintFailure {
FingerprintFailure {
code: FingerprintFailureCode::UnsafeNumber,
message: format!(
"integral number {text} at {path} exceeds float64-exact range; portable IR must use {{int:\"...\"}} (fail-closed)"
),
}
}
fn to_value_domain(doc: &J, path: &str) -> Result<Value, FingerprintFailure> {
match doc {
J::Null => Ok(Value::Null),
J::Bool(b) => Ok(Value::Bool(*b)),
J::String(s) => Ok(Value::Str(s.clone())),
J::Number(n) => {
if let Some(i) = n.as_i64() {
if i <= -I64_EXACT || i >= I64_EXACT {
return Err(unsafe_number(&n.to_string(), path));
}
return Ok(Value::Int(i));
}
if n.as_u64().is_some() {
return Err(unsafe_number(&n.to_string(), path));
}
let f = n.as_f64().ok_or_else(|| FingerprintFailure {
code: FingerprintFailureCode::InvalidValue,
message: format!("bad number literal {n} at {path} (fail-closed)"),
})?;
if f.fract() == 0.0 && f.is_finite() && !(f == 0.0 && f.is_sign_negative()) {
if f.abs() >= FLOAT64_EXACT {
return Err(unsafe_number(&n.to_string(), path));
}
return Ok(Value::Int(f as i64));
}
Ok(Value::Float(f))
}
J::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for (i, e) in items.iter().enumerate() {
out.push(to_value_domain(e, &format!("{path}[{i}]"))?);
}
Ok(Value::Arr(out))
}
J::Object(map) => {
let mut out = Vec::with_capacity(map.len());
for (k, v) in map {
out.push((k.clone(), to_value_domain(v, &format!("{path}.{k}"))?));
}
Ok(Value::Obj(out))
}
}
}
fn fnv1a64(data: &[u8]) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
for b in data {
h ^= u64::from(*b);
h = h.wrapping_mul(PRIME);
}
format!("{h:016x}")
}
pub fn fingerprint_component_graph(doc: &J) -> Result<String, FingerprintFailure> {
let lifted = to_value_domain(doc, "$")?;
let s = canonical_json(&lifted).map_err(|e| FingerprintFailure {
code: FingerprintFailureCode::InvalidValue,
message: format!("canonical serialization failed: {e} (fail-closed)"),
})?;
Ok(format!("fnv1a64:{}", fnv1a64(s.as_bytes())))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const MINI_IR_FP: &str = "fnv1a64:9421ef4d0840926a";
fn mini_ir() -> J {
json!({
"irVersion": 1,
"exprVersion": 2,
"components": [
{
"name": "getA",
"inputPorts": { "id": { "type": "string", "required": true } },
"body": [
{ "id": "a", "component": "GetItem", "ports": { "PK": { "concat": ["A#", { "ref": ["id"] }] } } }
],
"output": { "ref": ["a"] },
"plan": { "groups": [[0]], "concurrency": 16 }
}
]
})
}
#[test]
fn pinned_cross_language_value() {
assert_eq!(fingerprint_component_graph(&mini_ir()).unwrap(), MINI_IR_FP);
}
#[test]
fn key_order_insensitive_and_int_float_canonicalization() {
let a = fingerprint_component_graph(&json!({"a": 1, "b": [1, 2]})).unwrap();
let b = fingerprint_component_graph(&json!({"b": [1.0, 2.0], "a": 1.0})).unwrap();
assert_eq!(a, b);
let c = fingerprint_component_graph(&json!({"a": 1.5, "b": [1, 2]})).unwrap();
assert_ne!(a, c);
}
#[test]
fn unsafe_number_fail_closed() {
for doc in [json!({"n": 9007199254740993_i64}), json!({"n": 1e300})] {
let err = fingerprint_component_graph(&doc).unwrap_err();
assert_eq!(err.code, FingerprintFailureCode::UnsafeNumber);
assert!(err.message.contains("fail-closed"));
}
}
#[test]
fn negative_zero_stays_float() {
let neg = fingerprint_component_graph(&json!({"n": -0.0})).unwrap();
let zero = fingerprint_component_graph(&json!({"n": 0})).unwrap();
assert_ne!(neg, zero);
}
}