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}
42
43impl Audit {
44 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 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}