1use 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 failure: Mutex<Option<String>>,
43}
44
45impl Audit {
46 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 pub fn failure(&self) -> Option<String> {
89 lock(&self.failure).clone()
90 }
91
92 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 *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}