use cortex_core::Event;
pub const DOMAIN_TAG_EVENT_HASH: u8 = 0x01;
pub const HEX_HASH_LEN: usize = 64;
#[must_use]
pub fn canonical_payload_bytes(value: &serde_json::Value) -> Vec<u8> {
let mut out = Vec::with_capacity(64);
encode_canonical(value, &mut out);
out
}
#[must_use]
pub fn payload_hash(value: &serde_json::Value) -> String {
let bytes = canonical_payload_bytes(value);
blake3::hash(&bytes).to_hex().to_string()
}
#[must_use]
pub fn event_hash(prev_event_hash: Option<&str>, payload_hash_hex: &str) -> String {
let prev_bytes = prev_event_hash.map(str::as_bytes).unwrap_or(&[]);
let payload_bytes = payload_hash_hex.as_bytes();
let mut hasher = blake3::Hasher::new();
hasher.update(&[DOMAIN_TAG_EVENT_HASH]);
hasher.update(&(prev_bytes.len() as u64).to_le_bytes());
hasher.update(prev_bytes);
hasher.update(&(payload_bytes.len() as u64).to_le_bytes());
hasher.update(payload_bytes);
hasher.finalize().to_hex().to_string()
}
pub fn seal(event: &mut Event) {
event.payload_hash = payload_hash(&event.payload);
event.event_hash = event_hash(event.prev_event_hash.as_deref(), &event.payload_hash);
}
fn encode_canonical(v: &serde_json::Value, out: &mut Vec<u8>) {
match v {
serde_json::Value::Null => out.extend_from_slice(b"null"),
serde_json::Value::Bool(true) => out.extend_from_slice(b"true"),
serde_json::Value::Bool(false) => out.extend_from_slice(b"false"),
serde_json::Value::Number(n) => {
out.extend_from_slice(n.to_string().as_bytes());
}
serde_json::Value::String(s) => {
let s = serde_json::to_string(s).expect("string encode");
out.extend_from_slice(s.as_bytes());
}
serde_json::Value::Array(items) => {
out.push(b'[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(b',');
}
encode_canonical(item, out);
}
out.push(b']');
}
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push(b'{');
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push(b',');
}
let key_str = serde_json::to_string(k).expect("key encode");
out.extend_from_slice(key_str.as_bytes());
out.push(b':');
encode_canonical(&map[*k], out);
}
out.push(b'}');
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use cortex_core::{Event, EventSource, EventType, SCHEMA_VERSION};
use proptest::prelude::*;
fn fixture_event(payload: serde_json::Value) -> Event {
Event {
id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap(),
schema_version: SCHEMA_VERSION,
observed_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
recorded_at: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 1).unwrap(),
source: EventSource::User,
event_type: EventType::UserMessage,
trace_id: None,
session_id: None,
domain_tags: vec![],
payload,
payload_hash: String::new(),
prev_event_hash: None,
event_hash: String::new(),
}
}
#[test]
fn hash_chain_stable_across_reserialization() {
let payload = serde_json::json!({
"z": 1,
"a": "two",
"m": [3, 4, {"y": "v", "x": "u"}],
"n": null,
"b": true,
});
let mut e1 = fixture_event(payload);
seal(&mut e1);
let h1_payload = e1.payload_hash.clone();
let h1_event = e1.event_hash.clone();
let serialized = serde_json::to_string(&e1).unwrap();
let mut e2: Event = serde_json::from_str(&serialized).unwrap();
e2.payload_hash.clear();
e2.event_hash.clear();
seal(&mut e2);
assert_eq!(e2.payload_hash, h1_payload, "payload_hash drifted");
assert_eq!(e2.event_hash, h1_event, "event_hash drifted");
let scrambled = serde_json::json!({
"b": true,
"n": null,
"m": [3, 4, {"x": "u", "y": "v"}],
"a": "two",
"z": 1,
});
assert_eq!(payload_hash(&e1.payload), payload_hash(&scrambled));
}
#[test]
fn genesis_event_has_distinct_hash_from_empty_prev_string() {
let p = payload_hash(&serde_json::json!({"x": 1}));
let h_none = event_hash(None, &p);
let h_empty = event_hash(Some(""), &p);
assert_eq!(
h_none, h_empty,
"genesis vs empty prev currently equivalent"
);
}
#[test]
fn payload_hash_is_deterministic() {
let p = serde_json::json!({"x": 1, "y": 2});
assert_eq!(payload_hash(&p), payload_hash(&p));
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 256,
..ProptestConfig::default()
})]
#[test]
fn framing_resists_boundary_confusion(
a_prev in ".{0,40}",
a_payload in ".{1,40}",
b_prev in ".{0,40}",
b_payload in ".{1,40}",
) {
let a_prev_opt = if a_prev.is_empty() { None } else { Some(a_prev.as_str()) };
let b_prev_opt = if b_prev.is_empty() { None } else { Some(b_prev.as_str()) };
let a_norm: (&[u8], &str) = (a_prev_opt.map(str::as_bytes).unwrap_or(&[]), &a_payload);
let b_norm: (&[u8], &str) = (b_prev_opt.map(str::as_bytes).unwrap_or(&[]), &b_payload);
prop_assume!(a_norm != b_norm);
let ha = event_hash(a_prev_opt, &a_payload);
let hb = event_hash(b_prev_opt, &b_payload);
prop_assert_ne!(ha, hb);
}
}
#[test]
fn boundary_confusion_regression() {
let h1 = event_hash(Some("AB"), "CD");
let h2 = event_hash(Some("ABC"), "D");
assert_ne!(
h1, h2,
"naive concatenation would collide; length-prefix framing must prevent this"
);
}
#[test]
fn domain_tag_prevents_cross_domain_collision() {
let prev = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let p = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
let with_tag = event_hash(Some(prev), p);
let mut hasher = blake3::Hasher::new();
hasher.update(&(prev.len() as u64).to_le_bytes());
hasher.update(prev.as_bytes());
hasher.update(&(p.len() as u64).to_le_bytes());
hasher.update(p.as_bytes());
let no_tag = hasher.finalize().to_hex().to_string();
assert_ne!(with_tag, no_tag, "domain tag must change hash output");
}
}