use std::{
fs::{self, OpenOptions},
io::Write,
path::Path,
};
use crate::error::Result;
use serde_derive::{Deserialize, Serialize};
use crate::checks::Severity;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuditOutcome {
Allowed,
Denied,
Skipped,
Cancelled,
}
impl std::fmt::Display for AuditOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Allowed => write!(f, "ALLOWED"),
Self::Denied => write!(f, "DENIED"),
Self::Skipped => write!(f, "SKIPPED"),
Self::Cancelled => write!(f, "CANCELLED"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub event_id: String,
pub timestamp: String,
pub command: String,
pub matched_ids: Vec<String>,
pub challenge_type: String,
pub outcome: AuditOutcome,
pub context_labels: Vec<String>,
pub severity: Severity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blast_radius_scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blast_radius_detail: Option<String>,
}
pub fn log_event(audit_path: &Path, event: &AuditEvent) -> Result<()> {
if let Some(parent) = audit_path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(audit_path)?;
let json = serde_json::to_string(event)?;
writeln!(file, "{json}")?;
Ok(())
}
pub fn read_log(audit_path: &Path) -> Result<String> {
if !audit_path.exists() {
return Ok("No audit events recorded yet.".into());
}
Ok(fs::read_to_string(audit_path)?)
}
pub fn clear_log(audit_path: &Path) -> Result<()> {
if audit_path.exists() {
fs::remove_file(audit_path)?;
}
Ok(())
}
#[must_use]
pub fn now_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days = secs / 86400;
let remaining = secs % 86400;
let hours = remaining / 3600;
let minutes = (remaining % 3600) / 60;
let seconds = remaining % 60;
let (year, month, day) = epoch_days_to_date(days);
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
const fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + 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;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_log_and_read() {
let temp = tree_fs::TreeBuilder::default()
.create()
.expect("create tree");
let path = temp.root.join("audit.log");
let event = AuditEvent {
event_id: "test-event-1".into(),
timestamp: "2026-02-15T10:00:00Z".into(),
command: "git push -f".into(),
matched_ids: vec!["git:force_push".into()],
challenge_type: "Math".into(),
outcome: AuditOutcome::Allowed,
context_labels: vec!["branch=main".into()],
severity: Severity::High,
agent_name: None,
agent_session_id: None,
blast_radius_scope: None,
blast_radius_detail: None,
};
log_event(&path, &event).unwrap();
let content = read_log(&path).unwrap();
let parsed: AuditEvent = serde_json::from_str(content.trim()).unwrap();
assert_eq!(parsed.event_id, "test-event-1");
assert_eq!(parsed.command, "git push -f");
assert_eq!(parsed.outcome, AuditOutcome::Allowed);
assert_eq!(parsed.matched_ids, vec!["git:force_push"]);
assert_eq!(parsed.context_labels, vec!["branch=main"]);
assert_eq!(parsed.severity, Severity::High);
}
#[test]
fn test_log_command_with_pipe_characters() {
let temp = tree_fs::TreeBuilder::default()
.create()
.expect("create tree");
let path = temp.root.join("audit.log");
let event = AuditEvent {
event_id: "test-event-2".into(),
timestamp: "2026-02-15T10:00:00Z".into(),
command: "cat file | grep pattern | rm -rf /".into(),
matched_ids: vec!["fs:recursively_delete".into()],
challenge_type: "Math".into(),
outcome: AuditOutcome::Allowed,
context_labels: vec![],
severity: Severity::Critical,
agent_name: None,
agent_session_id: None,
blast_radius_scope: None,
blast_radius_detail: None,
};
log_event(&path, &event).unwrap();
let content = read_log(&path).unwrap();
let parsed: AuditEvent = serde_json::from_str(content.trim()).unwrap();
assert_eq!(parsed.command, "cat file | grep pattern | rm -rf /");
}
#[test]
fn test_clear_log() {
let temp = tree_fs::TreeBuilder::default()
.create()
.expect("create tree");
let path = temp.root.join("audit.log");
let event = AuditEvent {
event_id: "test-event-3".into(),
timestamp: "2026-02-15T10:00:00Z".into(),
command: "rm -rf /".into(),
matched_ids: vec!["fs:recursively_delete".into()],
challenge_type: "Deny".into(),
outcome: AuditOutcome::Denied,
context_labels: vec![],
severity: Severity::Critical,
agent_name: None,
agent_session_id: None,
blast_radius_scope: None,
blast_radius_detail: None,
};
log_event(&path, &event).unwrap();
assert!(path.exists());
clear_log(&path).unwrap();
assert!(!path.exists());
}
#[test]
fn test_read_nonexistent_log() {
let path = PathBuf::from("/tmp/nonexistent-audit-test.log");
let result = read_log(&path).unwrap();
assert!(result.contains("No audit events"));
}
#[test]
fn test_cancelled_outcome_serialization() {
let temp = tree_fs::TreeBuilder::default()
.create()
.expect("create tree");
let path = temp.root.join("audit.log");
let event = AuditEvent {
event_id: "cancel-test-1".into(),
timestamp: "2026-02-15T10:00:00Z".into(),
command: "rm -rf /".into(),
matched_ids: vec!["fs:recursively_delete".into()],
challenge_type: "Math".into(),
outcome: AuditOutcome::Cancelled,
context_labels: vec![],
severity: Severity::Critical,
agent_name: None,
agent_session_id: None,
blast_radius_scope: None,
blast_radius_detail: None,
};
log_event(&path, &event).unwrap();
let content = read_log(&path).unwrap();
let parsed: AuditEvent = serde_json::from_str(content.trim()).unwrap();
assert_eq!(parsed.outcome, AuditOutcome::Cancelled);
assert_eq!(parsed.event_id, "cancel-test-1");
assert_eq!(format!("{}", parsed.outcome), "CANCELLED");
}
#[test]
fn test_now_timestamp_format() {
let ts = now_timestamp();
assert!(ts.contains('T'));
assert!(ts.ends_with('Z'));
assert_eq!(ts.len(), 20);
}
}