use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub fn merkle_root(leaves: &[String]) -> Option<String> {
if leaves.is_empty() {
return None;
}
let mut current: Vec<Vec<u8>> = leaves
.iter()
.map(|h| Sha256::digest(h.as_bytes()).to_vec())
.collect();
while current.len() > 1 {
let mut next = Vec::with_capacity(current.len().div_ceil(2));
for pair in current.chunks(2) {
let mut hasher = Sha256::new();
hasher.update(&pair[0]);
if pair.len() == 2 {
hasher.update(&pair[1]);
} else {
hasher.update(&pair[0]);
}
next.push(hasher.finalize().to_vec());
}
current = next;
}
Some(format!("sha256:{:x}", Sha256::digest(¤t[0])))
}
pub fn merkle_root_from_log(jsonl: &str) -> Option<String> {
let hashes: Vec<String> = jsonl
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
v.get("entry_hash")?.as_str().map(|s| s.to_string())
})
.collect();
merkle_root(&hashes)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WitnessRecord {
pub merkle_root: String,
pub entry_count: u64,
pub timestamp: String,
pub log_source: String,
#[serde(default)]
pub signature: String,
#[serde(default)]
pub signer_kid: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ReplicationError {
#[error("I/O error: {reason}")]
Io { reason: String },
#[error("backend unavailable: {reason}")]
Unavailable { reason: String },
}
pub trait AuditReplicator: Send + Sync {
fn replicate_entry(&mut self, jsonl_line: &str) -> Result<(), ReplicationError>;
fn flush(&mut self) -> Result<(), ReplicationError>;
fn backend_name(&self) -> &str;
}
pub struct FileReplicator {
writer: std::io::BufWriter<std::fs::File>,
}
impl FileReplicator {
pub fn open(path: &std::path::Path) -> Result<Self, ReplicationError> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| ReplicationError::Io {
reason: format!("{}: {e}", path.display()),
})?;
Ok(Self {
writer: std::io::BufWriter::new(file),
})
}
}
impl AuditReplicator for FileReplicator {
fn replicate_entry(&mut self, jsonl_line: &str) -> Result<(), ReplicationError> {
use std::io::Write;
writeln!(self.writer, "{jsonl_line}").map_err(|e| ReplicationError::Io {
reason: e.to_string(),
})
}
fn flush(&mut self) -> Result<(), ReplicationError> {
use std::io::Write;
self.writer.flush().map_err(|e| ReplicationError::Io {
reason: e.to_string(),
})
}
fn backend_name(&self) -> &str {
"file"
}
}
pub struct S3Replicator {
bucket: String,
prefix: String,
}
impl S3Replicator {
pub fn new(bucket: String, prefix: String) -> Self {
Self { bucket, prefix }
}
}
impl AuditReplicator for S3Replicator {
fn replicate_entry(&mut self, _jsonl_line: &str) -> Result<(), ReplicationError> {
Err(ReplicationError::Unavailable {
reason: format!(
"S3 replicator not yet implemented — target: s3://{}/{}",
self.bucket, self.prefix
),
})
}
fn flush(&mut self) -> Result<(), ReplicationError> {
Ok(())
}
fn backend_name(&self) -> &str {
"s3"
}
}
pub struct WebhookWitness {
url: String,
}
impl WebhookWitness {
pub fn new(url: String) -> Self {
Self { url }
}
pub fn publish(&self, _record: &WitnessRecord) -> Result<(), ReplicationError> {
Err(ReplicationError::Unavailable {
reason: format!("webhook witness not yet implemented — target: {}", self.url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merkle_root_empty_returns_none() {
assert!(merkle_root(&[]).is_none());
}
#[test]
fn merkle_root_single_leaf() {
let root = merkle_root(&["sha256:aaa".into()]);
assert!(root.is_some());
assert!(root.unwrap().starts_with("sha256:"));
}
#[test]
fn merkle_root_two_leaves() {
let root = merkle_root(&["sha256:aaa".into(), "sha256:bbb".into()]);
assert!(root.is_some());
}
#[test]
fn merkle_root_deterministic() {
let leaves = vec!["a".into(), "b".into(), "c".into()];
let r1 = merkle_root(&leaves).unwrap();
let r2 = merkle_root(&leaves).unwrap();
assert_eq!(r1, r2);
}
#[test]
fn merkle_root_different_inputs_differ() {
let r1 = merkle_root(&["a".into(), "b".into()]).unwrap();
let r2 = merkle_root(&["a".into(), "c".into()]).unwrap();
assert_ne!(r1, r2);
}
#[test]
fn merkle_root_order_matters() {
let r1 = merkle_root(&["a".into(), "b".into()]).unwrap();
let r2 = merkle_root(&["b".into(), "a".into()]).unwrap();
assert_ne!(r1, r2, "Merkle tree is order-sensitive");
}
#[test]
fn merkle_root_odd_leaves() {
let root = merkle_root(&["a".into(), "b".into(), "c".into()]);
assert!(root.is_some());
}
#[test]
fn merkle_root_from_log_valid() {
let log = r#"{"entry_hash":"sha256:aaa","sequence":0}
{"entry_hash":"sha256:bbb","sequence":1}
{"entry_hash":"sha256:ccc","sequence":2}"#;
let root = merkle_root_from_log(log);
assert!(root.is_some());
}
#[test]
fn merkle_root_from_log_empty() {
assert!(merkle_root_from_log("").is_none());
assert!(merkle_root_from_log("\n\n").is_none());
}
#[test]
fn merkle_root_from_log_ignores_invalid_lines() {
let log = "not json\n{\"entry_hash\":\"sha256:aaa\"}\nmore garbage";
let root = merkle_root_from_log(log);
assert!(root.is_some()); }
#[test]
fn witness_record_serde_roundtrip() {
let record = WitnessRecord {
merkle_root: "sha256:abc".into(),
entry_count: 100,
timestamp: "2026-03-30T00:00:00Z".into(),
log_source: "audit.jsonl".into(),
signature: "sig".into(),
signer_kid: "kid".into(),
};
let json = serde_json::to_string(&record).unwrap();
let parsed: WitnessRecord = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, record);
}
#[test]
fn file_replicator_writes_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("replica.jsonl");
let mut repl = FileReplicator::open(&path).unwrap();
assert_eq!(repl.backend_name(), "file");
repl.replicate_entry(r#"{"sequence":0}"#).unwrap();
repl.replicate_entry(r#"{"sequence":1}"#).unwrap();
repl.flush().unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\"sequence\":0"));
assert!(lines[1].contains("\"sequence\":1"));
}
#[test]
fn file_replicator_appends() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("append.jsonl");
{
let mut repl = FileReplicator::open(&path).unwrap();
repl.replicate_entry("line1").unwrap();
repl.flush().unwrap();
}
{
let mut repl = FileReplicator::open(&path).unwrap();
repl.replicate_entry("line2").unwrap();
repl.flush().unwrap();
}
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
}
#[test]
fn s3_replicator_returns_unavailable() {
let mut repl = S3Replicator::new("bucket".into(), "prefix/".into());
assert_eq!(repl.backend_name(), "s3");
let err = repl.replicate_entry("test").unwrap_err();
assert!(matches!(err, ReplicationError::Unavailable { .. }));
}
#[test]
fn webhook_witness_returns_unavailable() {
let w = WebhookWitness::new("https://witness.example.com".into());
let record = WitnessRecord {
merkle_root: "sha256:test".into(),
entry_count: 0,
timestamp: "2026-01-01T00:00:00Z".into(),
log_source: "test".into(),
signature: String::new(),
signer_kid: String::new(),
};
let err = w.publish(&record).unwrap_err();
assert!(matches!(err, ReplicationError::Unavailable { .. }));
}
}