Skip to main content

qli_ext/
audit.rs

1//! Audit log records for the dispatcher.
2//!
3//! Each dispatched extension produces two log records (start + finish, or
4//! start + interrupted). Records are JSON Lines: one [`AuditEvent`] per
5//! line, atomically appended to the manifest's `audit_log` path.
6//!
7//! The schema is stable at the field level for Phase 1 (pre-1.0): consumers
8//! that parse JSONL can treat any unknown future fields as additive. Renames
9//! or removals will bump the manifest schema version.
10//!
11//! Secrets never appear in this stream. The `Start` event carries
12//! `env_var_names` (the *names* of injected secret env vars) so an audit
13//! reader can confirm which secrets were in scope without seeing values.
14
15use 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/// One JSONL record in an audit log.
25#[derive(Debug, Serialize)]
26#[serde(tag = "event", rename_all = "snake_case")]
27pub enum AuditEvent {
28    /// Recorded right before the extension is spawned, after secrets are
29    /// resolved. `env_var_names` lists the names of secret env vars about to
30    /// be set in the child — never the values.
31    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    /// Recorded after the child exits normally (or with a non-zero status
40    /// the child chose itself).
41    Finish {
42        timestamp: DateTime<Utc>,
43        group: String,
44        extension: String,
45        exit_code: i32,
46        duration_ms: u128,
47    },
48    /// Recorded when the dispatcher observes a SIGINT/SIGTERM and tears the
49    /// child down. `signal` is the conventional name (`SIGINT` / `SIGTERM`).
50    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/// Error raised while resolving or writing an audit log path.
61#[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
85/// Expand a manifest `audit_log` literal into a concrete path.
86///
87/// Supports `~` and `$VAR` (or `${VAR}`) expansion. `defaults` is consulted
88/// only when an env var is unset in the process environment — typically the
89/// dispatcher passes the resolved XDG defaults so a manifest written as
90/// `"$XDG_STATE_HOME/qli/prod-audit.log"` works even if the user hasn't
91/// explicitly exported `XDG_STATE_HOME`.
92pub 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                // No fallback either: error so an unexpanded `$VAR` doesn't
102                // become a literal path component (would leak `$VAR` into
103                // the filesystem layout, never what the manifest meant).
104                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
119/// Append an event as one JSON line to `path`. Creates parent directories as
120/// needed.
121///
122/// Concurrent dispatchers must not interleave lines. Unix `O_APPEND` is
123/// per-`write` atomic only up to `PIPE_BUF` (4096 on Linux, 512 on macOS),
124/// and a record with several secret env names + a long arg list can exceed
125/// that. So on Unix we additionally take an exclusive `flock` for the write.
126/// The kernel releases the lock when the file descriptor closes at the end
127/// of this function.
128pub 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    // `guard` drops here → kernel releases the lock and closes the fd.
164}
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/// Best-effort current-user discovery. Reads `USER` (Unix) or `USERNAME`
175/// (Windows-style) from the process environment, falling back to `"unknown"`.
176/// Audit records are not authentication; this is only a hint.
177#[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    //! Tiny stub so `shellexpand::full_with_context` has a tilde resolver
186    //! without dragging in the `dirs` / `directories` crates. We mirror their
187    //! behaviour: read `$HOME` on Unix, `%USERPROFILE%` on Windows.
188    //! `shellexpand` requires `AsRef<str>`, so we return `String`; non-UTF-8
189    //! home directories yield `None` (rare but well-defined).
190
191    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        // Set the env var so it overrides defaults.
218        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}