Skip to main content

farhand_core/
audit.rs

1//! The audit log: one JSON object per line, one file per day. It records
2//! what was asked, where, and how it ended — never file contents and never
3//! the text a guard rule matched.
4
5use std::fs::{File, OpenOptions};
6use std::io::Write;
7use std::path::PathBuf;
8use std::sync::Mutex;
9
10use serde::Serialize;
11
12use crate::error::{Error, Result};
13
14#[derive(Debug, Serialize)]
15pub struct Record<'a> {
16    pub ts: String,
17    pub session: &'a str,
18    pub host: &'a str,
19    pub tool: &'a str,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub command: Option<&'a str>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub path: Option<&'a str>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub target: Option<&'a str>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub bytes: Option<u64>,
28    pub outcome: &'a str,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub exit_code: Option<i32>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub error: Option<String>,
33    pub duration_ms: u128,
34}
35
36pub struct Audit {
37    dir: PathBuf,
38    session: String,
39    host: String,
40    file: Mutex<Option<(String, File)>>,
41    /// Why a record could not be written, once one could not.
42    failure: Mutex<Option<String>>,
43}
44
45impl Audit {
46    /// Open the log in `dir`, creating it and today's file. A log that
47    /// cannot be written is an error here, at startup: the alternative is a
48    /// session whose every record quietly goes nowhere.
49    pub fn open(dir: PathBuf, host: &str) -> Result<Self> {
50        let unwritable = |e: std::io::Error| {
51            Error::Config(format!(
52                "the audit log directory {} is not writable: {e}",
53                dir.display()
54            ))
55        };
56        std::fs::create_dir_all(&dir).map_err(unwritable)?;
57        let day = chrono::Utc::now().format("%Y-%m-%d").to_string();
58        let file = OpenOptions::new()
59            .create(true)
60            .append(true)
61            .open(dir.join(format!("{day}.jsonl")))
62            .map_err(unwritable)?;
63        let session = format!(
64            "{}-{}",
65            chrono::Utc::now().format("%Y%m%dT%H%M%S"),
66            std::process::id()
67        );
68        Ok(Self {
69            dir,
70            session,
71            host: host.to_string(),
72            file: Mutex::new(Some((day, file))),
73            failure: Mutex::new(None),
74        })
75    }
76
77    pub fn session(&self) -> &str {
78        &self.session
79    }
80
81    pub fn dir(&self) -> &PathBuf {
82        &self.dir
83    }
84
85    /// Why the log stopped being written, if it has. A session that has
86    /// lost a record refuses every later call (see the server): the log is
87    /// the record of what the model did, and it does not go on unrecorded.
88    pub fn failure(&self) -> Option<String> {
89        lock(&self.failure).clone()
90    }
91
92    /// Append one record. A failure to write it is reported on stderr and
93    /// remembered in [`failure`](Self::failure).
94    pub fn record<'a>(&'a self, mut rec: Record<'a>) {
95        if let Err(e) = self.write(&mut rec) {
96            eprintln!("farhand: {e}; refusing every further call");
97            lock(&self.failure).get_or_insert(e);
98        }
99    }
100
101    fn write<'a>(&'a self, rec: &mut Record<'a>) -> std::result::Result<(), String> {
102        rec.ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
103        rec.session = &self.session;
104        rec.host = &self.host;
105        let line =
106            serde_json::to_string(&rec).map_err(|e| format!("audit serialise failed: {e}"))?;
107        let day = chrono::Utc::now().format("%Y-%m-%d").to_string();
108        let mut guard = lock(&self.file);
109        let needs_open = !matches!(&*guard, Some((d, _)) if *d == day);
110        if needs_open {
111            let path = self.dir.join(format!("{day}.jsonl"));
112            let f = OpenOptions::new()
113                .create(true)
114                .append(true)
115                .open(&path)
116                .map_err(|e| format!("cannot open audit log {}: {e}", path.display()))?;
117            *guard = Some((day, f));
118        }
119        let Some((_, f)) = &mut *guard else {
120            unreachable!("opened above")
121        };
122        writeln!(f, "{line}").map_err(|e| format!("audit write failed: {e}"))
123    }
124}
125
126fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
127    match m.lock() {
128        Ok(g) => g,
129        Err(p) => p.into_inner(),
130    }
131}
132
133impl<'a> Record<'a> {
134    pub fn new(tool: &'a str) -> Self {
135        Self {
136            ts: String::new(),
137            session: "",
138            host: "",
139            tool,
140            command: None,
141            path: None,
142            target: None,
143            bytes: None,
144            outcome: "ok",
145            exit_code: None,
146            error: None,
147            duration_ms: 0,
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn writes_one_line_per_record() {
158        let tmp = tempfile::tempdir().unwrap();
159        let audit = Audit::open(tmp.path().join("audit"), "devbox").unwrap();
160        let mut r = Record::new("remote_shell");
161        r.command = Some("ls");
162        r.exit_code = Some(0);
163        audit.record(r);
164        let mut r = Record::new("upload");
165        r.outcome = "denied";
166        r.error = Some("rule: private-key-block".into());
167        audit.record(r);
168        let files: Vec<_> = std::fs::read_dir(tmp.path().join("audit"))
169            .unwrap()
170            .collect();
171        assert_eq!(files.len(), 1);
172        let text = std::fs::read_to_string(files[0].as_ref().unwrap().path()).unwrap();
173        let lines: Vec<&str> = text.lines().collect();
174        assert_eq!(lines.len(), 2);
175        let v: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
176        assert_eq!(v["host"], "devbox");
177        assert_eq!(v["command"], "ls");
178        assert!(v["path"].is_null());
179    }
180
181    #[test]
182    fn a_lost_record_is_remembered() {
183        let tmp = tempfile::tempdir().unwrap();
184        let dir = tmp.path().join("audit");
185        let audit = Audit::open(dir.clone(), "devbox").unwrap();
186        audit.record(Record::new("remote_info"));
187        assert!(audit.failure().is_none());
188        // The day's file can no longer be opened.
189        *lock(&audit.file) = None;
190        std::fs::remove_dir_all(&dir).unwrap();
191        std::fs::write(&dir, "").unwrap();
192        audit.record(Record::new("remote_info"));
193        assert!(audit.failure().unwrap().contains("cannot open"));
194    }
195
196    #[test]
197    fn unwritable_dir_fails_at_open() {
198        let tmp = tempfile::tempdir().unwrap();
199        let file = tmp.path().join("not-a-dir");
200        std::fs::write(&file, "").unwrap();
201        let err = Audit::open(file.join("audit"), "devbox").err().unwrap();
202        assert!(err.to_string().contains("not writable"), "{err}");
203    }
204}