use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const AUDIT_FILENAME: &str = ".security_audit.jsonl";
const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub ts: String,
pub action: AuditAction,
pub content_sha256: String,
pub prev_entry_sha256: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
Created,
Signed,
Verified,
TamperDetected,
Missing,
Unsigned,
ManifestCorrupted,
SuspiciousContent,
FileChanged,
WriteBlocked,
ChainRecovery,
}
pub fn append_audit_entry(
state_dir: &Path,
action: AuditAction,
content_sha256: &str,
source: &str,
) -> Result<()> {
append_audit_entry_with_detail(state_dir, action, content_sha256, source, None)
}
pub fn append_audit_entry_with_detail(
state_dir: &Path,
action: AuditAction,
content_sha256: &str,
source: &str,
detail: Option<&str>,
) -> Result<()> {
let path = audit_file_path(state_dir);
let prev_hash = if path.exists() {
let content = fs::read_to_string(&path).context("Failed to read audit log")?;
match content.lines().last() {
Some(last_line) if !last_line.is_empty() => {
if serde_json::from_str::<AuditEntry>(last_line).is_ok() {
sha256_hex(last_line.as_bytes())
} else {
let raw_hash = sha256_hex(last_line.as_bytes());
let recovery = AuditEntry {
ts: chrono::Utc::now().to_rfc3339(),
action: AuditAction::ChainRecovery,
content_sha256: String::new(),
prev_entry_sha256: raw_hash,
source: "audit_system".to_string(),
detail: Some(format!(
"Previous entry corrupted ({} bytes), new chain segment",
last_line.len()
)),
};
let recovery_json = serde_json::to_string(&recovery)
.context("Failed to serialize recovery entry")?;
append_line(&path, &recovery_json)?;
sha256_hex(recovery_json.as_bytes())
}
}
_ => GENESIS_HASH.to_string(),
}
} else {
GENESIS_HASH.to_string()
};
let entry = AuditEntry {
ts: chrono::Utc::now().to_rfc3339(),
action,
content_sha256: content_sha256.to_string(),
prev_entry_sha256: prev_hash,
source: source.to_string(),
detail: detail.map(|d| d.to_string()),
};
let json = serde_json::to_string(&entry).context("Failed to serialize audit entry")?;
append_line(&path, &json)?;
Ok(())
}
fn append_line(path: &Path, line: &str) -> Result<()> {
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.context("Failed to open audit log")?;
writeln!(file, "{}", line).context("Failed to write audit entry")?;
Ok(())
}
pub fn read_audit_log(state_dir: &Path) -> Result<Vec<AuditEntry>> {
let path = audit_file_path(state_dir);
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).context("Failed to read audit log")?;
let mut entries = Vec::new();
for line in content.lines() {
if line.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<AuditEntry>(line) {
entries.push(entry);
}
}
Ok(entries)
}
pub fn verify_audit_chain(state_dir: &Path) -> Result<Vec<usize>> {
let path = audit_file_path(state_dir);
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).context("Failed to read audit log")?;
let lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
if lines.is_empty() {
return Ok(Vec::new());
}
let mut broken = Vec::new();
let mut parsed: Vec<Option<AuditEntry>> = Vec::new();
for line in &lines {
parsed.push(serde_json::from_str(line).ok());
}
if let Some(ref first) = parsed[0] {
if first.prev_entry_sha256 != GENESIS_HASH {
broken.push(0);
}
} else {
broken.push(0); }
for i in 1..lines.len() {
if parsed[i].is_none() {
broken.push(i); continue;
}
let expected_hash = sha256_hex(lines[i - 1].as_bytes());
if parsed[i].as_ref().unwrap().prev_entry_sha256 != expected_hash {
broken.push(i);
}
}
Ok(broken)
}
pub fn audit_file_path(state_dir: &Path) -> PathBuf {
state_dir.join(AUDIT_FILENAME)
}
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hasher
.finalize()
.iter()
.map(|b| format!("{:02x}", b))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audit_chain_integrity() {
let tmp = tempfile::tempdir().unwrap();
for i in 0..5 {
append_audit_entry(
tmp.path(),
AuditAction::Verified,
&format!("sha256_{}", i),
"test",
)
.unwrap();
}
let entries = read_audit_log(tmp.path()).unwrap();
assert_eq!(entries.len(), 5);
let broken = verify_audit_chain(tmp.path()).unwrap();
assert!(broken.is_empty(), "Chain should be intact: {:?}", broken);
}
#[test]
fn first_entry_uses_genesis_hash() {
let tmp = tempfile::tempdir().unwrap();
append_audit_entry(tmp.path(), AuditAction::Created, "abc123", "cli").unwrap();
let entries = read_audit_log(tmp.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].prev_entry_sha256, GENESIS_HASH);
assert_eq!(entries[0].action, AuditAction::Created);
}
#[test]
fn broken_chain_detected() {
let tmp = tempfile::tempdir().unwrap();
for i in 0..3 {
append_audit_entry(
tmp.path(),
AuditAction::Verified,
&format!("sha256_{}", i),
"test",
)
.unwrap();
}
let path = audit_file_path(tmp.path());
let content = fs::read_to_string(&path).unwrap();
let mut lines: Vec<&str> = content.lines().collect();
let tampered = lines[1].replace("sha256_1", "tampered_hash");
lines[1] = &tampered;
fs::write(&path, lines.join("\n") + "\n").unwrap();
let broken = verify_audit_chain(tmp.path()).unwrap();
assert!(!broken.is_empty(), "Should detect broken chain");
assert!(broken.contains(&2), "Entry 2 should have broken link");
}
#[test]
fn empty_log_no_errors() {
let tmp = tempfile::tempdir().unwrap();
let entries = read_audit_log(tmp.path()).unwrap();
assert!(entries.is_empty());
let broken = verify_audit_chain(tmp.path()).unwrap();
assert!(broken.is_empty());
}
#[test]
fn audit_actions_serialize_snake_case() {
let entry = AuditEntry {
ts: "2026-02-09T14:00:00Z".to_string(),
action: AuditAction::TamperDetected,
content_sha256: "abc".to_string(),
prev_entry_sha256: GENESIS_HASH.to_string(),
source: "cli".to_string(),
detail: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"tamper_detected\""));
assert!(!json.contains("\"detail\""));
}
#[test]
fn detail_field_serialized_when_present() {
let entry = AuditEntry {
ts: "2026-02-09T14:00:00Z".to_string(),
action: AuditAction::WriteBlocked,
content_sha256: String::new(),
prev_entry_sha256: GENESIS_HASH.to_string(),
source: "tool:write_file".to_string(),
detail: Some("Agent attempted write to LocalGPT.md".to_string()),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"write_blocked\""));
assert!(json.contains("Agent attempted write to LocalGPT.md"));
let parsed: AuditEntry = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed.detail.unwrap(),
"Agent attempted write to LocalGPT.md"
);
}
#[test]
fn chain_recovery_on_corrupted_line() {
let tmp = tempfile::tempdir().unwrap();
append_audit_entry(tmp.path(), AuditAction::Signed, "abc", "cli").unwrap();
append_audit_entry(tmp.path(), AuditAction::Verified, "abc", "session_start").unwrap();
let path = audit_file_path(tmp.path());
let mut content = fs::read_to_string(&path).unwrap();
content.push_str("this is not json\n");
fs::write(&path, &content).unwrap();
append_audit_entry(tmp.path(), AuditAction::Verified, "abc", "session_start").unwrap();
let entries = read_audit_log(tmp.path()).unwrap();
assert_eq!(entries.len(), 4);
assert_eq!(entries[2].action, AuditAction::ChainRecovery);
assert!(entries[2].detail.as_ref().unwrap().contains("corrupted"));
assert_eq!(entries[2].source, "audit_system");
}
#[test]
fn corrupted_lines_skipped_in_read() {
let tmp = tempfile::tempdir().unwrap();
let path = audit_file_path(tmp.path());
append_audit_entry(tmp.path(), AuditAction::Signed, "abc", "cli").unwrap();
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
writeln!(file, "not valid json garbage").unwrap();
drop(file);
append_audit_entry(tmp.path(), AuditAction::Verified, "abc", "test").unwrap();
let entries = read_audit_log(tmp.path()).unwrap();
assert_eq!(entries.len(), 3);
}
}