use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use chrono::Utc;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use thiserror::Error;
fn sorted_json(v: &Value) -> String {
match v {
Value::Object(map) => {
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let pairs: Vec<String> = entries
.iter()
.map(|(k, val)| format!("{}:{}", serde_json::json!(k), sorted_json(val)))
.collect();
format!("{{{}}}", pairs.join(","))
}
Value::Array(arr) => {
let parts: Vec<String> = arr.iter().map(sorted_json).collect();
format!("[{}]", parts.join(","))
}
other => other.to_string(),
}
}
#[derive(Debug, Clone)]
pub struct AuditLogger {
path: Option<PathBuf>,
write_failure_warned: Arc<AtomicBool>,
}
impl AuditLogger {
pub fn default_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".apcore-cli").join("audit.jsonl"))
}
pub fn new(path: Option<PathBuf>) -> Self {
let resolved = path.or_else(Self::default_path);
if let Some(ref p) = resolved {
if let Some(parent) = p.parent() {
let _ = std::fs::create_dir_all(parent);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
}
Self {
path: resolved,
write_failure_warned: Arc::new(AtomicBool::new(false)),
}
}
fn get_user() -> String {
#[cfg(unix)]
{
unsafe {
let raw = libc::getlogin();
if !raw.is_null() {
let cstr = std::ffi::CStr::from_ptr(raw);
let name = cstr.to_string_lossy().into_owned();
if !name.is_empty() {
return name;
}
}
}
let euid = nix::unistd::geteuid();
if let Ok(Some(user)) = nix::unistd::User::from_uid(euid) {
if !user.name.is_empty() {
return user.name;
}
}
}
Self::resolve_user_from_env(&|k| std::env::var(k).ok())
}
fn resolve_user_from_env<F>(env_lookup: &F) -> String
where
F: Fn(&str) -> Option<String>,
{
for key in ["USER", "LOGNAME", "USERNAME"] {
if let Some(v) = env_lookup(key) {
if !v.is_empty() {
return v;
}
}
}
"unknown".to_string()
}
fn hash_input(input_data: &Value) -> String {
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::aead::OsRng;
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
let payload = sorted_json(input_data);
let mut hasher = Sha256::new();
hasher.update(salt);
hasher.update(payload.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn log_execution(
&self,
module_id: &str,
input_data: &Value,
status: &str,
exit_code: i32,
duration_ms: u64,
) {
let Some(ref path) = self.path else {
return; };
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let input_hash = Self::hash_input(input_data);
let entry = json!({
"timestamp": timestamp,
"user": Self::get_user(),
"module_id": module_id,
"input_hash": input_hash,
"status": status,
"exit_code": exit_code,
"duration_ms": duration_ms,
});
let result = (|| -> std::io::Result<()> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
let mut writer = BufWriter::new(file);
serde_json::to_writer(&mut writer, &entry).map_err(std::io::Error::other)?;
writeln!(writer)?;
writer.flush()?;
Ok(())
})();
if let Err(e) = result {
if !self.write_failure_warned.swap(true, Ordering::Relaxed) {
tracing::warn!("Could not write audit log: {e}");
} else {
tracing::trace!("Could not write audit log (suppressed): {e}");
}
}
}
}
#[derive(Debug, Error)]
pub enum AuditLogError {
#[error("failed to write audit log: {0}")]
Io(#[from] std::io::Error),
#[error("failed to serialise audit record: {0}")]
Serialise(#[from] serde_json::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_audit_logger_disabled_no_op() {
let logger = AuditLogger {
path: None,
write_failure_warned: Arc::new(AtomicBool::new(false)),
};
logger.log_execution("mod.test", &json!({}), "success", 0, 1);
}
#[test]
fn test_audit_logger_writes_jsonl_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(path.clone()));
logger.log_execution("math.add", &json!({"a": 1}), "success", 0, 42);
let content = std::fs::read_to_string(&path).unwrap();
let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
assert_eq!(entry["module_id"], "math.add");
assert_eq!(entry["status"], "success");
assert_eq!(entry["exit_code"], 0);
assert_eq!(entry["duration_ms"], 42);
}
#[test]
fn test_audit_logger_appends_multiple_records() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(path.clone()));
logger.log_execution("a.b", &json!({}), "success", 0, 1);
logger.log_execution("c.d", &json!({}), "error", 1, 2);
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
}
#[test]
fn test_audit_logger_record_contains_required_fields() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(path.clone()));
logger.log_execution("x.y", &json!({"k": "v"}), "success", 0, 10);
let raw = std::fs::read_to_string(&path).unwrap();
let entry: serde_json::Value = serde_json::from_str(raw.trim()).unwrap();
assert!(entry["timestamp"].as_str().unwrap().ends_with('Z'));
assert!(entry["user"].is_string());
assert_eq!(entry["module_id"], "x.y");
assert!(entry["input_hash"].as_str().unwrap().len() == 64); assert!(entry.get("input_salt").is_none());
assert_eq!(entry["status"], "success");
assert!(entry["exit_code"].is_number());
assert!(entry["duration_ms"].is_number());
}
#[test]
fn test_audit_logger_different_inputs_produce_different_hashes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(path.clone()));
logger.log_execution("x.y", &json!({"a": 1}), "success", 0, 0);
logger.log_execution("x.y", &json!({"a": 2}), "success", 0, 0);
let lines: Vec<String> = std::fs::read_to_string(&path)
.unwrap()
.lines()
.map(String::from)
.collect();
let h0 = serde_json::from_str::<serde_json::Value>(&lines[0]).unwrap()["input_hash"]
.as_str()
.unwrap()
.to_string();
let h1 = serde_json::from_str::<serde_json::Value>(&lines[1]).unwrap()["input_hash"]
.as_str()
.unwrap()
.to_string();
assert_ne!(h0, h1, "different inputs must produce different hashes");
}
#[test]
fn test_audit_logger_same_input_different_hash_per_call() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(path.clone()));
logger.log_execution("u.v", &json!({}), "success", 0, 0);
logger.log_execution("u.v", &json!({}), "success", 0, 0);
let lines: Vec<String> = std::fs::read_to_string(&path)
.unwrap()
.lines()
.map(String::from)
.collect();
let h0 = serde_json::from_str::<serde_json::Value>(&lines[0]).unwrap()["input_hash"]
.as_str()
.unwrap()
.to_string();
let h1 = serde_json::from_str::<serde_json::Value>(&lines[1]).unwrap()["input_hash"]
.as_str()
.unwrap()
.to_string();
assert_ne!(
h0, h1,
"same input across calls must produce different hashes (random salt)"
);
}
#[cfg(unix)]
#[test]
fn test_audit_logger_file_mode_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(Some(path.clone()));
logger.log_execution("perm.test", &json!({}), "success", 0, 0);
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "audit log must be 0600; got {:o}", mode);
}
#[cfg(unix)]
#[test]
fn test_audit_logger_parent_dir_mode_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("nested-audit-dir");
let path = nested.join("audit.jsonl");
let _logger = AuditLogger::new(Some(path));
let mode = std::fs::metadata(&nested).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "parent dir must be 0700; got {:o}", mode);
}
#[test]
fn test_sorted_json_recurses_into_nested_objects() {
let a = json!({ "outer": { "y": 1, "x": 2 } });
let b = json!({ "outer": { "x": 2, "y": 1 } });
assert_eq!(
super::sorted_json(&a),
super::sorted_json(&b),
"nested objects with reordered keys must canonicalise identically"
);
}
#[test]
fn test_sorted_json_recurses_into_arrays_of_objects() {
let a = json!({ "items": [ { "y": 1, "x": 2 }, { "b": 4, "a": 3 } ] });
let b = json!({ "items": [ { "x": 2, "y": 1 }, { "a": 3, "b": 4 } ] });
assert_eq!(
super::sorted_json(&a),
super::sorted_json(&b),
"objects nested inside arrays must canonicalise identically"
);
}
#[test]
fn test_sorted_json_preserves_array_element_order() {
let a = json!([3, 1, 2]);
let b = json!([1, 2, 3]);
assert_ne!(
super::sorted_json(&a),
super::sorted_json(&b),
"array element order must be preserved (it is part of the value)"
);
}
#[test]
fn test_resolve_user_from_env_priority_chain() {
let env = |k: &str| -> Option<String> {
match k {
"USER" => Some("user_val".to_string()),
"LOGNAME" => Some("logname_val".to_string()),
"USERNAME" => Some("username_val".to_string()),
_ => None,
}
};
assert_eq!(AuditLogger::resolve_user_from_env(&env), "user_val");
let env = |k: &str| -> Option<String> {
match k {
"LOGNAME" => Some("logname_val".to_string()),
"USERNAME" => Some("username_val".to_string()),
_ => None,
}
};
assert_eq!(AuditLogger::resolve_user_from_env(&env), "logname_val");
let env = |k: &str| -> Option<String> {
match k {
"USERNAME" => Some("username_val".to_string()),
_ => None,
}
};
assert_eq!(AuditLogger::resolve_user_from_env(&env), "username_val");
let env = |_: &str| -> Option<String> { None };
assert_eq!(AuditLogger::resolve_user_from_env(&env), "unknown");
}
#[cfg(unix)]
#[test]
fn test_get_user_prefers_system_identity_over_env() {
let prev_user = std::env::var("USER").ok();
let prev_logname = std::env::var("LOGNAME").ok();
std::env::set_var("USER", "sentinel_user_d10_007");
std::env::set_var("LOGNAME", "sentinel_logname_d10_007");
let resolved = AuditLogger::get_user();
match prev_user {
Some(v) => std::env::set_var("USER", v),
None => std::env::remove_var("USER"),
}
match prev_logname {
Some(v) => std::env::set_var("LOGNAME", v),
None => std::env::remove_var("LOGNAME"),
}
assert_ne!(
resolved, "sentinel_user_d10_007",
"get_user must consult getlogin/getpwuid before USER env var"
);
assert_ne!(
resolved, "sentinel_logname_d10_007",
"get_user must consult getlogin/getpwuid before LOGNAME env var"
);
assert!(!resolved.is_empty(), "get_user must never return empty");
}
}