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}
42
43impl Audit {
44    /// Open the log in `dir`, creating it and today's file. A log that
45    /// cannot be written is an error here, at startup: the alternative is a
46    /// session whose every record quietly goes nowhere.
47    pub fn open(dir: PathBuf, host: &str) -> Result<Self> {
48        let unwritable = |e: std::io::Error| {
49            Error::Config(format!(
50                "the audit log directory {} is not writable: {e}",
51                dir.display()
52            ))
53        };
54        std::fs::create_dir_all(&dir).map_err(unwritable)?;
55        let day = chrono::Utc::now().format("%Y-%m-%d").to_string();
56        let file = OpenOptions::new()
57            .create(true)
58            .append(true)
59            .open(dir.join(format!("{day}.jsonl")))
60            .map_err(unwritable)?;
61        let session = format!(
62            "{}-{}",
63            chrono::Utc::now().format("%Y%m%dT%H%M%S"),
64            std::process::id()
65        );
66        Ok(Self {
67            dir,
68            session,
69            host: host.to_string(),
70            file: Mutex::new(Some((day, file))),
71        })
72    }
73
74    pub fn session(&self) -> &str {
75        &self.session
76    }
77
78    pub fn dir(&self) -> &PathBuf {
79        &self.dir
80    }
81
82    /// Append one record. A failure to write the log is reported on stderr
83    /// and otherwise ignored: losing an audit line must not block work,
84    /// but it must not be silent either.
85    pub fn record<'a>(&'a self, mut rec: Record<'a>) {
86        rec.ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
87        rec.session = &self.session;
88        rec.host = &self.host;
89        let line = match serde_json::to_string(&rec) {
90            Ok(l) => l,
91            Err(e) => {
92                eprintln!("farhand: audit serialise failed: {e}");
93                return;
94            }
95        };
96        let day = chrono::Utc::now().format("%Y-%m-%d").to_string();
97        let mut guard = match self.file.lock() {
98            Ok(g) => g,
99            Err(p) => p.into_inner(),
100        };
101        let needs_open = !matches!(&*guard, Some((d, _)) if *d == day);
102        if needs_open {
103            let path = self.dir.join(format!("{day}.jsonl"));
104            match OpenOptions::new().create(true).append(true).open(&path) {
105                Ok(f) => *guard = Some((day, f)),
106                Err(e) => {
107                    eprintln!("farhand: cannot open audit log {}: {e}", path.display());
108                    return;
109                }
110            }
111        }
112        if let Some((_, f)) = &mut *guard {
113            if let Err(e) = writeln!(f, "{line}") {
114                eprintln!("farhand: audit write failed: {e}");
115            }
116        }
117    }
118}
119
120impl<'a> Record<'a> {
121    pub fn new(tool: &'a str) -> Self {
122        Self {
123            ts: String::new(),
124            session: "",
125            host: "",
126            tool,
127            command: None,
128            path: None,
129            target: None,
130            bytes: None,
131            outcome: "ok",
132            exit_code: None,
133            error: None,
134            duration_ms: 0,
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn writes_one_line_per_record() {
145        let tmp = tempfile::tempdir().unwrap();
146        let audit = Audit::open(tmp.path().join("audit"), "devbox").unwrap();
147        let mut r = Record::new("remote_shell");
148        r.command = Some("ls");
149        r.exit_code = Some(0);
150        audit.record(r);
151        let mut r = Record::new("upload");
152        r.outcome = "denied";
153        r.error = Some("rule: private-key-block".into());
154        audit.record(r);
155        let files: Vec<_> = std::fs::read_dir(tmp.path().join("audit"))
156            .unwrap()
157            .collect();
158        assert_eq!(files.len(), 1);
159        let text = std::fs::read_to_string(files[0].as_ref().unwrap().path()).unwrap();
160        let lines: Vec<&str> = text.lines().collect();
161        assert_eq!(lines.len(), 2);
162        let v: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
163        assert_eq!(v["host"], "devbox");
164        assert_eq!(v["command"], "ls");
165        assert!(v["path"].is_null());
166    }
167
168    #[test]
169    fn unwritable_dir_fails_at_open() {
170        let tmp = tempfile::tempdir().unwrap();
171        let file = tmp.path().join("not-a-dir");
172        std::fs::write(&file, "").unwrap();
173        let err = Audit::open(file.join("audit"), "devbox").err().unwrap();
174        assert!(err.to_string().contains("not writable"), "{err}");
175    }
176}