use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum AuditEvent {
Start {
timestamp: DateTime<Utc>,
user: String,
group: String,
extension: String,
args: Vec<String>,
env_var_names: Vec<String>,
},
Finish {
timestamp: DateTime<Utc>,
group: String,
extension: String,
exit_code: i32,
duration_ms: u128,
},
Interrupted {
timestamp: DateTime<Utc>,
group: String,
extension: String,
signal: String,
exit_code: i32,
duration_ms: u128,
},
}
#[derive(Debug, Error)]
pub enum AuditError {
#[error("could not expand audit_log path {literal:?}: {source}")]
Expand {
literal: String,
#[source]
source: shellexpand::LookupError<std::env::VarError>,
},
#[error("could not create audit log directory {path:?}: {source}")]
CreateDir {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not write audit log {path:?}: {source}")]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not serialize audit event: {0}")]
Serialize(#[from] serde_json::Error),
}
pub fn expand_path<S: ::std::hash::BuildHasher>(
literal: &str,
defaults: &HashMap<String, String, S>,
) -> Result<PathBuf, AuditError> {
let lookup = |name: &str| -> Result<Option<String>, std::env::VarError> {
match std::env::var(name) {
Ok(v) if !v.is_empty() => Ok(Some(v)),
Ok(_) | Err(std::env::VarError::NotPresent) => match defaults.get(name) {
Some(d) => Ok(Some(d.clone())),
None => Err(std::env::VarError::NotPresent),
},
Err(e) => Err(e),
}
};
let expanded =
shellexpand::full_with_context(literal, dirs::home_dir, lookup).map_err(|e| {
AuditError::Expand {
literal: literal.to_owned(),
source: e,
}
})?;
Ok(PathBuf::from(expanded.into_owned()))
}
pub fn append(path: &Path, event: &AuditEvent) -> Result<(), AuditError> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|source| AuditError::CreateDir {
path: parent.to_path_buf(),
source,
})?;
}
}
let mut line = serde_json::to_vec(event)?;
line.push(b'\n');
let file = OpenOptions::new()
.append(true)
.create(true)
.open(path)
.map_err(|source| AuditError::Write {
path: path.to_path_buf(),
source,
})?;
write_locked(path, file, &line)
}
#[cfg(unix)]
fn write_locked(path: &Path, file: std::fs::File, line: &[u8]) -> Result<(), AuditError> {
use nix::fcntl::{Flock, FlockArg};
let mut guard =
Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, errno)| AuditError::Write {
path: path.to_path_buf(),
source: std::io::Error::from_raw_os_error(errno as i32),
})?;
guard.write_all(line).map_err(|source| AuditError::Write {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
#[cfg(not(unix))]
fn write_locked(path: &Path, mut file: std::fs::File, line: &[u8]) -> Result<(), AuditError> {
file.write_all(line).map_err(|source| AuditError::Write {
path: path.to_path_buf(),
source,
})
}
#[must_use]
pub fn current_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".into())
}
mod dirs {
pub fn home_dir() -> Option<String> {
#[cfg(unix)]
{
std::env::var("HOME").ok().filter(|s| !s.is_empty())
}
#[cfg(windows)]
{
std::env::var("USERPROFILE").ok().filter(|s| !s.is_empty())
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn expand_uses_process_env_first() {
let mut defaults = HashMap::new();
defaults.insert("QLI_TEST_AUDIT_VAR".into(), "from-defaults".into());
std::env::set_var("QLI_TEST_AUDIT_VAR", "from-env");
let p = expand_path("$QLI_TEST_AUDIT_VAR/file.log", &defaults).unwrap();
assert_eq!(p, PathBuf::from("from-env/file.log"));
std::env::remove_var("QLI_TEST_AUDIT_VAR");
}
#[test]
#[serial]
fn expand_falls_back_to_defaults_when_env_unset() {
let mut defaults = HashMap::new();
defaults.insert("QLI_TEST_AUDIT_UNSET".into(), "from-defaults".into());
std::env::remove_var("QLI_TEST_AUDIT_UNSET");
let p = expand_path("$QLI_TEST_AUDIT_UNSET/file.log", &defaults).unwrap();
assert_eq!(p, PathBuf::from("from-defaults/file.log"));
}
#[test]
#[serial]
fn expand_errors_on_unset_var_with_no_default() {
let defaults = HashMap::new();
std::env::remove_var("QLI_TEST_AUDIT_MISSING");
let err = expand_path("$QLI_TEST_AUDIT_MISSING/x", &defaults).unwrap_err();
assert!(matches!(err, AuditError::Expand { .. }), "got {err:?}");
}
#[test]
fn expand_handles_literal_path_unchanged() {
let defaults = HashMap::new();
let p = expand_path("/var/log/qli/audit.log", &defaults).unwrap();
assert_eq!(p, PathBuf::from("/var/log/qli/audit.log"));
}
#[test]
fn append_writes_one_jsonl_line_per_event() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested/audit.log");
let event = AuditEvent::Start {
timestamp: DateTime::<Utc>::default(),
user: "tester".into(),
group: "dev".into(),
extension: "hello".into(),
args: vec!["--flag".into()],
env_var_names: vec!["TOKEN".into()],
};
append(&path, &event).unwrap();
append(
&path,
&AuditEvent::Finish {
timestamp: DateTime::<Utc>::default(),
group: "dev".into(),
extension: "hello".into(),
exit_code: 0,
duration_ms: 12,
},
)
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first["event"], "start");
assert_eq!(first["env_var_names"][0], "TOKEN");
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(second["event"], "finish");
assert_eq!(second["exit_code"], 0);
}
#[test]
fn interrupted_event_serializes_with_signal_field() {
let event = AuditEvent::Interrupted {
timestamp: DateTime::<Utc>::default(),
group: "prod".into(),
extension: "deploy".into(),
signal: "SIGINT".into(),
exit_code: 130,
duration_ms: 3,
};
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
assert_eq!(v["event"], "interrupted");
assert_eq!(v["signal"], "SIGINT");
assert_eq!(v["exit_code"], 130);
}
}