#![allow(clippy::redundant_pub_crate)]
use std::{
fs::OpenOptions,
io::Write as _,
sync::{Mutex, OnceLock},
};
fn write_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn audit_log_path() -> Option<String> {
std::env::var("SLOC_AUDIT_LOG")
.ok()
.filter(|s| !s.trim().is_empty())
}
fn audit_log_max_bytes() -> u64 {
if let Some(bytes) = std::env::var("SLOC_AUDIT_LOG_MAX_BYTES")
.ok()
.and_then(|v| v.parse::<u64>().ok())
{
return bytes;
}
std::env::var("SLOC_AUDIT_LOG_MAX_MB")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(10)
* 1024
* 1024
}
fn audit_log_keep() -> u32 {
std::env::var("SLOC_AUDIT_LOG_KEEP")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(5)
}
pub(crate) fn record(event: &str, outcome: &str, fields: &[(&str, &str)]) {
tracing::info!(
target: "audit",
event,
outcome,
fields = ?fields,
"security audit event"
);
if let Some(path) = audit_log_path() {
append_json_line(&path, event, outcome, fields);
}
}
fn append_json_line(path: &str, event: &str, outcome: &str, fields: &[(&str, &str)]) {
let mut map = serde_json::Map::with_capacity(fields.len() + 3);
map.insert(
"ts".to_owned(),
serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
);
map.insert(
"event".to_owned(),
serde_json::Value::String(event.to_owned()),
);
map.insert(
"outcome".to_owned(),
serde_json::Value::String(outcome.to_owned()),
);
for (k, v) in fields {
map.insert((*k).to_owned(), serde_json::Value::String((*v).to_owned()));
}
let Ok(mut line) = serde_json::to_string(&serde_json::Value::Object(map)) else {
return;
};
line.push('\n');
let _guard = write_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let max_bytes = audit_log_max_bytes();
if max_bytes > 0 {
if let Err(e) =
sloc_core::rotate_log(std::path::Path::new(path), max_bytes, audit_log_keep())
{
tracing::error!(target: "audit", error = %e, path = %path,
"failed to rotate audit log");
}
}
match OpenOptions::new().create(true).append(true).open(path) {
Ok(mut f) => {
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::error!(target: "audit", error = %e, path = %path,
"failed to write audit log line");
}
}
Err(e) => {
tracing::error!(target: "audit", error = %e, path = %path,
"failed to open audit log file");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_does_not_panic() {
record("unit_test_event", "success", &[("k", "v")]);
}
#[test]
fn append_json_line_writes_one_record_per_event() {
let dir = std::env::temp_dir().join("sloc_audit_test");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(format!("audit-{}.log", uuid::Uuid::new_v4()));
let path_str = path.to_string_lossy().into_owned();
append_json_line(
&path_str,
"auth_failure",
"failure",
&[("peer_ip", "10.0.0.9"), ("path", "/analyze")],
);
append_json_line(
&path_str,
"auth_success",
"success",
&[("peer_ip", "10.0.0.9")],
);
let contents = std::fs::read_to_string(&path).expect("audit file written");
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2, "one JSON line per event");
let first: serde_json::Value =
serde_json::from_str(lines[0]).expect("each line is valid JSON");
assert_eq!(first["event"], "auth_failure");
assert_eq!(first["outcome"], "failure");
assert_eq!(first["peer_ip"], "10.0.0.9");
assert_eq!(first["path"], "/analyze");
assert!(
first["ts"].is_string(),
"record carries an RFC3339 timestamp"
);
let _ = std::fs::remove_file(&path);
}
}