use std::fs;
use std::path::PathBuf;
use chrono::Utc;
use serde_json::{Value, json};
use crate::utils::{flush_and_sync, open_append};
pub fn log_sensitive_event(event: &str, details: Value) {
if let Err(err) = append_event(event, details) {
crate::logging::warn(format!("audit log write failed: {err}"));
}
}
const AUDIT_LOG_ROTATE_BYTES: u64 = 16 * 1024 * 1024;
fn rotate_if_oversized(path: &std::path::Path) {
let oversized = fs::metadata(path).is_ok_and(|meta| meta.len() >= AUDIT_LOG_ROTATE_BYTES);
if !oversized {
return;
}
let mut rolled = path.as_os_str().to_owned();
rolled.push(".1");
let _ = fs::rename(path, std::path::Path::new(&rolled));
}
fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
let path = default_audit_path()?;
let parent = path.parent().map(|p| p.to_path_buf());
if let Some(ref parent) = parent {
fs::create_dir_all(parent)?;
}
rotate_if_oversized(&path);
let mut writer = open_append(&path)?;
let record = json!({
"ts": Utc::now().to_rfc3339(),
"event": event,
"details": details,
});
let line = serde_json::to_string(&record)?;
use std::io::Write;
writeln!(writer, "{line}")?;
flush_and_sync(&mut writer)?;
Ok(())
}
fn default_audit_path() -> anyhow::Result<PathBuf> {
Ok(codewhale_config::codewhale_home()?.join("audit.log"))
}
#[must_use]
pub fn audit_log_path() -> Option<PathBuf> {
default_audit_path().ok()
}
#[cfg(test)]
mod tests {
use super::{AUDIT_LOG_ROTATE_BYTES, rotate_if_oversized};
#[test]
fn a_small_log_is_left_alone() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("audit.log");
std::fs::write(&path, b"{}\n").expect("write");
rotate_if_oversized(&path);
assert!(path.exists(), "an ordinary log is never rolled");
assert!(!dir.path().join("audit.log.1").exists());
}
#[test]
fn an_oversized_log_rolls_to_one_previous_generation() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("audit.log");
let rolled = dir.path().join("audit.log.1");
std::fs::write(&path, vec![b'x'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("write");
rotate_if_oversized(&path);
assert!(
!path.exists(),
"the live log is rolled aside, not truncated"
);
assert_eq!(
std::fs::metadata(&rolled).expect("rolled log").len(),
AUDIT_LOG_ROTATE_BYTES,
"the previous generation keeps every byte — rolling is a rename"
);
}
#[test]
fn rolling_twice_keeps_exactly_one_previous_generation() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("audit.log");
let rolled = dir.path().join("audit.log.1");
std::fs::write(&path, vec![b'a'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("first");
rotate_if_oversized(&path);
std::fs::write(&path, vec![b'b'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("second");
rotate_if_oversized(&path);
let kept = std::fs::read(&rolled).expect("rolled log");
assert_eq!(kept.len(), AUDIT_LOG_ROTATE_BYTES as usize);
assert_eq!(kept[0], b'b', "the newer generation replaces the older one");
assert!(
!dir.path().join("audit.log.2").exists(),
"generations must not accumulate — that is the bug being fixed"
);
}
#[test]
fn a_missing_log_is_not_an_error() {
let dir = tempfile::TempDir::new().expect("tempdir");
rotate_if_oversized(&dir.path().join("audit.log"));
}
}