use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub ts: String,
pub caller: String,
pub secret: String,
pub scope: String,
pub tier: String,
#[serde(default = "unknown_source")]
pub source: String,
pub decision: String,
pub rule: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peer_uid: Option<u32>,
}
fn unknown_source() -> String {
String::new()
}
impl Entry {
#[allow(clippy::too_many_arguments)]
pub fn now(
caller: &str,
secret: &str,
scope: &str,
tier: &str,
decision: &str,
rule: &str,
reason: &str,
) -> Self {
Self {
ts: Utc::now().to_rfc3339(),
caller: caller.to_string(),
secret: secret.to_string(),
scope: scope.to_string(),
tier: tier.to_string(),
source: crate::core::usage::source().as_str().to_string(),
decision: decision.to_string(),
rule: rule.to_string(),
reason: reason.to_string(),
peer_uid: None,
}
}
pub fn with_peer_uid(mut self, uid: Option<u32>) -> Self {
self.peer_uid = uid;
self
}
pub fn with_source(mut self, source: &str) -> Self {
self.source = source.to_string();
self
}
pub fn timestamp(&self) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&self.ts)
.ok()
.map(|t| t.with_timezone(&Utc))
}
}
fn audit_path(vault_dir: &Path) -> PathBuf {
vault_dir.join("audit.log")
}
pub fn record(vault_dir: &Path, entry: &Entry) -> Result<()> {
let path = audit_path(vault_dir);
let mut line = serde_json::to_string(entry)?;
line.push('\n');
use std::io::Write;
#[cfg(unix)]
let mut f = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(&path)?
};
#[cfg(not(unix))]
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
f.write_all(line.as_bytes())?;
Ok(())
}
pub fn recent(vault_dir: &Path, caller: &str, since: DateTime<Utc>) -> Result<Vec<Entry>> {
let path = audit_path(vault_dir);
let Ok(content) = std::fs::read_to_string(&path) else {
return Ok(vec![]);
};
let mut out = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<Entry>(line) {
if entry.caller == caller && entry.timestamp().is_some_and(|t| t >= since) {
out.push(entry);
}
}
}
Ok(out)
}
pub fn recent_for_secret(
vault_dir: &Path,
secret: &str,
since: DateTime<Utc>,
) -> Result<Vec<Entry>> {
let path = audit_path(vault_dir);
let Ok(content) = std::fs::read_to_string(&path) else {
return Ok(vec![]);
};
let mut out = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<Entry>(line) {
if entry.secret == secret && entry.timestamp().is_some_and(|t| t >= since) {
out.push(entry);
}
}
}
Ok(out)
}
pub fn all(vault_dir: &Path) -> Result<Vec<Entry>> {
let path = audit_path(vault_dir);
let Ok(content) = std::fs::read_to_string(&path) else {
return Ok(vec![]);
};
Ok(content
.lines()
.filter_map(|l| serde_json::from_str::<Entry>(l.trim()).ok())
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn record_then_recent_roundtrip() {
let dir = TempDir::new().unwrap();
let vault_dir = dir.path();
record(
vault_dir,
&Entry::now(
"claude",
"DB_URL",
"database",
"low",
"allow",
"ok",
"run migration",
),
)
.unwrap();
record(
vault_dir,
&Entry::now(
"claude", "DB_URL", "database", "low", "allow", "ok", "again",
),
)
.unwrap();
record(
vault_dir,
&Entry::now("other", "API_KEY", "api", "low", "allow", "ok", "use api"),
)
.unwrap();
let since = Utc::now() - chrono::Duration::hours(1);
let got = recent(vault_dir, "claude", since).unwrap();
assert_eq!(got.len(), 2);
assert!(got.iter().all(|e| e.caller == "claude"));
assert_eq!(all(vault_dir).unwrap().len(), 3);
}
#[test]
fn recent_filters_by_time() {
let dir = TempDir::new().unwrap();
let vault_dir = dir.path();
record(
vault_dir,
&Entry::now("c", "S", "misc", "low", "allow", "ok", "reason here"),
)
.unwrap();
let future = Utc::now() + chrono::Duration::hours(1);
assert!(recent(vault_dir, "c", future).unwrap().is_empty());
}
#[test]
fn recent_on_missing_log_is_empty() {
let dir = TempDir::new().unwrap();
let since = Utc::now() - chrono::Duration::hours(1);
assert!(recent(dir.path(), "anyone", since).unwrap().is_empty());
}
}