pub mod log;
pub mod record;
pub use log::{AuditLog, AuditSink, RequestAuditor};
pub use record::{AuditKind, AuditRecord, args_hash, now_ts};
use mcpmesh_local_api::AuditSummaryResult;
use std::path::{Path, PathBuf};
fn month_of_filename(name: &str) -> Option<String> {
let stem = name.strip_suffix(".jsonl")?;
let bytes = stem.as_bytes();
if stem.len() == 7
&& bytes[4] == b'-'
&& bytes[..4].iter().all(u8::is_ascii_digit)
&& bytes[5..].iter().all(u8::is_ascii_digit)
{
Some(stem.to_string())
} else {
None
}
}
pub fn list_month_files(dir: &Path) -> std::io::Result<Vec<(String, PathBuf, u64)>> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e),
};
for entry in entries {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(month) = month_of_filename(&name) {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
out.push((month, entry.path(), size));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
pub fn read_records(path: &Path) -> std::io::Result<Vec<AuditRecord>> {
let body = std::fs::read_to_string(path)?;
let mut out = Vec::new();
for line in body.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<AuditRecord>(line) {
Ok(rec) => out.push(rec),
Err(e) => tracing::warn!(%e, "skipping unparseable audit line"),
}
}
Ok(out)
}
pub fn read_all_records(dir: &Path) -> std::io::Result<Vec<AuditRecord>> {
let mut out = Vec::new();
for (_, path, _) in list_month_files(dir)? {
out.extend(read_records(&path)?);
}
Ok(out)
}
pub fn filter_records<'a>(
records: &'a [AuditRecord],
kind: Option<AuditKind>,
peer: Option<&str>,
) -> Vec<&'a AuditRecord> {
records
.iter()
.filter(|r| kind.is_none_or(|k| r.kind == k))
.filter(|r| peer.is_none_or(|p| r.peer.as_deref() == Some(p)))
.collect()
}
pub fn summarize_sessions(records: &[AuditRecord]) -> AuditSummaryResult {
use std::collections::BTreeMap;
let mut per_peer: BTreeMap<String, u64> = BTreeMap::new();
let mut per_service: BTreeMap<String, u64> = BTreeMap::new();
let mut total_sessions: u64 = 0;
for rec in records {
if rec.kind != AuditKind::SessionOpen {
continue;
}
total_sessions += 1;
if let Some(peer) = &rec.peer {
*per_peer.entry(peer.clone()).or_default() += 1;
}
if let Some(service) = &rec.service {
*per_service.entry(service.clone()).or_default() += 1;
}
}
AuditSummaryResult {
per_peer: per_peer.into_iter().collect(),
per_service: per_service.into_iter().collect(),
total_sessions,
}
}
pub fn prune_before(dir: &Path, before: &str) -> std::io::Result<Vec<String>> {
let mut deleted = Vec::new();
for (month, path, _) in list_month_files(dir)? {
if month.as_str() < before {
std::fs::remove_file(&path)?;
deleted.push(month);
}
}
Ok(deleted)
}
pub fn parse_kind(s: &str) -> Option<AuditKind> {
match s {
"session_open" => Some(AuditKind::SessionOpen),
"session_close" => Some(AuditKind::SessionClose),
"request" => Some(AuditKind::Request),
"blob_fetch" => Some(AuditKind::BlobFetch),
"trust" => Some(AuditKind::Trust),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::record::AuditRecord;
fn seed(dir: &std::path::Path) {
crate::audit::log::append_record(
dir,
&AuditRecord::trust(
"2026-06-30T23:59:59.000Z".into(),
"pair".into(),
Some("bob".into()),
),
)
.unwrap();
crate::audit::log::append_record(
dir,
&AuditRecord::session_open(
"2026-07-01T00:00:00.000Z".into(),
Some("bob".into()),
"notes".into(),
),
)
.unwrap();
crate::audit::log::append_record(
dir,
&AuditRecord::proxied_notification(
"2026-07-01T00:00:01.000Z".into(),
Some("bob".into()),
"notes".into(),
"tools/list".into(),
None,
"blake3:deadbeef".into(),
),
)
.unwrap();
crate::audit::log::append_record(
dir,
&AuditRecord::proxied_notification(
"2026-07-01T00:00:02.000Z".into(),
Some("alice".into()),
"notes".into(),
"tools/call".into(),
Some("read_file".into()),
"blake3:cafe".into(),
),
)
.unwrap();
}
#[test]
fn lists_monthly_files_sorted() {
let dir = tempfile::tempdir().unwrap();
seed(dir.path());
let months: Vec<String> = list_month_files(dir.path())
.unwrap()
.into_iter()
.map(|(m, _, _)| m)
.collect();
assert_eq!(months, vec!["2026-06".to_string(), "2026-07".to_string()]);
}
#[test]
fn reads_and_filters_by_kind_and_peer() {
let dir = tempfile::tempdir().unwrap();
seed(dir.path());
let all = read_all_records(dir.path()).unwrap();
assert_eq!(all.len(), 4);
let reqs = filter_records(&all, Some(AuditKind::Request), None);
assert_eq!(reqs.len(), 2);
let alice = filter_records(&all, None, Some("alice"));
assert_eq!(alice.len(), 1);
assert_eq!(alice[0].tool.as_deref(), Some("read_file"));
}
#[test]
fn prune_deletes_months_strictly_before_the_boundary() {
let dir = tempfile::tempdir().unwrap();
seed(dir.path());
let deleted = prune_before(dir.path(), "2026-07").unwrap();
assert_eq!(deleted, vec!["2026-06".to_string()]);
assert!(!dir.path().join("2026-06.jsonl").exists());
assert!(
dir.path().join("2026-07.jsonl").exists(),
"the boundary month is kept"
);
}
#[test]
fn session_summary_reconciles_with_the_raw_audit_log() {
let dir = tempfile::tempdir().unwrap();
crate::audit::log::append_record(
dir.path(),
&AuditRecord::session_open(
"2026-06-30T10:00:00.000Z".into(),
Some("bob".into()),
"notes".into(),
),
)
.unwrap();
crate::audit::log::append_record(
dir.path(),
&AuditRecord::session_open(
"2026-07-01T10:00:00.000Z".into(),
Some("bob".into()),
"notes".into(),
),
)
.unwrap();
crate::audit::log::append_record(
dir.path(),
&AuditRecord::session_open(
"2026-07-01T11:00:00.000Z".into(),
Some("alice".into()),
"notes".into(),
),
)
.unwrap();
crate::audit::log::append_record(
dir.path(),
&AuditRecord::session_open("2026-07-01T12:00:00.000Z".into(), None, "kb".into()),
)
.unwrap();
crate::audit::log::append_record(
dir.path(),
&AuditRecord::proxied_notification(
"2026-07-01T13:00:00.000Z".into(),
Some("bob".into()),
"notes".into(),
"tools/list".into(),
None,
"blake3:x".into(),
),
)
.unwrap();
crate::audit::log::append_record(
dir.path(),
&AuditRecord::trust(
"2026-07-01T14:00:00.000Z".into(),
"pair".into(),
Some("carol".into()),
),
)
.unwrap();
let all = read_all_records(dir.path()).unwrap();
let summary = summarize_sessions(&all);
assert_eq!(summary.total_sessions, 4);
for (peer, count) in &summary.per_peer {
let direct =
filter_records(&all, Some(AuditKind::SessionOpen), Some(peer)).len() as u64;
assert_eq!(
*count, direct,
"per-peer session count must reconcile with the raw log for {peer}"
);
}
assert_eq!(
summary.per_peer,
vec![("alice".to_string(), 1), ("bob".to_string(), 2)]
);
assert_eq!(
summary.per_service,
vec![("kb".to_string(), 1), ("notes".to_string(), 3)]
);
}
}