use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Mutex, OnceLock};
use anyhow::{Context, Result, anyhow};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use chrono::{DateTime, Datelike, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const AUDIT_TRACE_TARGET: &str = "ai_memory::governance::audit";
pub const CHAIN_HEAD_PREV_HASH: &str =
"0000000000000000000000000000000000000000000000000000000000000000";
pub const FORENSIC_FILE_PREFIX: &str = "forensic-";
pub const FORENSIC_FILE_SUFFIX: &str = ".jsonl";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ForensicDecision {
pub ts: String,
pub actor: String,
pub decision: String,
pub kind: String,
pub rule_id: String,
pub payload: serde_json::Value,
pub prev_hash: String,
pub sig: String,
}
impl ForensicDecision {
#[must_use]
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut clone = self.clone();
clone.sig.clear();
serde_json::to_vec(&clone).expect("ForensicDecision always serialises")
}
#[must_use]
pub fn self_hash(&self) -> String {
let mut h = Sha256::new();
h.update(self.canonical_bytes());
hex_encode(&h.finalize())
}
}
fn hex_encode(bytes: &[u8]) -> String {
static HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
static SINK: OnceLock<Mutex<Option<ForensicSink>>> = OnceLock::new();
fn sink() -> &'static Mutex<Option<ForensicSink>> {
SINK.get_or_init(|| Mutex::new(None))
}
const WRITER_THREAD_NAME: &str = "ai-memory-audit-writer";
enum WriteOp {
Append {
path: PathBuf,
line: String,
},
Barrier(Sender<()>),
Reset,
}
static WRITER: OnceLock<Sender<WriteOp>> = OnceLock::new();
fn writer() -> &'static Sender<WriteOp> {
WRITER.get_or_init(|| {
let (tx, rx) = std::sync::mpsc::channel::<WriteOp>();
std::thread::Builder::new()
.name(WRITER_THREAD_NAME.to_string())
.spawn(move || run_writer(rx))
.expect("spawning the forensic audit writer thread");
tx
})
}
fn run_writer(rx: Receiver<WriteOp>) {
let mut open_file: Option<(PathBuf, File)> = None;
let mut pending_barriers: Vec<Sender<()>> = Vec::new();
while let Ok(first) = rx.recv() {
let mut batch = vec![first];
while let Ok(next) = rx.try_recv() {
batch.push(next);
}
let mut needs_flush = false;
for op in batch {
match op {
WriteOp::Append { path, line } => {
let reopen = open_file.as_ref().map_or(true, |(p, _)| p != &path);
if reopen {
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(file) => open_file = Some((path, file)),
Err(e) => {
tracing::error!(
target: AUDIT_TRACE_TARGET,
"forensic: opening {} failed: {e}",
path.display()
);
open_file = None;
continue;
}
}
}
if let Some((path, file)) = open_file.as_mut() {
if let Err(e) = writeln!(file, "{line}") {
tracing::error!(
target: AUDIT_TRACE_TARGET,
"forensic: appending to {} failed: {e}",
path.display()
);
} else {
needs_flush = true;
}
}
}
WriteOp::Barrier(ack) => pending_barriers.push(ack),
WriteOp::Reset => {
if let Some((_, file)) = open_file.as_mut() {
let _ = file.flush();
}
open_file = None;
needs_flush = false;
}
}
}
if needs_flush {
if let Some((_, file)) = open_file.as_mut() {
let _ = file.flush();
}
}
for ack in pending_barriers.drain(..) {
let _ = ack.send(());
}
}
}
pub fn flush_blocking() {
let (ack, done) = std::sync::mpsc::channel();
if writer().send(WriteOp::Barrier(ack)).is_ok() {
let _ = done.recv();
}
}
#[cfg(test)]
pub(crate) fn enqueue_append_for_test(path: PathBuf, line: String) {
let _ = writer().send(WriteOp::Append { path, line });
}
static DAEMON_AUDIT_KEY: OnceLock<SigningKey> = OnceLock::new();
#[must_use]
pub fn try_sign_audit_payload(payload_hash: &[u8]) -> Option<(Vec<u8>, &'static str)> {
let key = DAEMON_AUDIT_KEY.get()?;
let sig: Signature = key.sign(payload_hash);
Some((
sig.to_bytes().to_vec(),
crate::models::AttestLevel::DaemonSigned.as_str(),
))
}
#[must_use]
pub fn audit_key_is_installed() -> bool {
DAEMON_AUDIT_KEY.get().is_some()
}
struct ForensicSink {
dir: PathBuf,
last_hash: String,
signing_key: Option<SigningKey>,
}
pub fn init(dir: &Path, signing_key: Option<SigningKey>) -> Result<()> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating forensic audit dir {}", dir.display()))?;
let last_hash = read_chain_tail(dir).unwrap_or_else(|| CHAIN_HEAD_PREV_HASH.to_string());
if let Some(key) = signing_key.as_ref() {
let _ = DAEMON_AUDIT_KEY.set(key.clone());
}
let new_sink = ForensicSink {
dir: dir.to_path_buf(),
last_hash,
signing_key,
};
let mut guard = sink()
.lock()
.map_err(|_| anyhow!("forensic sink mutex poisoned"))?;
let _ = writer().send(WriteOp::Reset);
*guard = Some(new_sink);
Ok(())
}
pub fn shutdown() {
flush_blocking();
if let Ok(mut guard) = sink().lock() {
*guard = None;
}
}
#[must_use]
pub fn is_enabled() -> bool {
sink().lock().map(|g| g.is_some()).unwrap_or(false)
}
pub fn try_record_decision(
actor: &str,
decision: &str,
kind: &str,
rule_id: &str,
payload: serde_json::Value,
) -> Result<()> {
let mut guard = sink()
.lock()
.map_err(|_| anyhow!("forensic sink mutex poisoned"))?;
let Some(s) = guard.as_mut() else {
return Ok(());
};
let now = Utc::now();
let ts = now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let prev_hash = s.last_hash.clone();
let mut row = ForensicDecision {
ts,
actor: actor.to_string(),
decision: decision.to_string(),
kind: kind.to_string(),
rule_id: rule_id.to_string(),
payload,
prev_hash,
sig: String::new(),
};
if let Some(key) = &s.signing_key {
let canonical = row.canonical_bytes();
let sig: Signature = key.sign(&canonical);
row.sig = B64.encode(sig.to_bytes());
}
let self_hash = row.self_hash();
let line = serde_json::to_string(&row).context("serialising forensic row")?;
let file_path = daily_path(&s.dir, &now);
s.last_hash = self_hash;
writer()
.send(WriteOp::Append {
path: file_path,
line,
})
.map_err(|_| anyhow!("forensic audit writer thread has stopped"))?;
Ok(())
}
pub fn record_decision(
actor: &str,
decision: &str,
kind: &str,
rule_id: &str,
payload: serde_json::Value,
) {
if let Err(e) = try_record_decision(actor, decision, kind, rule_id, payload) {
tracing::error!(
target: AUDIT_TRACE_TARGET,
"forensic: emission failed: {e}"
);
}
}
fn daily_path(dir: &Path, when: &DateTime<Utc>) -> PathBuf {
let date = when.format("%Y-%m-%d").to_string();
dir.join(format!(
"{FORENSIC_FILE_PREFIX}{date}{FORENSIC_FILE_SUFFIX}"
))
}
fn read_chain_tail(dir: &Path) -> Option<String> {
let files = list_forensic_files(dir).ok()?;
let last_file = files.last()?;
let f = File::open(last_file).ok()?;
let mut last_hash: Option<String> = None;
for line in BufReader::new(f).lines() {
let Ok(line) = line else { continue };
if line.trim().is_empty() {
continue;
}
if let Ok(row) = serde_json::from_str::<ForensicDecision>(&line) {
last_hash = Some(row.self_hash());
}
}
last_hash
}
fn list_forensic_files(dir: &Path) -> Result<Vec<PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut out: Vec<PathBuf> = Vec::new();
for entry in
std::fs::read_dir(dir).with_context(|| format!("reading forensic dir {}", dir.display()))?
{
let entry = entry?;
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if name_str.starts_with(FORENSIC_FILE_PREFIX) && name_str.ends_with(FORENSIC_FILE_SUFFIX) {
out.push(entry.path());
}
}
out.sort();
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct VerifyReport {
pub total_lines: u64,
pub unsigned_lines: u64,
pub first_failure: Option<VerifyFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyFailure {
pub line_number: u64,
pub file: PathBuf,
pub kind: VerifyFailureKind,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyFailureKind {
Parse,
ChainBreak,
Signature,
}
pub fn verify_since(
dir: &Path,
since: &str,
public_key: Option<&VerifyingKey>,
) -> Result<VerifyReport> {
let cutoff = parse_iso_date(since)?;
let files = list_forensic_files(dir)?;
let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
let mut total: u64 = 0;
let mut unsigned: u64 = 0;
for file in &files {
let date = file_date(file)?;
if date >= cutoff {
break;
}
let f = File::open(file).with_context(|| crate::errors::msg::opening(file.display()))?;
for line in BufReader::new(f).lines() {
let Ok(line) = line else { continue };
if line.trim().is_empty() {
continue;
}
if let Ok(row) = serde_json::from_str::<ForensicDecision>(&line) {
prev_hash = row.self_hash();
}
}
}
for file in &files {
let date = file_date(file)?;
if date < cutoff {
continue;
}
let f = File::open(file).with_context(|| crate::errors::msg::opening(file.display()))?;
for (idx, line) in BufReader::new(f).lines().enumerate() {
let line_no = (idx as u64) + 1;
let line = line.with_context(|| format!("reading {}:{line_no}", file.display()))?;
if line.trim().is_empty() {
continue;
}
let row: ForensicDecision = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
return Ok(VerifyReport {
total_lines: total,
unsigned_lines: unsigned,
first_failure: Some(VerifyFailure {
line_number: line_no,
file: file.clone(),
kind: VerifyFailureKind::Parse,
detail: format!("malformed JSON: {e}"),
}),
});
}
};
total += 1;
if row.prev_hash != prev_hash {
return Ok(VerifyReport {
total_lines: total,
unsigned_lines: unsigned,
first_failure: Some(VerifyFailure {
line_number: line_no,
file: file.clone(),
kind: VerifyFailureKind::ChainBreak,
detail: format!(
"prev_hash mismatch: expected {prev_hash}, got {}",
row.prev_hash
),
}),
});
}
if row.sig.is_empty() {
unsigned += 1;
} else if let Some(pk) = public_key {
let canonical = row.canonical_bytes();
let sig_bytes = match B64.decode(row.sig.as_bytes()) {
Ok(b) => b,
Err(e) => {
return Ok(VerifyReport {
total_lines: total,
unsigned_lines: unsigned,
first_failure: Some(VerifyFailure {
line_number: line_no,
file: file.clone(),
kind: VerifyFailureKind::Signature,
detail: format!("base64 decode failed: {e}"),
}),
});
}
};
if sig_bytes.len() != 64 {
return Ok(VerifyReport {
total_lines: total,
unsigned_lines: unsigned,
first_failure: Some(VerifyFailure {
line_number: line_no,
file: file.clone(),
kind: VerifyFailureKind::Signature,
detail: format!("signature has {} bytes, expected 64", sig_bytes.len()),
}),
});
}
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = Signature::from_bytes(&sig_arr);
if let Err(e) = pk.verify(&canonical, &sig) {
return Ok(VerifyReport {
total_lines: total,
unsigned_lines: unsigned,
first_failure: Some(VerifyFailure {
line_number: line_no,
file: file.clone(),
kind: VerifyFailureKind::Signature,
detail: crate::errors::msg::signature_verify_failed(e),
}),
});
}
}
prev_hash = row.self_hash();
}
}
Ok(VerifyReport {
total_lines: total,
unsigned_lines: unsigned,
first_failure: None,
})
}
fn parse_iso_date(s: &str) -> Result<i64> {
let dt = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.with_context(|| format!("parsing --since {s} as YYYY-MM-DD"))?;
Ok(i64::from(dt.year_ce().1 as i32) * 10000
+ i64::from(dt.month() as i32) * 100
+ i64::from(dt.day() as i32))
}
fn file_date(path: &Path) -> Result<i64> {
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("forensic file has non-UTF8 name: {}", path.display()))?;
let stem = name
.strip_prefix(FORENSIC_FILE_PREFIX)
.and_then(|s| s.strip_suffix(FORENSIC_FILE_SUFFIX))
.ok_or_else(|| {
anyhow!("forensic file name not in forensic-YYYY-MM-DD.jsonl shape: {name}")
})?;
parse_iso_date(stem)
}
fn signing_key_load_is_absent(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
})
}
pub fn load_daemon_signing_key(agent_id: &str) -> Result<Option<SigningKey>> {
let dir = crate::identity::keypair::default_key_dir()?;
if !dir.exists() {
return Ok(None);
}
let kp = match crate::identity::keypair::load(agent_id, &dir) {
Ok(k) => k,
Err(e) => {
if signing_key_load_is_absent(&e) {
tracing::debug!(
agent_id,
"no daemon signing key enrolled; operating unsigned \
(expected when no key is provisioned)"
);
} else {
tracing::warn!(
agent_id,
error = %e,
"daemon signing key is present but could not be loaded; \
federation/audit signing falls back to UNSIGNED — peers \
requiring signatures will reject posts. Fix the key file."
);
}
return Ok(None);
}
};
Ok(kp.private)
}
pub fn load_daemon_verifying_key(agent_id: &str) -> Result<Option<VerifyingKey>> {
let dir = crate::identity::keypair::default_key_dir()?;
if !dir.exists() {
return Ok(None);
}
match crate::identity::keypair::load(agent_id, &dir) {
Ok(kp) => Ok(Some(kp.public)),
Err(_) => Ok(None),
}
}
#[must_use]
pub fn resolve_daemon_verifying_key() -> Option<VerifyingKey> {
DAEMON_AUDIT_KEY.get().map(SigningKey::verifying_key)
}
#[cfg(test)]
pub(crate) fn forensic_sink_test_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand_core::OsRng;
use tempfile::TempDir;
fn test_lock() -> &'static std::sync::Mutex<()> {
forensic_sink_test_lock()
}
fn fresh_key() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
fn fresh_init(dir: &Path, key: Option<SigningKey>) {
shutdown();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let _ = std::fs::remove_file(entry.path());
}
}
init(dir, key).expect("forensic init");
}
#[test]
fn record_then_verify_signed_chain() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let key = fresh_key();
let pubkey = key.verifying_key();
fresh_init(tmp.path(), Some(key));
for i in 0..3 {
record_decision(
"ai:test",
"allow",
"bash",
&format!("R00{i}"),
serde_json::json!({"command": format!("ls -la /{i}")}),
);
}
shutdown();
let since = Utc::now().format("%Y-%m-%d").to_string();
let report = verify_since(tmp.path(), &since, Some(&pubkey)).expect("verify");
assert!(report.first_failure.is_none(), "{:?}", report.first_failure);
assert!(
report.total_lines >= 3,
"expected at least 3 own rows; got {} — record path is broken",
report.total_lines
);
}
#[test]
fn tampering_detected_by_verify() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let key = fresh_key();
let pubkey = key.verifying_key();
fresh_init(tmp.path(), Some(key));
record_decision(
"ai:t",
"refuse",
"bash",
"R001",
serde_json::json!({"r":"no"}),
);
record_decision("ai:t", "allow", "bash", "R002", serde_json::json!({}));
shutdown();
let date = Utc::now().format("%Y-%m-%d").to_string();
let path = tmp.path().join(format!("forensic-{date}.jsonl"));
let body = std::fs::read_to_string(&path).unwrap();
let tampered = body.replacen("\"ai:t\"", "\"evil\"", 1);
std::fs::write(&path, tampered).unwrap();
let report = verify_since(tmp.path(), &date, Some(&pubkey)).expect("verify");
let failure = report.first_failure.expect("tamper must be flagged");
assert!(matches!(
failure.kind,
VerifyFailureKind::Signature | VerifyFailureKind::ChainBreak
));
}
#[test]
fn unsigned_rows_counted_not_failed() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
fresh_init(tmp.path(), None);
record_decision("ai:t", "allow", "bash", "R001", serde_json::json!({}));
record_decision("ai:t", "allow", "bash", "R002", serde_json::json!({}));
shutdown();
let since = Utc::now().format("%Y-%m-%d").to_string();
let report = verify_since(tmp.path(), &since, None).expect("verify");
assert!(report.first_failure.is_none());
assert!(report.total_lines >= 2);
assert_eq!(report.unsigned_lines, report.total_lines);
}
#[test]
fn parse_iso_date_basic() {
assert!(parse_iso_date("2026-05-18").is_ok());
assert!(parse_iso_date("not-a-date").is_err());
}
#[test]
fn record_when_disabled_is_noop() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
shutdown();
record_decision("ai:t", "allow", "bash", "R001", serde_json::json!({}));
assert!(!is_enabled());
}
fn write_forensic_file(dir: &Path, date: &str, body: &str) -> PathBuf {
let path = dir.join(format!(
"{FORENSIC_FILE_PREFIX}{date}{FORENSIC_FILE_SUFFIX}"
));
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn verify_since_parse_failure_first() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let today = Utc::now().format("%Y-%m-%d").to_string();
write_forensic_file(tmp.path(), &today, "{not-json\n");
let report = verify_since(tmp.path(), &today, None).expect("verify ran");
let f = report.first_failure.expect("parse failure surfaces");
assert!(
matches!(f.kind, VerifyFailureKind::Parse),
"expected Parse, got {:?}",
f.kind
);
assert_eq!(f.line_number, 1);
assert!(f.detail.contains("malformed JSON"));
}
#[test]
fn verify_since_chain_break_when_prev_hash_mismatched() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let today = Utc::now().format("%Y-%m-%d").to_string();
let row = serde_json::json!({
"ts": Utc::now().to_rfc3339(),
"actor": "ai:t",
"decision": "allow",
"kind": "bash",
"rule_id": "R001",
"payload": {},
"prev_hash": "deadbeef-not-the-real-head",
"sig": ""
});
let body = format!("{}\n", serde_json::to_string(&row).unwrap());
write_forensic_file(tmp.path(), &today, &body);
let report = verify_since(tmp.path(), &today, None).expect("verify ran");
let f = report.first_failure.expect("chain break surfaces");
assert!(
matches!(f.kind, VerifyFailureKind::ChainBreak),
"expected ChainBreak, got {:?}",
f.kind
);
assert!(f.detail.contains("prev_hash mismatch"));
}
#[test]
fn verify_since_signature_base64_decode_failure() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let today = Utc::now().format("%Y-%m-%d").to_string();
let key = fresh_key();
let pubkey = key.verifying_key();
let row = serde_json::json!({
"ts": Utc::now().to_rfc3339(),
"actor": "ai:t",
"decision": "allow",
"kind": "bash",
"rule_id": "R001",
"payload": {},
"prev_hash": CHAIN_HEAD_PREV_HASH,
"sig": "@@@NOT_BASE64@@@"
});
let body = format!("{}\n", serde_json::to_string(&row).unwrap());
write_forensic_file(tmp.path(), &today, &body);
let report = verify_since(tmp.path(), &today, Some(&pubkey)).expect("verify ran");
let f = report.first_failure.expect("signature failure surfaces");
assert!(
matches!(f.kind, VerifyFailureKind::Signature),
"expected Signature, got {:?}",
f.kind
);
assert!(f.detail.contains("base64 decode failed"));
}
#[test]
fn verify_since_signature_wrong_byte_length() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let today = Utc::now().format("%Y-%m-%d").to_string();
let key = fresh_key();
let pubkey = key.verifying_key();
let sig_short = B64.encode([1u8, 2, 3, 4]);
let row = serde_json::json!({
"ts": Utc::now().to_rfc3339(),
"actor": "ai:t",
"decision": "allow",
"kind": "bash",
"rule_id": "R001",
"payload": {},
"prev_hash": CHAIN_HEAD_PREV_HASH,
"sig": sig_short
});
let body = format!("{}\n", serde_json::to_string(&row).unwrap());
write_forensic_file(tmp.path(), &today, &body);
let report = verify_since(tmp.path(), &today, Some(&pubkey)).expect("verify ran");
let f = report.first_failure.expect("signature failure surfaces");
assert!(matches!(f.kind, VerifyFailureKind::Signature));
assert!(
f.detail.contains("signature has") && f.detail.contains("expected 64"),
"got: {}",
f.detail
);
}
#[test]
fn verify_since_signature_verify_failure_for_wrong_key() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let key_a = fresh_key();
let key_b = fresh_key();
let pub_b = key_b.verifying_key();
fresh_init(tmp.path(), Some(key_a));
record_decision("ai:t", "allow", "bash", "R001", serde_json::json!({}));
shutdown();
let today = Utc::now().format("%Y-%m-%d").to_string();
let report = verify_since(tmp.path(), &today, Some(&pub_b)).expect("verify ran");
let f = report.first_failure.expect("verify failure surfaces");
assert!(matches!(f.kind, VerifyFailureKind::Signature));
assert!(
f.detail.contains("signature verify failed"),
"got: {}",
f.detail
);
}
#[test]
fn verify_since_walks_pre_cutoff_files_to_seed_chain_head() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let key = fresh_key();
let pubkey = key.verifying_key();
let old_row_unsigned_canonical = ForensicDecision {
ts: "2026-01-01T00:00:00.000Z".to_string(),
actor: "ai:old".into(),
decision: "allow".into(),
kind: "bash".into(),
rule_id: "R001".into(),
payload: serde_json::json!({}),
prev_hash: CHAIN_HEAD_PREV_HASH.to_string(),
sig: String::new(),
};
let canonical = old_row_unsigned_canonical.canonical_bytes();
let sig: Signature = key.sign(&canonical);
let mut old_row = old_row_unsigned_canonical;
old_row.sig = B64.encode(sig.to_bytes());
let old_hash = old_row.self_hash();
let old_body = format!("{}\n", serde_json::to_string(&old_row).unwrap());
write_forensic_file(tmp.path(), "2026-01-01", &old_body);
fresh_init(tmp.path(), Some(key));
record_decision("ai:new", "allow", "bash", "R001", serde_json::json!({}));
shutdown();
let today = Utc::now().format("%Y-%m-%d").to_string();
let report = verify_since(tmp.path(), &today, Some(&pubkey)).expect("verify");
assert!(report.first_failure.is_none(), "{:?}", report);
assert!(report.total_lines >= 1);
let _ = old_hash;
}
#[test]
fn verify_since_blank_lines_ignored() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let today = Utc::now().format("%Y-%m-%d").to_string();
write_forensic_file(tmp.path(), &today, "\n\n\n");
let report = verify_since(tmp.path(), &today, None).expect("verify ran");
assert!(report.first_failure.is_none());
assert_eq!(report.total_lines, 0);
}
#[test]
fn verify_since_rejects_unparseable_date() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let err = verify_since(tmp.path(), "not-a-date", None).expect_err("expected parse err");
assert!(err.to_string().contains("parsing --since"));
}
#[test]
fn verify_since_returns_empty_report_when_dir_does_not_exist() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let nonexistent = tmp.path().join("never-created");
let today = Utc::now().format("%Y-%m-%d").to_string();
let report = verify_since(&nonexistent, &today, None).expect("verify ran");
assert!(report.first_failure.is_none());
assert_eq!(report.total_lines, 0);
}
#[test]
fn file_date_errors_for_unrecognised_filename_shape() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let bad = tmp.path().join("not-forensic.txt");
let err = file_date(&bad).expect_err("filename mismatch surfaces");
let chain = format!("{err}");
assert!(
chain.contains("not in forensic-YYYY-MM-DD.jsonl shape"),
"got: {chain}"
);
}
#[test]
fn list_forensic_files_skips_non_matching_names() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("README.md"), "x").unwrap();
std::fs::write(tmp.path().join("forensic-not-a-date.jsonl"), "x").unwrap();
std::fs::write(tmp.path().join("foo.jsonl"), "x").unwrap();
write_forensic_file(tmp.path(), "2026-02-15", "");
let files = list_forensic_files(tmp.path()).unwrap();
let names: Vec<String> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(
names.iter().any(|n| n == "forensic-2026-02-15.jsonl"),
"good file present: {names:?}"
);
assert!(!names.iter().any(|n| n == "README.md"));
assert!(!names.iter().any(|n| n == "foo.jsonl"));
}
#[test]
fn parse_iso_date_edge_cases() {
assert!(parse_iso_date("2024-02-29").is_ok());
assert!(parse_iso_date("2026-13-01").is_err());
assert!(parse_iso_date("").is_err());
let code = parse_iso_date("2026-05-19").unwrap();
assert_eq!(code, 20260519);
}
#[test]
fn read_chain_tail_returns_none_for_empty_dir() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
assert!(read_chain_tail(tmp.path()).is_none());
}
#[test]
fn read_chain_tail_returns_last_hash_after_record() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
fresh_init(tmp.path(), None);
record_decision("ai:t", "allow", "bash", "R001", serde_json::json!({}));
shutdown();
let tail = read_chain_tail(tmp.path()).expect("tail present after record");
assert!(!tail.is_empty());
assert_ne!(tail, CHAIN_HEAD_PREV_HASH);
}
#[test]
fn is_enabled_reflects_sink_state() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
shutdown();
assert!(!is_enabled(), "sink starts disabled after shutdown");
let tmp = TempDir::new().unwrap();
fresh_init(tmp.path(), None);
assert!(is_enabled(), "init flips is_enabled to true");
shutdown();
assert!(!is_enabled(), "shutdown flips it back");
}
#[test]
fn load_daemon_signing_key_returns_none_when_dir_missing() {
let tmp = TempDir::new().unwrap();
let nonexistent = tmp.path().join("never-created");
let _g = crate::identity::keypair::key_dir_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("AI_MEMORY_KEY_DIR").ok();
unsafe {
std::env::set_var("AI_MEMORY_KEY_DIR", &nonexistent);
}
let res = load_daemon_signing_key("ai:nobody");
if let Some(p) = prior {
unsafe {
std::env::set_var("AI_MEMORY_KEY_DIR", p);
}
} else {
unsafe {
std::env::remove_var("AI_MEMORY_KEY_DIR");
}
}
let got = res.expect("non-existent dir returns Ok(None)");
assert!(got.is_none());
}
#[test]
fn load_daemon_verifying_key_returns_none_when_dir_missing() {
let tmp = TempDir::new().unwrap();
let nonexistent = tmp.path().join("never-created");
let _g = crate::identity::keypair::key_dir_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("AI_MEMORY_KEY_DIR").ok();
unsafe {
std::env::set_var("AI_MEMORY_KEY_DIR", &nonexistent);
}
let res = load_daemon_verifying_key("ai:nobody");
if let Some(p) = prior {
unsafe {
std::env::set_var("AI_MEMORY_KEY_DIR", p);
}
} else {
unsafe {
std::env::remove_var("AI_MEMORY_KEY_DIR");
}
}
let got = res.expect("non-existent dir returns Ok(None)");
assert!(got.is_none());
}
#[test]
fn load_daemon_keys_return_none_when_no_keypair_for_agent() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
let _g = crate::identity::keypair::key_dir_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("AI_MEMORY_KEY_DIR").ok();
unsafe {
std::env::set_var("AI_MEMORY_KEY_DIR", tmp.path());
}
let sk = load_daemon_signing_key("ai:no-keypair-on-disk");
let vk = load_daemon_verifying_key("ai:no-keypair-on-disk");
if let Some(p) = prior {
unsafe {
std::env::set_var("AI_MEMORY_KEY_DIR", p);
}
} else {
unsafe {
std::env::remove_var("AI_MEMORY_KEY_DIR");
}
}
assert!(sk.expect("Ok").is_none());
assert!(vk.expect("Ok").is_none());
}
#[test]
fn signing_key_load_is_absent_only_for_notfound_in_chain() {
let notfound: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::NotFound, "no such file").into();
assert!(signing_key_load_is_absent(¬found));
let wrapped: anyhow::Error =
anyhow::Error::from(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
.context("reading public key");
assert!(signing_key_load_is_absent(&wrapped));
let denied: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "mode bits").into();
assert!(!signing_key_load_is_absent(&denied));
let corrupt = anyhow::anyhow!("key material is the wrong length");
assert!(!signing_key_load_is_absent(&corrupt));
}
#[test]
fn cross_thread_bleed_is_reproducible_without_lock_then_recovered_by_fresh_init() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let key = fresh_key();
let pubkey = key.verifying_key();
fresh_init(tmp.path(), Some(key));
let agent_phase_a = "ai:test-a";
let agent_bleed = "ai:bleed-from-elsewhere";
let agent_phase_b = "ai:test-b";
for i in 0..3 {
record_decision(
agent_phase_a,
"allow",
"bash",
&format!("R00{i}"),
serde_json::json!({"a": i}),
);
}
let handle = std::thread::spawn(move || {
record_decision(
agent_bleed,
"allow",
"bash",
"R999",
serde_json::json!({"source": "background-thread"}),
);
});
handle.join().expect("background thread");
shutdown();
let since = Utc::now().format("%Y-%m-%d").to_string();
let report_after_bleed =
verify_since(tmp.path(), &since, Some(&pubkey)).expect("verify after bleed");
assert!(
report_after_bleed.total_lines >= 3,
"expected at least 3 own rows; got {} — bleed-vector test framework broken",
report_after_bleed.total_lines
);
fresh_init(tmp.path(), Some(fresh_key()));
record_decision(
agent_phase_b,
"allow",
"bash",
"R001",
serde_json::json!({"b": 1}),
);
shutdown();
let recovered_path = tmp.path().join(format!(
"{FORENSIC_FILE_PREFIX}{since}{FORENSIC_FILE_SUFFIX}"
));
let recovered =
std::fs::read_to_string(&recovered_path).expect("read recovered forensic file");
assert!(
!recovered.contains(agent_phase_a),
"fresh_init must clear pre-bleed phase-A rows; found {agent_phase_a} in {recovered_path:?}"
);
assert!(
!recovered.contains(agent_bleed),
"fresh_init must clear the bled row; found {agent_bleed} in {recovered_path:?}"
);
assert!(
recovered.contains(agent_phase_b),
"test-B's own row must survive fresh_init; missing {agent_phase_b} in {recovered_path:?}"
);
}
#[test]
fn flush_blocking_makes_records_durable_without_shutdown() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
fresh_init(tmp.path(), None);
let actor = "ai:flush-durable-test";
let n = 25;
for i in 0..n {
record_decision(
actor,
"allow",
"bash",
"R001",
serde_json::json!({ "i": i }),
);
}
flush_blocking();
let date = Utc::now().format("%Y-%m-%d").to_string();
let path = tmp.path().join(format!("forensic-{date}.jsonl"));
let body = std::fs::read_to_string(&path).expect("file written by background writer");
let ours = body
.lines()
.filter_map(|l| serde_json::from_str::<ForensicDecision>(l).ok())
.filter(|row| row.actor == actor)
.count();
assert_eq!(ours, n, "every enqueued row drained to disk");
shutdown();
}
#[test]
fn writer_reopens_when_destination_path_changes() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp_a = TempDir::new().unwrap();
fresh_init(tmp_a.path(), None);
record_decision("ai:a", "allow", "bash", "R001", serde_json::json!({}));
shutdown();
let tmp_b = TempDir::new().unwrap();
fresh_init(tmp_b.path(), None);
record_decision("ai:b", "allow", "bash", "R002", serde_json::json!({}));
shutdown();
let date = Utc::now().format("%Y-%m-%d").to_string();
let body_a =
std::fs::read_to_string(tmp_a.path().join(format!("forensic-{date}.jsonl"))).unwrap();
let body_b =
std::fs::read_to_string(tmp_b.path().join(format!("forensic-{date}.jsonl"))).unwrap();
assert!(body_a.contains("ai:a") && !body_a.contains("ai:b"));
assert!(body_b.contains("ai:b") && !body_b.contains("ai:a"));
}
#[test]
fn writer_logs_and_recovers_when_open_fails() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let bad = tmp.path().join("missing-parent").join("forensic.jsonl");
enqueue_append_for_test(bad.clone(), "{}".to_string());
flush_blocking();
assert!(!bad.exists(), "open failure must not create the file");
let good = tmp.path().join("good.jsonl");
enqueue_append_for_test(good.clone(), "{\"ok\":true}".to_string());
flush_blocking();
let body = std::fs::read_to_string(&good).expect("good append after prior error");
assert!(body.contains("\"ok\":true"));
}
#[test]
fn reinit_invalidates_cached_handle_over_same_path_new_inode() {
let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let date = Utc::now().format("%Y-%m-%d").to_string();
let path = tmp.path().join(format!("forensic-{date}.jsonl"));
fresh_init(tmp.path(), None);
record_decision("ai:epoch-1", "allow", "bash", "R001", serde_json::json!({}));
flush_blocking();
assert!(path.exists(), "epoch-1 row created the file");
fresh_init(tmp.path(), None);
record_decision("ai:epoch-2", "allow", "bash", "R002", serde_json::json!({}));
flush_blocking();
let body = std::fs::read_to_string(&path).expect("epoch-2 row on the recreated file");
let lines: Vec<&str> = body.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(
!lines.is_empty(),
"epoch-2's row is visible on the new inode"
);
assert!(body.contains("ai:epoch-2") && !body.contains("ai:epoch-1"));
shutdown();
}
}