use loom_events::{canonical_json, field, hmac_sha256_hex, CanonError};
use serde_json::Value;
use std::collections::HashSet;
pub const SNAPSHOT_DOMAIN: &str = "loom.snapshot/1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorldStateSnapshot {
pub event_index: u64,
pub state_hash: String,
}
fn snapshot_message(state: &Value) -> Result<String, CanonError> {
let mut msg = String::new();
msg.push_str(&field(SNAPSHOT_DOMAIN));
msg.push_str(&field(&canonical_json(state, 0)?));
Ok(msg)
}
pub fn canonical_world_state(state: &Value) -> Result<String, CanonError> {
canonical_json(state, 0)
}
pub fn world_state_hash(key: &[u8], state: &Value) -> Result<String, CanonError> {
Ok(hmac_sha256_hex(key, &snapshot_message(state)?))
}
pub fn region_leaves(key: &[u8], regions: &Value) -> Result<Value, CanonError> {
let mut leaves = serde_json::Map::new();
if let Some(obj) = regions.as_object() {
for (id, state) in obj {
leaves.insert(id.clone(), Value::String(world_state_hash(key, state)?));
}
}
Ok(Value::Object(leaves))
}
pub fn global_region_hash(key: &[u8], regions: &Value) -> Result<String, CanonError> {
let leaves = region_leaves(key, regions)?;
world_state_hash(key, &leaves)
}
pub const MAX_SAFE_EVENT_INDEX: u64 = 9_007_199_254_740_991;
pub fn snapshot_world_state(
key: &[u8],
state: &Value,
event_index: u64,
) -> Result<WorldStateSnapshot, CanonError> {
if event_index > MAX_SAFE_EVENT_INDEX {
return Err(CanonError::UnsafeInteger);
}
Ok(WorldStateSnapshot {
event_index,
state_hash: world_state_hash(key, state)?,
})
}
pub fn verify_world_snapshot(
key: &[u8],
state: &Value,
expected_hash: &str,
) -> Result<bool, CanonError> {
let actual = world_state_hash(key, state)?;
Ok(ct_eq(actual.as_bytes(), expected_hash.as_bytes()))
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for i in 0..a.len() {
diff |= a[i] ^ b[i];
}
diff == 0
}
pub fn normalize_tags(tags: &[String]) -> Vec<String> {
let mut seen: HashSet<&str> = HashSet::new();
let mut out: Vec<String> = Vec::new();
for t in tags {
if seen.insert(t.as_str()) {
out.push(t.clone());
}
}
out.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn normalize_tags_utf16_dedupe() {
let astral = "\u{1F40D}"; let bmp = "\u{F8FF}"; let got = normalize_tags(&[
bmp.to_string(),
astral.to_string(),
"a".to_string(),
"a".to_string(),
]);
assert_eq!(got, vec!["a".to_string(), astral.to_string(), bmp.to_string()]);
}
#[test]
fn verify_matches_and_rejects() {
let key = b"runtime-secret";
let state = json!({"epoch": 1, "worldSeed": 2, "entities": {}});
let h = world_state_hash(key, &state).unwrap();
assert!(verify_world_snapshot(key, &state, &h).unwrap());
let tampered = json!({"epoch": 1, "worldSeed": 3, "entities": {}});
assert!(!verify_world_snapshot(key, &tampered, &h).unwrap());
assert!(!verify_world_snapshot(b"other", &state, &h).unwrap());
}
#[test]
fn fail_closed_on_non_integer() {
let key = b"k";
assert!(world_state_hash(key, &json!({"x": 1.5})).is_err());
}
#[test]
fn rejects_unsafe_event_index() {
let key = b"k";
let state = json!({"entities": {}});
assert!(snapshot_world_state(key, &state, MAX_SAFE_EVENT_INDEX).is_ok());
assert!(snapshot_world_state(key, &state, MAX_SAFE_EVENT_INDEX + 1).is_err());
}
#[test]
fn insertion_order_irrelevant() {
let key = b"k";
let a = json!({"entities": {"x": {"properties": {"b": 2, "a": 1}}}});
let b = json!({"entities": {"x": {"properties": {"a": 1, "b": 2}}}});
assert_eq!(
world_state_hash(key, &a).unwrap(),
world_state_hash(key, &b).unwrap()
);
}
}