use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditKind {
SessionOpen,
SessionClose,
Request,
BlobFetch,
Trust,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditRecord {
pub ts: String,
pub kind: AuditKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub peer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_out: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
impl AuditRecord {
fn base(ts: String, kind: AuditKind) -> Self {
Self {
ts,
kind,
peer: None,
service: None,
method: None,
tool: None,
args_hash: None,
bytes_out: None,
status: None,
latency_ms: None,
event: None,
target: None,
}
}
pub fn session_open(ts: String, peer: Option<String>, service: String) -> Self {
let mut r = Self::base(ts, AuditKind::SessionOpen);
r.peer = peer;
r.service = Some(service);
r
}
pub fn session_close(ts: String, peer: Option<String>, service: String) -> Self {
let mut r = Self::base(ts, AuditKind::SessionClose);
r.peer = peer;
r.service = Some(service);
r
}
#[allow(clippy::too_many_arguments)]
pub fn proxied_request(
ts: String,
peer: Option<String>,
service: String,
method: String,
tool: Option<String>,
args_hash: String,
bytes_out: u64,
status: String,
latency_ms: u64,
) -> Self {
let mut r = Self::base(ts, AuditKind::Request);
r.peer = peer;
r.service = Some(service);
r.method = Some(method);
r.tool = tool;
r.args_hash = Some(args_hash);
r.bytes_out = Some(bytes_out);
r.status = Some(status);
r.latency_ms = Some(latency_ms);
r
}
pub fn proxied_notification(
ts: String,
peer: Option<String>,
service: String,
method: String,
tool: Option<String>,
args_hash: String,
) -> Self {
let mut r = Self::base(ts, AuditKind::Request);
r.peer = peer;
r.service = Some(service);
r.method = Some(method);
r.tool = tool;
r.args_hash = Some(args_hash);
r
}
pub fn blob_fetch(ts: String, peer: Option<String>, hash: String, status: String) -> Self {
let mut r = Self::base(ts, AuditKind::BlobFetch);
r.peer = peer;
r.target = Some(hash);
r.status = Some(status);
r
}
pub fn trust(ts: String, event: String, target: Option<String>) -> Self {
let mut r = Self::base(ts, AuditKind::Trust);
r.event = Some(event);
r.target = target;
r
}
}
pub fn args_hash(params: &Value) -> String {
let bytes = serde_json::to_vec(params).unwrap_or_default();
format!("blake3:{}", blake3::hash(&bytes).to_hex())
}
pub fn now_ts() -> String {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
fmt_rfc3339_millis(millis)
}
pub fn fmt_rfc3339_millis(unix_millis: u128) -> String {
let secs = (unix_millis / 1000) as i64;
let millis = (unix_millis % 1000) as u64;
let days = secs.div_euclid(86_400);
let secs_of_day = secs.rem_euclid(86_400);
let (h, m, s) = (
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
);
let (year, month, day) = civil_from_days(days);
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{millis:03}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn timestamp_formats_epoch_and_millis() {
assert_eq!(fmt_rfc3339_millis(0), "1970-01-01T00:00:00.000Z");
assert_eq!(fmt_rfc3339_millis(1500), "1970-01-01T00:00:01.500Z");
assert_eq!(
fmt_rfc3339_millis(1_700_000_000_000),
"2023-11-14T22:13:20.000Z"
);
assert_eq!(&fmt_rfc3339_millis(1_700_000_000_000)[..7], "2023-11");
}
#[test]
fn args_hash_is_blake3_prefixed_and_deterministic() {
let p = json!({"path": "/secret/passwords.txt"});
let h = args_hash(&p);
assert!(h.starts_with("blake3:"));
assert_eq!(h, args_hash(&p), "same params → same hash");
assert_ne!(h, args_hash(&json!({"path": "/other"})), "differs by input");
assert_eq!(h.len(), "blake3:".len() + 64);
}
#[test]
fn proxied_request_serialization_never_leaks_raw_arguments() {
let secret = "hunter2-super-secret-token";
let params = json!({"name": "read_file", "arguments": {"token": secret}});
let rec = AuditRecord::proxied_request(
"2026-07-03T14:02:11.480Z".into(),
Some("bob".into()),
"notes".into(),
"tools/call".into(),
Some("read_file".into()),
args_hash(¶ms),
6210,
"ok".into(),
41,
);
let line = serde_json::to_string(&rec).unwrap();
assert!(
!line.contains(secret),
"raw argument leaked into the record: {line}"
);
assert!(line.contains("blake3:"), "args_hash digest must be present");
assert!(
line.contains("\"tool\":\"read_file\""),
"tool NAME is recorded"
);
assert!(line.contains("\"method\":\"tools/call\""));
let s = AuditRecord::session_open(
"2026-07-03T14:02:11.480Z".into(),
Some("bob".into()),
"notes".into(),
);
let sline = serde_json::to_string(&s).unwrap();
assert!(sline.contains("\"kind\":\"session_open\""));
assert!(
!sline.contains("method"),
"session record elides request fields"
);
}
#[test]
fn record_round_trips_through_jsonl() {
let rec = AuditRecord::trust(
"2026-07-03T14:02:11.480Z".into(),
"pair".into(),
Some("bob".into()),
);
let line = serde_json::to_string(&rec).unwrap();
let back: AuditRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back, rec);
assert_eq!(back.kind, AuditKind::Trust);
}
}