use std::collections::{BTreeMap, HashMap};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde_json::{Map, Value};
use super::wire::{EgressRow, GuardEvent};
use super::{GUARD_KINDS, ProError, ProResult};
const RECENT_KEEP: usize = 300;
const MAX_TRAIL_BYTES: u64 = 4 << 30;
const MAX_RECEIPTS: usize = 3_000;
const MAX_RECEIPT_BYTES: u64 = 64 << 20;
const FIXTURE_MODELS: [&str; 6] = [
"claude-x",
"gpt-x",
"gpt-4o-mini",
"m",
"model",
"test-model",
];
fn audit_pattern() -> &'static regex::bytes::Regex {
static PATTERN: OnceLock<regex::bytes::Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
let kinds = GUARD_KINDS.join("|");
regex::bytes::Regex::new(&format!(
r#""timestamp":"(\d{{4}}-\d{{2}}-\d{{2}})T(\d{{2}}:\d{{2}}:\d{{2}})[^"]*".*?"tool":"([^"]{{0,80}})".*?"action":"({kinds}):(\d{{1,9}})"#
))
.expect("static regex")
})
}
pub(crate) fn parse_event(line: &[u8]) -> Option<GuardEvent> {
if !line.windows(10).any(|w| w == b"\"action\":\"") {
return None;
}
let caps = audit_pattern().captures(line)?;
let text = |i: usize| String::from_utf8_lossy(&caps[i]).into_owned();
Some(GuardEvent {
at: format!("{}T{}Z", text(1), text(2)),
kind: text(4),
count: text(5).parse().unwrap_or(0),
tool: text(3),
})
}
pub(crate) fn scan_lines(path: &Path, offset: u64, mut visit: impl FnMut(&[u8])) -> ProResult<u64> {
let files = trail_files(path);
let total: u64 = files.iter().map(|(_, len)| len).sum();
let start = if total < offset { 0 } else { offset };
if total - start > MAX_TRAIL_BYTES {
return Err(ProError::new(413, "The audit trail is too large to read"));
}
let unreadable = |_| ProError::internal("Audit trail unreadable");
let mut position = start;
let mut base = 0u64;
for (file, len) in files {
let end = base + len;
if end <= position {
base = end;
continue;
}
let Ok(mut file) = std::fs::File::open(&file) else {
break;
};
file.seek(SeekFrom::Start(position - base))
.map_err(unreadable)?;
let mut reader = BufReader::new(file.take(end - position));
let mut line = Vec::new();
loop {
line.clear();
let read = reader.read_until(b'\n', &mut line).map_err(unreadable)?;
if read == 0 {
break;
}
if line.last() != Some(&b'\n') {
return Ok(position);
}
position += read as u64;
visit(&line);
}
base = end;
}
Ok(position)
}
fn trail_files(path: &Path) -> Vec<(PathBuf, u64)> {
crate::core::audit_trail::trail_files_since(path, None)
.into_iter()
.filter_map(|file| {
let meta = std::fs::symlink_metadata(&file).ok()?;
meta.file_type().is_file().then_some((file, meta.len()))
})
.collect()
}
pub(crate) fn trail_len(path: &Path) -> u64 {
trail_files(path).iter().map(|(_, len)| len).sum()
}
#[derive(Default)]
struct Timeline {
offset: u64,
days: BTreeMap<String, BTreeMap<String, i64>>,
recent: Vec<GuardEvent>,
}
type TimelineKey = (PathBuf, Option<blake3::Hash>);
fn timelines() -> &'static Mutex<HashMap<TimelineKey, Timeline>> {
static T: OnceLock<Mutex<HashMap<TimelineKey, Timeline>>> = OnceLock::new();
T.get_or_init(|| Mutex::new(HashMap::new()))
}
fn session_pattern() -> &'static regex::bytes::Regex {
static PATTERN: OnceLock<regex::bytes::Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
regex::bytes::Regex::new(r#""action":"[^"]*\|session=([A-Za-z0-9_.-]{1,128})""#)
.expect("static regex")
})
}
fn line_session(line: &[u8]) -> Option<&str> {
let caps = session_pattern().captures(line)?;
std::str::from_utf8(caps.get(1)?.as_bytes()).ok()
}
pub(crate) fn timeline(trail: &Path) -> ProResult<(Vec<Value>, Vec<GuardEvent>)> {
timeline_for(trail, None)
}
pub(crate) fn timeline_for(
trail: &Path,
sessions: Option<&std::collections::HashSet<String>>,
) -> ProResult<(Vec<Value>, Vec<GuardEvent>)> {
let digest = sessions.map(|sessions| {
let mut ids: Vec<&str> = sessions.iter().map(String::as_str).collect();
ids.sort_unstable();
blake3::hash(ids.join("\n").as_bytes())
});
let mut map = timelines()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if digest.is_some() {
map.retain(|(path, kept), _| path != trail || kept.is_none() || *kept == digest);
}
let entry = map.entry((trail.to_path_buf(), digest)).or_default();
if trail_len(trail) < entry.offset {
*entry = Timeline::default();
}
let mut fresh = Vec::new();
entry.offset = scan_lines(trail, entry.offset, |line| {
if let Some(sessions) = sessions
&& !line_session(line).is_some_and(|id| sessions.contains(id))
{
return;
}
if let Some(event) = parse_event(line) {
fresh.push(event);
}
})?;
for event in fresh {
*entry
.days
.entry(event.at[..10].to_string())
.or_default()
.entry(event.kind.clone())
.or_default() += event.count;
entry.recent.push(event);
}
let keep = entry.recent.len().saturating_sub(RECENT_KEEP);
entry.recent.drain(..keep);
let days = entry
.days
.iter()
.map(|(day, kinds)| {
let mut row = Map::new();
row.insert("day".into(), day.clone().into());
for kind in GUARD_KINDS {
row.insert(kind.into(), kinds.get(kind).copied().unwrap_or(0).into());
}
Value::Object(row)
})
.collect();
Ok((days, entry.recent.clone()))
}
fn is_packed(name: &str) -> bool {
Path::new(name)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zst"))
}
fn receipt_digest(name: &str) -> Option<&str> {
name.strip_suffix(".json.zst")
.or_else(|| name.strip_suffix(".json"))
.filter(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_alphanumeric()))
}
fn receipt_tail(path: &Path, compressed: bool) -> Option<Value> {
let marker = b"],\"destination\":";
let parse = |tail: &[u8]| -> Option<Value> {
let at = tail.windows(marker.len()).rposition(|w| w == marker)?;
let mut json = Vec::with_capacity(tail.len() - at);
json.push(b'{');
json.extend_from_slice(&tail[at + 2..]);
serde_json::from_slice(&json).ok()
};
if compressed {
let file = std::fs::File::open(path).ok()?;
let mut raw = Vec::new();
zstd::stream::read::Decoder::new(file)
.ok()?
.take(MAX_RECEIPT_BYTES)
.read_to_end(&mut raw)
.ok()?;
return parse(&raw);
}
let mut file = std::fs::File::open(path).ok()?;
let size = file.metadata().ok()?.len();
for window in [16_384u64, 262_144] {
file.seek(SeekFrom::Start(size.saturating_sub(window)))
.ok()?;
let mut tail = Vec::new();
(&mut file).take(window).read_to_end(&mut tail).ok()?;
if let Some(value) = parse(&tail) {
return Some(value);
}
}
None
}
fn summarize(path: &Path, mtime: i64, compressed: bool) -> Option<EgressRow> {
let receipt = receipt_tail(path, compressed)?;
let s = |v: &Value, key: &str, default: &str, limit: usize| -> String {
v.get(key)
.and_then(Value::as_str)
.unwrap_or(default)
.chars()
.take(limit)
.collect()
};
let n = |v: &Value, key: &str| v.get(key).and_then(Value::as_i64).unwrap_or(0);
let destination = receipt.get("destination").cloned().unwrap_or(Value::Null);
let tokens = receipt.get("tokens").cloned().unwrap_or(Value::Null);
let security = receipt.get("security").cloned().unwrap_or(Value::Null);
Some(EgressRow {
mtime,
provider: s(&destination, "provider", "unknown", 60),
model: s(&destination, "model", "", 80),
locality: s(&destination, "locality", "unknown", 20),
outcome: s(&receipt, "outcome", "unknown", 20),
original: n(&tokens, "original"),
delivered: n(&tokens, "delivered"),
redactions: n(&security, "redactions"),
blocked: n(&security, "blocked_objects"),
injection: n(&security, "injection_signals"),
incomplete: n(&security, "incomplete_coverage"),
mode: s(&receipt, "mode", "", 20),
})
}
fn receipt_cache() -> &'static Mutex<HashMap<PathBuf, HashMap<String, EgressRow>>> {
static C: OnceLock<Mutex<HashMap<PathBuf, HashMap<String, EgressRow>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(crate) fn egress_rows(receipts: &Path, since_unix: i64) -> Value {
let Ok(entries) = std::fs::read_dir(receipts) else {
return serde_json::json!({ "rows": [], "indexed": 0, "total": 0, "running": false });
};
let mut files: BTreeMap<String, (String, i64)> = BTreeMap::new();
for entry in entries.filter_map(Result::ok) {
let Ok(meta) = entry.metadata() else { continue };
if !meta.file_type().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let Some(digest) = receipt_digest(&name) else {
continue;
};
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0));
if mtime < since_unix {
continue;
}
let digest = digest.to_string();
if files
.get(&digest)
.is_none_or(|(existing, _)| !is_packed(existing))
{
files.insert(digest, (name, mtime));
}
}
let total = files.len();
let mut newest: Vec<(String, (String, i64))> = files.into_iter().collect();
newest.sort_by_key(|a| std::cmp::Reverse(a.1.1));
newest.truncate(MAX_RECEIPTS);
let mut cache = receipt_cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let known = cache.entry(receipts.to_path_buf()).or_default();
let listed: std::collections::HashSet<&String> = newest.iter().map(|(d, _)| d).collect();
known.retain(|digest, _| listed.contains(digest));
let mut rows = Vec::with_capacity(newest.len());
for (digest, (name, mtime)) in &newest {
if !known.contains_key(digest) {
let path = receipts.join(name);
if let Some(row) = summarize(&path, *mtime, is_packed(name)) {
known.insert(digest.clone(), row);
}
}
if let Some(row) = known.get(digest)
&& !FIXTURE_MODELS.contains(&row.model.as_str())
{
rows.push(row.clone());
}
}
serde_json::json!({
"rows": rows,
"indexed": newest.len(),
"total": total,
"running": false,
})
}