1use std::collections::HashMap;
16use std::fs::OpenOptions;
17use std::io::{self, Write};
18use std::path::{Path, PathBuf};
19
20use chrono::{DateTime, Utc};
21use serde::Serialize;
22use thiserror::Error;
23
24#[derive(Debug, Serialize)]
26#[serde(tag = "event", rename_all = "snake_case")]
27pub enum AuditEvent {
28 Start {
32 timestamp: DateTime<Utc>,
33 user: String,
34 group: String,
35 extension: String,
36 args: Vec<String>,
37 env_var_names: Vec<String>,
38 },
39 Finish {
42 timestamp: DateTime<Utc>,
43 group: String,
44 extension: String,
45 exit_code: i32,
46 duration_ms: u128,
47 },
48 Interrupted {
51 timestamp: DateTime<Utc>,
52 group: String,
53 extension: String,
54 signal: String,
55 exit_code: i32,
56 duration_ms: u128,
57 },
58}
59
60#[derive(Debug, Error)]
62pub enum AuditError {
63 #[error("could not expand audit_log path {literal:?}: {source}")]
64 Expand {
65 literal: String,
66 #[source]
67 source: shellexpand::LookupError<std::env::VarError>,
68 },
69 #[error("could not create audit log directory {path:?}: {source}")]
70 CreateDir {
71 path: PathBuf,
72 #[source]
73 source: io::Error,
74 },
75 #[error("could not write audit log {path:?}: {source}")]
76 Write {
77 path: PathBuf,
78 #[source]
79 source: io::Error,
80 },
81 #[error("could not serialize audit event: {0}")]
82 Serialize(#[from] serde_json::Error),
83}
84
85pub fn expand_path<S: ::std::hash::BuildHasher>(
93 literal: &str,
94 defaults: &HashMap<String, String, S>,
95) -> Result<PathBuf, AuditError> {
96 let lookup = |name: &str| -> Result<Option<String>, std::env::VarError> {
97 match std::env::var(name) {
98 Ok(v) if !v.is_empty() => Ok(Some(v)),
99 Ok(_) | Err(std::env::VarError::NotPresent) => match defaults.get(name) {
100 Some(d) => Ok(Some(d.clone())),
101 None => Err(std::env::VarError::NotPresent),
105 },
106 Err(e) => Err(e),
107 }
108 };
109 let expanded =
110 shellexpand::full_with_context(literal, dirs::home_dir, lookup).map_err(|e| {
111 AuditError::Expand {
112 literal: literal.to_owned(),
113 source: e,
114 }
115 })?;
116 Ok(PathBuf::from(expanded.into_owned()))
117}
118
119pub fn append(path: &Path, event: &AuditEvent) -> Result<(), AuditError> {
129 if let Some(parent) = path.parent() {
130 if !parent.as_os_str().is_empty() {
131 std::fs::create_dir_all(parent).map_err(|source| AuditError::CreateDir {
132 path: parent.to_path_buf(),
133 source,
134 })?;
135 }
136 }
137 let mut line = serde_json::to_vec(event)?;
138 line.push(b'\n');
139 let file = OpenOptions::new()
140 .append(true)
141 .create(true)
142 .open(path)
143 .map_err(|source| AuditError::Write {
144 path: path.to_path_buf(),
145 source,
146 })?;
147 write_locked(path, file, &line)
148}
149
150#[cfg(unix)]
151fn write_locked(path: &Path, file: std::fs::File, line: &[u8]) -> Result<(), AuditError> {
152 use nix::fcntl::{Flock, FlockArg};
153 let mut guard =
154 Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, errno)| AuditError::Write {
155 path: path.to_path_buf(),
156 source: std::io::Error::from_raw_os_error(errno as i32),
157 })?;
158 guard.write_all(line).map_err(|source| AuditError::Write {
159 path: path.to_path_buf(),
160 source,
161 })?;
162 Ok(())
163 }
165
166#[cfg(not(unix))]
167fn write_locked(path: &Path, mut file: std::fs::File, line: &[u8]) -> Result<(), AuditError> {
168 file.write_all(line).map_err(|source| AuditError::Write {
169 path: path.to_path_buf(),
170 source,
171 })
172}
173
174#[must_use]
178pub fn current_user() -> String {
179 std::env::var("USER")
180 .or_else(|_| std::env::var("USERNAME"))
181 .unwrap_or_else(|_| "unknown".into())
182}
183
184mod dirs {
185 pub fn home_dir() -> Option<String> {
192 #[cfg(unix)]
193 {
194 std::env::var("HOME").ok().filter(|s| !s.is_empty())
195 }
196 #[cfg(windows)]
197 {
198 std::env::var("USERPROFILE").ok().filter(|s| !s.is_empty())
199 }
200 #[cfg(not(any(unix, windows)))]
201 {
202 None
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use serial_test::serial;
211
212 #[test]
213 #[serial]
214 fn expand_uses_process_env_first() {
215 let mut defaults = HashMap::new();
216 defaults.insert("QLI_TEST_AUDIT_VAR".into(), "from-defaults".into());
217 std::env::set_var("QLI_TEST_AUDIT_VAR", "from-env");
219 let p = expand_path("$QLI_TEST_AUDIT_VAR/file.log", &defaults).unwrap();
220 assert_eq!(p, PathBuf::from("from-env/file.log"));
221 std::env::remove_var("QLI_TEST_AUDIT_VAR");
222 }
223
224 #[test]
225 #[serial]
226 fn expand_falls_back_to_defaults_when_env_unset() {
227 let mut defaults = HashMap::new();
228 defaults.insert("QLI_TEST_AUDIT_UNSET".into(), "from-defaults".into());
229 std::env::remove_var("QLI_TEST_AUDIT_UNSET");
230 let p = expand_path("$QLI_TEST_AUDIT_UNSET/file.log", &defaults).unwrap();
231 assert_eq!(p, PathBuf::from("from-defaults/file.log"));
232 }
233
234 #[test]
235 #[serial]
236 fn expand_errors_on_unset_var_with_no_default() {
237 let defaults = HashMap::new();
238 std::env::remove_var("QLI_TEST_AUDIT_MISSING");
239 let err = expand_path("$QLI_TEST_AUDIT_MISSING/x", &defaults).unwrap_err();
240 assert!(matches!(err, AuditError::Expand { .. }), "got {err:?}");
241 }
242
243 #[test]
244 fn expand_handles_literal_path_unchanged() {
245 let defaults = HashMap::new();
246 let p = expand_path("/var/log/qli/audit.log", &defaults).unwrap();
247 assert_eq!(p, PathBuf::from("/var/log/qli/audit.log"));
248 }
249
250 #[test]
251 fn append_writes_one_jsonl_line_per_event() {
252 let tmp = tempfile::tempdir().unwrap();
253 let path = tmp.path().join("nested/audit.log");
254 let event = AuditEvent::Start {
255 timestamp: DateTime::<Utc>::default(),
256 user: "tester".into(),
257 group: "dev".into(),
258 extension: "hello".into(),
259 args: vec!["--flag".into()],
260 env_var_names: vec!["TOKEN".into()],
261 };
262 append(&path, &event).unwrap();
263 append(
264 &path,
265 &AuditEvent::Finish {
266 timestamp: DateTime::<Utc>::default(),
267 group: "dev".into(),
268 extension: "hello".into(),
269 exit_code: 0,
270 duration_ms: 12,
271 },
272 )
273 .unwrap();
274 let body = std::fs::read_to_string(&path).unwrap();
275 let lines: Vec<&str> = body.lines().collect();
276 assert_eq!(lines.len(), 2);
277 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
278 assert_eq!(first["event"], "start");
279 assert_eq!(first["env_var_names"][0], "TOKEN");
280 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
281 assert_eq!(second["event"], "finish");
282 assert_eq!(second["exit_code"], 0);
283 }
284
285 #[test]
286 fn interrupted_event_serializes_with_signal_field() {
287 let event = AuditEvent::Interrupted {
288 timestamp: DateTime::<Utc>::default(),
289 group: "prod".into(),
290 extension: "deploy".into(),
291 signal: "SIGINT".into(),
292 exit_code: 130,
293 duration_ms: 3,
294 };
295 let v: serde_json::Value =
296 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
297 assert_eq!(v["event"], "interrupted");
298 assert_eq!(v["signal"], "SIGINT");
299 assert_eq!(v["exit_code"], 130);
300 }
301}