use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const MAX_SNAPSHOT_PATHS: usize = 16_384;
pub const MAX_SNAPSHOT_DEPTH: usize = 32;
pub const MAX_SNAPSHOT_VALUE_BYTES: usize = 4_096;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SnapshotEntry {
pub path: String,
pub value: Value,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticSnapshot {
pub owner: String,
pub kind: String,
pub entries: Vec<SnapshotEntry>,
pub digest: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SnapshotDiff {
pub owner: String,
pub kind: String,
pub changes: Vec<SnapshotChange>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SnapshotChange {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<Value>,
}
pub fn snapshot_from_json(
owner: impl Into<String>,
kind: impl Into<String>,
value: &Value,
) -> Result<SemanticSnapshot, String> {
let owner = owner.into();
let kind = kind.into();
let mut paths = BTreeMap::new();
flatten_value("", value, 0, &mut paths)?;
if paths.len() > MAX_SNAPSHOT_PATHS {
return Err(format!(
"snapshot for `{owner}` exceeds max path count {MAX_SNAPSHOT_PATHS}"
));
}
let entries = paths
.into_iter()
.map(|(path, value)| SnapshotEntry { path, value })
.collect::<Vec<_>>();
let digest = digest_entries(&entries);
Ok(SemanticSnapshot {
owner,
kind,
entries,
digest,
})
}
pub fn diff_snapshots(before: &SemanticSnapshot, after: &SemanticSnapshot) -> SnapshotDiff {
let mut changes = Vec::new();
let mut before_map = before
.entries
.iter()
.map(|entry| (entry.path.as_str(), &entry.value))
.collect::<BTreeMap<_, _>>();
for entry in &after.entries {
match before_map.remove(entry.path.as_str()) {
Some(previous) if previous == &entry.value => {}
Some(previous) => changes.push(SnapshotChange {
path: entry.path.clone(),
before: Some(previous.clone()),
after: Some(entry.value.clone()),
}),
None => changes.push(SnapshotChange {
path: entry.path.clone(),
before: None,
after: Some(entry.value.clone()),
}),
}
}
for (path, previous) in before_map {
changes.push(SnapshotChange {
path: path.to_string(),
before: Some(previous.clone()),
after: None,
});
}
changes.sort_by(|left, right| left.path.cmp(&right.path));
SnapshotDiff {
owner: after.owner.clone(),
kind: after.kind.clone(),
changes,
}
}
fn flatten_value(
prefix: &str,
value: &Value,
depth: usize,
out: &mut BTreeMap<String, Value>,
) -> Result<(), String> {
if depth > MAX_SNAPSHOT_DEPTH {
return Err(format!(
"snapshot depth exceeds maximum {MAX_SNAPSHOT_DEPTH} at `{prefix}`"
));
}
match value {
Value::Object(fields) => {
for (key, child) in fields {
if is_volatile_key(key) {
continue;
}
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
flatten_value(&path, child, depth + 1, out)?;
}
}
Value::Array(items) => {
for (index, child) in items.iter().enumerate() {
let path = format!("{prefix}[{index}]");
flatten_value(&path, child, depth + 1, out)?;
}
}
Value::String(text) if text.len() > MAX_SNAPSHOT_VALUE_BYTES => {
out.insert(
prefix.to_string(),
Value::String(format!(
"<redacted:string:len={}>",
text.len()
)),
);
}
other => {
out.insert(prefix.to_string(), other.clone());
}
}
Ok(())
}
fn is_volatile_key(key: &str) -> bool {
matches!(
key,
"generated_at"
| "timestamp"
| "wall_time"
| "absolute_path"
| "cwd"
| "hostname"
| "env"
| "environment"
| "secret"
| "password"
| "token"
| "connection_string"
)
}
fn digest_entries(entries: &[SnapshotEntry]) -> String {
let bytes = serde_json::to_vec(entries).unwrap_or_default();
let mut digest = Sha256::new();
digest.update(b"distributed.contract.snapshot.v1\0");
digest.update(&bytes);
format!("sha256:{:x}", digest.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn canonical_snapshot_ignores_unordered_input_but_detects_nullability() {
let left = snapshot_from_json(
"surface/web",
"surface_client_manifest",
&json!({
"models": {
"TodoView": { "fields": { "title": { "nullable": false }, "id": { "nullable": false } } }
},
"generated_at": "2026-01-01T00:00:00Z"
}),
)
.unwrap();
let right = snapshot_from_json(
"surface/web",
"surface_client_manifest",
&json!({
"models": {
"TodoView": { "fields": { "id": { "nullable": false }, "title": { "nullable": false } } }
}
}),
)
.unwrap();
assert_eq!(left.digest, right.digest);
assert!(diff_snapshots(&left, &right).changes.is_empty());
let drifted = snapshot_from_json(
"surface/web",
"surface_client_manifest",
&json!({
"models": {
"TodoView": { "fields": { "id": { "nullable": false }, "title": { "nullable": true } } }
}
}),
)
.unwrap();
let changes = diff_snapshots(&left, &drifted).changes;
assert_eq!(changes.len(), 1);
assert_eq!(
changes[0].path,
"models.TodoView.fields.title.nullable"
);
assert_eq!(changes[0].before, Some(json!(false)));
assert_eq!(changes[0].after, Some(json!(true)));
}
}