sesame/
audit.rs

1use anyhow::Context;
2use owo_colors::OwoColorize;
3
4pub(crate) fn cmd_audit_verify() -> anyhow::Result<()> {
5    let audit_path = core_config::config_dir().join("audit.jsonl");
6
7    if !audit_path.exists() {
8        println!("{}", "No audit log found.".dimmed());
9        return Ok(());
10    }
11
12    let contents = std::fs::read_to_string(&audit_path).context("failed to read audit log")?;
13
14    match core_profile::verify_chain(&contents, &core_types::AuditHash::Blake3) {
15        Ok(count) => {
16            println!("{} {} entries verified.", "OK:".green().bold(), count);
17        }
18        Err(e) => {
19            eprintln!(
20                "{} audit chain integrity check failed: {e}",
21                "FAIL:".red().bold()
22            );
23            std::process::exit(1);
24        }
25    }
26
27    Ok(())
28}
29
30pub(crate) async fn cmd_audit_tail(count: usize, follow: bool) -> anyhow::Result<()> {
31    let audit_path = core_config::config_dir().join("audit.jsonl");
32
33    if !audit_path.exists() {
34        println!("{}", "No audit log found.".dimmed());
35        return Ok(());
36    }
37
38    let contents = std::fs::read_to_string(&audit_path).context("failed to read audit log")?;
39
40    let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
41    let start = lines.len().saturating_sub(count);
42
43    for line in &lines[start..] {
44        print_audit_entry(line);
45    }
46
47    if !follow {
48        return Ok(());
49    }
50
51    // --follow: watch for new appends using notify.
52    let mut last_len = std::fs::metadata(&audit_path).map(|m| m.len()).unwrap_or(0);
53
54    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(4);
55
56    let watch_path = audit_path.clone();
57    let _watcher = {
58        use notify::{EventKind as NotifyEvent, RecommendedWatcher, RecursiveMode, Watcher};
59
60        let mut watcher = RecommendedWatcher::new(
61            move |res: Result<notify::Event, notify::Error>| {
62                if let Ok(event) = res
63                    && matches!(event.kind, NotifyEvent::Modify(_))
64                {
65                    let _ = tx.blocking_send(());
66                }
67            },
68            notify::Config::default(),
69        )
70        .context("failed to start file watcher")?;
71
72        watcher
73            .watch(
74                watch_path.parent().unwrap_or(watch_path.as_ref()),
75                RecursiveMode::NonRecursive,
76            )
77            .context("failed to watch audit log directory")?;
78
79        watcher
80    };
81
82    loop {
83        tokio::select! {
84            Some(()) = rx.recv() => {
85                let new_len = std::fs::metadata(&audit_path)
86                    .map(|m| m.len())
87                    .unwrap_or(0);
88
89                if new_len > last_len {
90                    // Read only the new bytes.
91                    use std::io::{Read, Seek, SeekFrom};
92                    let mut f = std::fs::File::open(&audit_path)?;
93                    f.seek(SeekFrom::Start(last_len))?;
94                    let mut buf = String::new();
95                    f.read_to_string(&mut buf)?;
96                    last_len = new_len;
97
98                    for line in buf.lines() {
99                        if !line.trim().is_empty() {
100                            print_audit_entry(line);
101                        }
102                    }
103                }
104            }
105            _ = tokio::signal::ctrl_c() => {
106                break;
107            }
108        }
109    }
110
111    Ok(())
112}
113
114fn print_audit_entry(line: &str) {
115    if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line)
116        && let Ok(pretty) = serde_json::to_string_pretty(&entry)
117    {
118        println!("{pretty}");
119        println!("---");
120        return;
121    }
122    println!("{line}");
123}