use std::path::Path;
use anyhow::{Context, Result};
use crate::debug::AuditLogEntry;
use crate::style;
#[derive(Debug, Default)]
pub struct LogFilter {
pub effect: Option<String>,
pub tool: Option<String>,
pub session: Option<String>,
pub exclude_session: Option<String>,
pub since: Option<f64>,
pub limit: usize,
}
pub fn read_log_file(path: &Path) -> Result<Vec<AuditLogEntry>> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read audit log: {}", path.display()))?;
let mut entries = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<AuditLogEntry>(line) {
Ok(entry) => entries.push(entry),
Err(e) => {
tracing::debug!(error = %e, line = line, "skipping malformed audit log entry");
}
}
}
Ok(entries)
}
pub fn read_session_log(session_id: &str) -> Result<Vec<AuditLogEntry>> {
let path = crate::session_dir::SessionDir::new(session_id).audit_log();
if !path.exists() {
anyhow::bail!(
"no audit log found for session {session_id} (expected {})",
path.display()
);
}
let mut entries = read_log_file(&path)?;
backfill_session_id(&mut entries, session_id);
Ok(entries)
}
pub fn read_global_log() -> Result<Vec<AuditLogEntry>> {
let path = dirs::home_dir()
.map(|h| h.join(".clash").join("audit.jsonl"))
.unwrap_or_else(|| std::path::PathBuf::from("audit.jsonl"));
if !path.exists() {
anyhow::bail!(
"no global audit log found at {} (enable audit logging in policy.yaml)",
path.display()
);
}
read_log_file(&path)
}
pub fn read_all_session_logs() -> Result<Vec<AuditLogEntry>> {
let mut all_entries = Vec::new();
if let Ok(sessions_dir) =
crate::settings::ClashSettings::settings_dir().map(|d| d.join("sessions"))
{
scan_session_dirs(&sessions_dir, &mut all_entries);
}
all_entries.sort_by(|a, b| {
a.timestamp_secs()
.partial_cmp(&b.timestamp_secs())
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(all_entries)
}
fn scan_session_dirs(sessions_dir: &Path, all_entries: &mut Vec<AuditLogEntry>) {
if let Ok(readdir) = std::fs::read_dir(sessions_dir) {
for entry in readdir.flatten() {
let session_id = entry.file_name();
let session_id = session_id.to_string_lossy();
let log_path = entry.path().join("audit.jsonl");
if log_path.exists() {
match read_log_file(&log_path) {
Ok(mut entries) => {
backfill_session_id(&mut entries, &session_id);
all_entries.extend(entries);
}
Err(e) => {
tracing::debug!(
path = %log_path.display(),
error = %e,
"skipping unreadable session log"
);
}
}
}
}
}
}
fn backfill_session_id(entries: &mut [AuditLogEntry], session_id: &str) {
for entry in entries.iter_mut() {
if entry.session_id.is_empty() {
entry.session_id = session_id.to_string();
}
}
}
pub fn find_by_hash(hash: &str) -> Result<AuditLogEntry> {
let entries = read_all_session_logs()?;
let matches: Vec<_> = entries
.into_iter()
.filter(|e| e.short_hash().starts_with(hash))
.collect();
match matches.len() {
0 => anyhow::bail!("no audit log entry matching '{hash}'"),
1 => Ok(matches.into_iter().next().expect("len == 1")),
n => anyhow::bail!("ambiguous hash '{hash}' matches {n} entries — use more characters"),
}
}
pub fn filter_entries(entries: Vec<AuditLogEntry>, filter: &LogFilter) -> Vec<AuditLogEntry> {
let mut filtered: Vec<AuditLogEntry> = entries
.into_iter()
.filter(|e| {
if let Some(ref effect) = filter.effect
&& !e.decision.eq_ignore_ascii_case(effect)
{
return false;
}
if let Some(ref tool) = filter.tool
&& !e.tool_name.eq_ignore_ascii_case(tool)
{
return false;
}
if let Some(ref session) = filter.session
&& !e.session_id.contains(session.as_str())
{
return false;
}
if let Some(ref exclude) = filter.exclude_session
&& e.session_id.contains(exclude.as_str())
{
return false;
}
if let Some(since) = filter.since
&& let Some(ts) = e.timestamp_secs()
&& ts < since
{
return false;
}
true
})
.collect();
if filter.limit > 0 && filtered.len() > filter.limit {
filtered = filtered.split_off(filtered.len() - filter.limit);
}
filtered
}
pub fn parse_duration(s: &str) -> Result<f64> {
let s = s.trim();
if s.is_empty() {
anyhow::bail!("empty duration string");
}
let (num_str, unit) = if let Some(stripped) = s.strip_suffix('s') {
(stripped, 1.0)
} else if let Some(stripped) = s.strip_suffix('m') {
(stripped, 60.0)
} else if let Some(stripped) = s.strip_suffix('h') {
(stripped, 3600.0)
} else if let Some(stripped) = s.strip_suffix('d') {
(stripped, 86400.0)
} else {
(s, 1.0)
};
let num: f64 = num_str
.trim()
.parse()
.with_context(|| format!("invalid duration: '{s}' (expected e.g. '5m', '1h', '30s')"))?;
Ok(num * unit)
}
fn format_timestamp(ts: &str) -> String {
let secs: f64 = match ts.parse() {
Ok(s) => s,
Err(_) => return ts.to_string(),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
let ago = now - secs;
if ago < 60.0 {
format!("{:.0}s ago", ago)
} else if ago < 3600.0 {
format!("{:.0}m ago", ago / 60.0)
} else if ago < 86400.0 {
format!("{:.1}h ago", ago / 3600.0)
} else {
format!("{:.1}d ago", ago / 86400.0)
}
}
fn effect_symbol(decision: &str) -> String {
match decision {
"allow" => style::green("\u{2713}"),
"deny" => style::red("\u{2717}"),
"ask" => style::yellow("?"),
_ => decision.to_string(),
}
}
pub fn format_table(entries: &[AuditLogEntry]) -> String {
if entries.is_empty() {
return style::dim("No audit log entries found.").to_string();
}
let mut lines = Vec::new();
let sessions: std::collections::HashSet<&str> =
entries.iter().map(|e| e.session_id.as_str()).collect();
let multi_session = sessions.len() > 1;
if !multi_session {
let sid = entries[0].session_id.as_str();
let label = if sid.is_empty() { "unknown" } else { sid };
lines.push(format!(
" {} {}",
style::dim("session:"),
style::dim(label)
));
lines.push(String::new());
}
let pad = |s: &str, w: usize| -> String {
if s.len() >= w {
s[..w].to_string()
} else {
format!("{s:<w$}")
}
};
let pad_right = |s: &str, w: usize| -> String {
if s.len() >= w {
s[..w].to_string()
} else {
format!("{s:>w$}")
}
};
if multi_session {
lines.push(format!(
" {} {} {} {} {} {} {}",
" ",
style::dim(&pad("id", 7)),
style::dim(&pad_right("when", 8)),
style::dim(&pad("session", 12)),
style::dim(&pad("tool", 6)),
style::dim(&pad("subject", 44)),
style::dim("resolution"),
));
} else {
lines.push(format!(
" {} {} {} {} {} {}",
" ",
style::dim(&pad("id", 7)),
style::dim(&pad_right("when", 8)),
style::dim(&pad("tool", 6)),
style::dim(&pad("subject", 50)),
style::dim("resolution"),
));
}
for entry in entries {
let symbol = effect_symbol(&entry.decision);
let hash = pad(&entry.short_hash(), 7);
let when = pad_right(&format_timestamp(&entry.timestamp), 8);
let subject_width = if multi_session { 44 } else { 50 };
let subject = pad(
&truncate(&entry.tool_input_summary, subject_width),
subject_width,
);
let resolution = truncate(&entry.resolution, 40);
let tool_label = if let Some(ref mode) = entry.mode {
format!("{}:{}", entry.tool_name, mode)
} else {
entry.tool_name.clone()
};
let tool = pad(&tool_label, 6);
if multi_session {
let session = pad(&truncate(&short_session_id(&entry.session_id), 12), 12);
lines.push(format!(
" {} {} {} {} {} {} {}",
symbol,
style::dim(&hash),
style::dim(&when),
style::dim(&session),
tool,
subject,
style::dim(&resolution),
));
} else {
lines.push(format!(
" {} {} {} {} {} {}",
symbol,
style::dim(&hash),
style::dim(&when),
tool,
subject,
style::dim(&resolution),
));
}
}
let total = entries.len();
let denials = entries.iter().filter(|e| e.decision == "deny").count();
if denials > 0 {
lines.push(String::new());
lines.push(format!(
" {} {total} entries, {} denied. Use {} to replay a denial.",
style::dim("\u{2139}"),
style::red(&denials.to_string()),
style::cyan("clash debug replay <id>"),
));
}
lines.join("\n")
}
pub fn format_json(entries: &[AuditLogEntry]) -> Result<String> {
let enriched: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
let mut v = serde_json::to_value(e).unwrap_or_default();
v["id"] = serde_json::Value::String(e.short_hash());
v
})
.collect();
serde_json::to_string_pretty(&enriched).context("failed to serialize audit entries as JSON")
}
fn short_session_id(id: &str) -> String {
if id.len() <= 12 {
return id.to_string();
}
if let Some(pos) = id[..12].rfind('-')
&& pos >= 4
{
return format!("{}..", &id[..pos]);
}
format!("{}..", &id[..10])
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let cut = s
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= max.saturating_sub(3))
.last()
.unwrap_or(0);
format!("{}...", &s[..cut])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration_seconds() {
assert!((parse_duration("30s").unwrap() - 30.0).abs() < 0.001);
}
#[test]
fn test_parse_duration_minutes() {
assert!((parse_duration("5m").unwrap() - 300.0).abs() < 0.001);
}
#[test]
fn test_parse_duration_hours() {
assert!((parse_duration("2h").unwrap() - 7200.0).abs() < 0.001);
}
#[test]
fn test_parse_duration_days() {
assert!((parse_duration("1d").unwrap() - 86400.0).abs() < 0.001);
}
#[test]
fn test_parse_duration_no_unit_defaults_to_seconds() {
assert!((parse_duration("45").unwrap() - 45.0).abs() < 0.001);
}
#[test]
fn test_parse_duration_invalid() {
assert!(parse_duration("abc").is_err());
assert!(parse_duration("").is_err());
}
#[test]
fn test_filter_by_effect() {
let entries = vec![
AuditLogEntry {
timestamp: "1000.0".into(),
session_id: "test".into(),
tool_name: "Bash".into(),
tool_input_summary: "ls".into(),
decision: "allow".into(),
reason: None,
matched_rules: 1,
skipped_rules: 0,
resolution: "result: allow".into(),
mode: None,
},
AuditLogEntry {
timestamp: "1001.0".into(),
session_id: "test".into(),
tool_name: "Bash".into(),
tool_input_summary: "rm -rf /".into(),
decision: "deny".into(),
reason: None,
matched_rules: 1,
skipped_rules: 0,
resolution: "result: deny".into(),
mode: None,
},
];
let filter = LogFilter {
effect: Some("deny".into()),
..Default::default()
};
let filtered = filter_entries(entries, &filter);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].decision, "deny");
}
#[test]
fn test_filter_by_tool() {
let entries = vec![
AuditLogEntry {
timestamp: "1000.0".into(),
session_id: "test".into(),
tool_name: "Bash".into(),
tool_input_summary: "ls".into(),
decision: "allow".into(),
reason: None,
matched_rules: 1,
skipped_rules: 0,
resolution: "result: allow".into(),
mode: None,
},
AuditLogEntry {
timestamp: "1001.0".into(),
session_id: "test".into(),
tool_name: "Read".into(),
tool_input_summary: "/tmp/file".into(),
decision: "allow".into(),
reason: None,
matched_rules: 1,
skipped_rules: 0,
resolution: "result: allow".into(),
mode: None,
},
];
let filter = LogFilter {
tool: Some("Read".into()),
..Default::default()
};
let filtered = filter_entries(entries, &filter);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].tool_name, "Read");
}
#[test]
fn test_filter_limit_takes_last() {
let entries: Vec<AuditLogEntry> = (0..10)
.map(|i| AuditLogEntry {
timestamp: format!("{}.0", 1000 + i),
session_id: "test".into(),
tool_name: "Bash".into(),
tool_input_summary: format!("cmd {i}"),
decision: "allow".into(),
reason: None,
matched_rules: 1,
skipped_rules: 0,
resolution: "result: allow".into(),
mode: None,
})
.collect();
let filter = LogFilter {
limit: 3,
..Default::default()
};
let filtered = filter_entries(entries, &filter);
assert_eq!(filtered.len(), 3);
assert_eq!(filtered[0].tool_input_summary, "cmd 7");
assert_eq!(filtered[2].tool_input_summary, "cmd 9");
}
#[test]
fn test_truncate() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world!", 8), "hello...");
}
}