use apcore::{AuditEntry, ErrorCode};
use serde::Serialize;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize)]
pub(crate) struct ExecutionRecord<'a> {
pub(crate) timestamp: String,
pub(crate) event: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) trace_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) caller_id: Option<&'a str>,
pub(crate) module_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) approval_id: Option<&'a str>,
pub(crate) status: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) error_code: Option<ErrorCode>,
pub(crate) duration_ms: u64,
}
#[derive(Debug)]
pub struct AuditManager {
path: PathBuf,
}
impl AuditManager {
pub fn new(audit_path: &Path) -> Self {
Self {
path: audit_path.to_path_buf(),
}
}
pub async fn log_execution(
&self,
module_id: &str,
trace_id: &str,
caller_id: Option<&str>,
status: &str,
exit_code: i32,
duration_ms: u64,
) {
self.append(&ExecutionRecord {
timestamp: Self::now(),
event: "execution",
trace_id: (!trace_id.is_empty()).then_some(trace_id),
caller_id,
module_id,
approval_id: None,
status,
exit_code: Some(exit_code),
error_code: None,
duration_ms,
})
.await;
}
pub async fn log_refusal(
&self,
module_id: &str,
trace_id: &str,
caller_id: Option<&str>,
approval_id: Option<&str>,
error_code: ErrorCode,
duration_ms: u64,
) {
self.append(&ExecutionRecord {
timestamp: Self::now(),
event: "refusal",
trace_id: (!trace_id.is_empty()).then_some(trace_id),
caller_id,
module_id,
approval_id,
status: "refused",
exit_code: None,
error_code: Some(error_code),
duration_ms,
})
.await;
}
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
async fn append<T: Serialize>(&self, record: &T) {
let line = match serde_json::to_string(record) {
Ok(line) => line,
Err(e) => {
tracing::warn!(error = %e, "Failed to serialize audit record");
return;
}
};
self.append_line(&line, "audit record").await;
}
pub fn log_acl_decision(&self, entry: &AuditEntry) {
let line = match serde_json::to_string(entry) {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, "Failed to serialize ACL audit entry");
return;
}
};
let path = self.path.clone();
tokio::task::spawn_blocking(move || {
Self::write_line_blocking(&path, &line, "ACL audit entry");
});
}
async fn append_line(&self, line: &str, what: &str) {
let path = self.path.clone();
let line = line.to_string();
let what = what.to_string();
if tokio::task::spawn_blocking(move || Self::write_line_blocking(&path, &line, &what))
.await
.is_err()
{
tracing::warn!("Audit write task panicked");
}
}
fn write_line_blocking(path: &Path, line: &str, what: &str) {
let mut options = std::fs::OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(path) {
Ok(mut file) => {
let mut record = String::with_capacity(line.len() + 1);
record.push_str(line);
record.push('\n');
if let Err(e) = file.write_all(record.as_bytes()) {
tracing::warn!(error = %e, "Failed to append {what}");
}
}
Err(e) => tracing::warn!(error = %e, "Failed to open audit log for {what}"),
}
}
pub fn log_path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
fn records(path: &Path) -> Vec<serde_json::Value> {
std::fs::read_to_string(path)
.expect("audit file should exist")
.lines()
.map(|line| serde_json::from_str(line).expect("every line must be valid JSON"))
.collect()
}
async fn wait_for_records(path: &Path, expected: usize) -> Vec<serde_json::Value> {
for _ in 0..200 {
let found = std::fs::read_to_string(path).unwrap_or_default();
if found.lines().count() >= expected {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
records(path)
}
#[tokio::test]
async fn test_audit_manager_creates_file() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
AuditManager::new(&path)
.log_execution("cli.git.status", "t1", None, "success", 0, 10)
.await;
assert!(path.exists());
}
#[tokio::test]
async fn test_audit_manager_appends_jsonl() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
let mgr = AuditManager::new(&path);
mgr.log_execution("cli.git.status", "t1", None, "success", 0, 10)
.await;
mgr.log_execution("cli.git.commit", "t2", None, "error", 1, 25)
.await;
assert_eq!(records(&path).len(), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn test_concurrent_writers_each_get_their_own_line() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
let audit = std::sync::Arc::new(AuditManager::new(&path));
const WRITERS: usize = 400;
let mut handles = Vec::with_capacity(WRITERS);
for i in 0..WRITERS {
let audit = audit.clone();
handles.push(tokio::spawn(async move {
audit
.log_execution(
"cli.probe",
&format!("trace{i:040}"),
Some("@external"),
"success",
0,
1,
)
.await;
}));
}
for handle in handles {
handle.await.expect("no writer task may panic");
}
let content = std::fs::read_to_string(&path).expect("the trail exists");
assert!(
content.lines().all(|line| !line.trim().is_empty()),
"a blank line means a record's newline landed apart from its body"
);
let lines: Vec<&str> = content.lines().collect();
assert_eq!(
lines.len(),
WRITERS,
"every record must occupy exactly one line"
);
for line in lines {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|e| panic!("every line must parse ({e}): {line}"));
}
}
#[cfg(unix)]
#[tokio::test]
async fn test_the_trail_is_owner_only_from_its_very_first_write() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
AuditManager::new(&path)
.log_execution("cli.ls", "t", None, "success", 0, 1)
.await;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"the trail must never be group- or world-readable"
);
}
#[tokio::test]
async fn test_log_execution_names_the_call_and_its_trace() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
AuditManager::new(&path)
.log_execution(
"cli.git.push",
"trace-abc",
Some("apexe-token"),
"success",
0,
42,
)
.await;
let entry = records(&path).remove(0);
assert_eq!(entry["event"], "execution");
assert_eq!(entry["module_id"], "cli.git.push");
assert_eq!(entry["status"], "success");
assert_eq!(entry["exit_code"], 0);
assert_eq!(entry["duration_ms"], 42);
assert_eq!(entry["trace_id"], "trace-abc");
assert_eq!(entry["caller_id"], "apexe-token");
assert!(entry["timestamp"].as_str().unwrap().ends_with('Z'));
}
#[tokio::test]
async fn test_log_refusal_records_a_call_that_never_ran() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
AuditManager::new(&path)
.log_refusal(
"cli.cp",
"trace-xyz",
Some("u1"),
None,
ErrorCode::ACLDenied,
3,
)
.await;
let entry = records(&path).remove(0);
assert_eq!(entry["event"], "refusal");
assert_eq!(entry["status"], "refused");
assert_eq!(entry["module_id"], "cli.cp");
assert_eq!(entry["trace_id"], "trace-xyz");
assert_eq!(entry["caller_id"], "u1");
assert_eq!(entry["error_code"], "ACL_DENIED");
assert!(entry["exit_code"].is_null());
}
#[tokio::test]
async fn test_anonymous_caller_omits_the_field_rather_than_naming_nobody() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
AuditManager::new(&path)
.log_execution("cli.ls", "t1", None, "success", 0, 1)
.await;
let entry = records(&path).remove(0);
assert!(
entry.get("caller_id").is_none(),
"an unauthenticated call must leave the field absent, not record a \
placeholder that reads like an identity: {entry}"
);
}
#[tokio::test]
async fn test_both_record_kinds_share_one_file_and_one_timestamp_format() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
let mgr = AuditManager::new(&path);
let decision: AuditEntry = serde_json::from_value(serde_json::json!({
"timestamp": "2026-08-20T00:00:00.000Z",
"caller_id": "u1",
"target_id": "cli.ls",
"decision": "allow",
"reason": "matched rule 0",
"trace_id": "shared-trace",
}))
.expect("AuditEntry should deserialize from its own wire shape");
mgr.log_acl_decision(&decision);
mgr.log_execution("cli.ls", "shared-trace", Some("u1"), "success", 0, 7)
.await;
let entries = wait_for_records(&path, 2).await;
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["trace_id"], entries[1]["trace_id"]);
for entry in &entries {
assert!(
entry["timestamp"].as_str().unwrap().ends_with('Z'),
"one timestamp format across both record kinds: {entry}"
);
}
}
#[cfg(unix)]
#[test]
fn test_log_execution_offloads_its_write_instead_of_blocking_the_runtime() {
let tmp = tempfile::TempDir::new().unwrap();
let fifo = tmp.path().join("audit.jsonl");
let status = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.expect("mkfifo must be available on a unix test host");
assert!(status.success(), "mkfifo failed to create the test fifo");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let mgr = AuditManager::new(&fifo);
tokio::spawn(async move {
mgr.log_execution("cli.ls", "t1", None, "success", 0, 1)
.await;
});
tokio::task::yield_now().await;
let other_task_ran = tokio::spawn(async { 42 }).await;
let _ = tx.send(other_task_ran.expect("the concurrent task must not panic"));
});
});
let other_task_ran = rx
.recv_timeout(std::time::Duration::from_millis(500))
.expect(
"a concurrent task on the same single-threaded runtime must complete \
while the audit write is in flight; it never ran, which means the \
write is blocking the runtime's only thread",
);
assert_eq!(other_task_ran, 42);
}
#[test]
fn test_audit_manager_log_path() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");
let mgr = AuditManager::new(&path);
assert_eq!(mgr.log_path(), path);
}
}