use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProvRecord {
pub engine_commit: String,
pub input_hashes: Vec<String>,
pub seed: Option<u64>,
pub nondeterminism_notes: Vec<String>,
pub lockfile_hash: String,
pub timestamp: Option<String>,
pub tolerance: Option<String>,
}
const DOMAIN_TAG: &[u8] = b"kshana.assurance.provenance.v1";
fn absorb(running: [u8; 32], label: &[u8], bytes: &[u8]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(running);
h.update((label.len() as u64).to_le_bytes());
h.update(label);
h.update((bytes.len() as u64).to_le_bytes());
h.update(bytes);
h.finalize().into()
}
fn absorb_opt_str(running: [u8; 32], label: &[u8], value: Option<&str>) -> [u8; 32] {
match value {
None => absorb(running, label, &[0u8]),
Some(v) => {
let mut buf = Vec::with_capacity(1 + v.len());
buf.push(1u8);
buf.extend_from_slice(v.as_bytes());
absorb(running, label, &buf)
}
}
}
fn absorb_str_vec(running: [u8; 32], label: &[u8], items: &[String]) -> [u8; 32] {
let mut buf = Vec::new();
buf.extend_from_slice(&(items.len() as u64).to_le_bytes());
for item in items {
buf.extend_from_slice(&(item.len() as u64).to_le_bytes());
buf.extend_from_slice(item.as_bytes());
}
absorb(running, label, &buf)
}
impl ProvRecord {
pub fn merkle_root(&self) -> String {
let mut running = {
let mut h = Sha256::new();
h.update(DOMAIN_TAG);
let out: [u8; 32] = h.finalize().into();
out
};
running = absorb(running, b"engine_commit", self.engine_commit.as_bytes());
running = absorb_str_vec(running, b"input_hashes", &self.input_hashes);
running = match self.seed {
None => absorb(running, b"seed", &[0u8]),
Some(s) => {
let mut buf = [0u8; 9];
buf[0] = 1;
buf[1..].copy_from_slice(&s.to_le_bytes());
absorb(running, b"seed", &buf)
}
};
running = absorb_str_vec(running, b"nondeterminism_notes", &self.nondeterminism_notes);
running = absorb(running, b"lockfile_hash", self.lockfile_hash.as_bytes());
running = absorb_opt_str(running, b"timestamp", self.timestamp.as_deref());
running = absorb_opt_str(running, b"tolerance", self.tolerance.as_deref());
hex::encode(running)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ProvRecord {
ProvRecord {
engine_commit: "4c07fedabc".to_string(),
input_hashes: vec!["aa11".to_string(), "bb22".to_string()],
seed: Some(42),
nondeterminism_notes: vec!["thread scheduling".to_string()],
lockfile_hash: "deadbeef".to_string(),
timestamp: Some("2026-06-29T00:00:00Z".to_string()),
tolerance: Some("1e-9".to_string()),
}
}
#[test]
fn merkle_root_is_stable_for_fixed_inputs() {
let r = sample();
let a = r.merkle_root();
let b = r.merkle_root();
assert_eq!(a, b, "root must be deterministic for identical inputs");
assert_eq!(a.len(), 64, "root must be 64 hex chars (SHA-256)");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn changing_any_field_changes_the_root() {
let base = sample();
let base_root = base.merkle_root();
let mut r = sample();
r.engine_commit = "0000000000".to_string();
assert_ne!(r.merkle_root(), base_root, "engine_commit must affect root");
let mut r = sample();
r.input_hashes = vec!["aa11".to_string(), "bb23".to_string()];
assert_ne!(r.merkle_root(), base_root, "input_hashes must affect root");
let mut r = sample();
r.seed = Some(43);
assert_ne!(r.merkle_root(), base_root, "seed value must affect root");
let mut r = sample();
r.seed = None;
assert_ne!(r.merkle_root(), base_root, "seed presence must affect root");
let mut r = sample();
r.nondeterminism_notes = vec!["thread scheduling".to_string(), "fma".to_string()];
assert_ne!(r.merkle_root(), base_root, "notes must affect root");
let mut r = sample();
r.lockfile_hash = "feedface".to_string();
assert_ne!(r.merkle_root(), base_root, "lockfile_hash must affect root");
let mut r = sample();
r.timestamp = Some("2026-06-30T00:00:00Z".to_string());
assert_ne!(r.merkle_root(), base_root, "timestamp must affect root");
let mut r = sample();
r.timestamp = None;
assert_ne!(
r.merkle_root(),
base_root,
"timestamp presence must affect root"
);
let mut r = sample();
r.tolerance = Some("1e-6".to_string());
assert_ne!(r.merkle_root(), base_root, "tolerance must affect root");
let mut r = sample();
r.tolerance = None;
assert_ne!(
r.merkle_root(),
base_root,
"tolerance presence must affect root"
);
}
#[test]
fn field_boundary_is_unambiguous() {
let mut r1 = sample();
r1.engine_commit = "ab".to_string();
r1.lockfile_hash = "c".to_string();
let mut r2 = sample();
r2.engine_commit = "a".to_string();
r2.lockfile_hash = "bc".to_string();
assert_ne!(r1.merkle_root(), r2.merkle_root());
}
#[test]
fn serde_json_round_trip_preserves_record() {
let r = sample();
let json = serde_json::to_string(&r).expect("serialize");
let back: ProvRecord = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.engine_commit, r.engine_commit);
assert_eq!(back.input_hashes, r.input_hashes);
assert_eq!(back.seed, r.seed);
assert_eq!(back.nondeterminism_notes, r.nondeterminism_notes);
assert_eq!(back.lockfile_hash, r.lockfile_hash);
assert_eq!(back.timestamp, r.timestamp);
assert_eq!(back.tolerance, r.tolerance);
assert_eq!(back.merkle_root(), r.merkle_root());
}
}