use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::{BufRead, Write};
use std::path::PathBuf;
use crate::core::dirs;
use crate::core::scope::matches_wildcard;
const MAX_ARG_VALUE_LEN: usize = 200;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AuditStatus {
Ok,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub ts: String,
pub tool: String,
pub args: Value,
pub status: AuditStatus,
pub duration_ms: u64,
pub agent_sub: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
pub fn audit_file_path() -> PathBuf {
if let Ok(p) = std::env::var("ATI_AUDIT_FILE") {
PathBuf::from(p)
} else {
dirs::ati_dir().join("audit.jsonl")
}
}
pub fn append(entry: &AuditEntry) -> Result<(), std::io::Error> {
let path = audit_file_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
writeln!(file, "{}", line)?;
Ok(())
}
pub fn tail(n: usize) -> Result<Vec<AuditEntry>, Box<dyn std::error::Error>> {
let path = audit_file_path();
if !path.exists() {
return Ok(Vec::new());
}
let file = std::fs::File::open(&path)?;
let reader = std::io::BufReader::new(file);
let entries: Vec<AuditEntry> = reader
.lines()
.map_while(|l| l.ok())
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(&l).ok())
.collect();
let start = entries.len().saturating_sub(n);
Ok(entries[start..].to_vec())
}
pub fn search(
tool_pattern: Option<&str>,
since: Option<&str>,
) -> Result<Vec<AuditEntry>, Box<dyn std::error::Error>> {
let path = audit_file_path();
if !path.exists() {
return Ok(Vec::new());
}
let since_ts = since.map(parse_duration_ago).transpose()?;
let file = std::fs::File::open(&path)?;
let reader = std::io::BufReader::new(file);
let entries: Vec<AuditEntry> = reader
.lines()
.map_while(|l| l.ok())
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(&l).ok())
.filter(|e: &AuditEntry| {
if let Some(pattern) = tool_pattern {
if !matches_wildcard(&e.tool, pattern) {
return false;
}
}
if let Some(ref cutoff) = since_ts {
if e.ts.as_str() < cutoff.as_str() {
return false;
}
}
true
})
.collect();
Ok(entries)
}
pub fn sanitize_args(args: &Value) -> Value {
match args {
Value::Object(map) => {
let mut sanitized = serde_json::Map::new();
for (key, value) in map {
let key_lower = key.to_lowercase();
if key_lower.contains("password")
|| key_lower.contains("secret")
|| key_lower.contains("token")
|| key_lower.contains("key")
|| key_lower.contains("credential")
|| key_lower.contains("auth")
{
sanitized.insert(key.clone(), Value::String("[REDACTED]".to_string()));
} else {
sanitized.insert(key.clone(), truncate_value(value));
}
}
Value::Object(sanitized)
}
other => truncate_value(other),
}
}
fn truncate_value(value: &Value) -> Value {
match value {
Value::String(s) if s.len() > MAX_ARG_VALUE_LEN => {
Value::String(format!("{}...[truncated]", &s[..MAX_ARG_VALUE_LEN]))
}
other => other.clone(),
}
}
fn parse_duration_ago(s: &str) -> Result<String, Box<dyn std::error::Error>> {
let s = s.trim();
if s.is_empty() {
return Err("Empty duration string".into());
}
let split_pos = s
.find(|c: char| !c.is_ascii_digit())
.ok_or_else(|| format!("Invalid duration: '{s}'. Use format like 1h, 30m, 7d"))?;
let (num_str, unit) = s.split_at(split_pos);
let count: i64 = num_str
.parse()
.map_err(|_| format!("Invalid number in duration: '{s}'"))?;
let secs_per_unit = dirs::unit_to_secs(unit)
.ok_or_else(|| format!("Invalid duration unit: '{unit}'. Use s, m, h, or d"))?;
let seconds = count * secs_per_unit as i64;
let cutoff = Utc::now() - chrono::Duration::seconds(seconds);
Ok(cutoff.to_rfc3339())
}