use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
Get,
Put,
List,
Delete,
Exists,
Cp,
Diff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheEvent {
Hit,
Miss,
Expire,
Clear,
}
impl CacheEvent {
pub fn label(self) -> &'static str {
match self {
CacheEvent::Hit => "cache.hit",
CacheEvent::Miss => "cache.miss",
CacheEvent::Expire => "cache.expire",
CacheEvent::Clear => "cache.clear",
}
}
}
impl Verb {
pub fn start_label(self) -> &'static str {
match self {
Verb::Get => "get.start",
Verb::Put => "put.start",
Verb::List => "list.start",
Verb::Delete => "delete.start",
Verb::Exists => "exists.start",
Verb::Cp => "cp.start",
Verb::Diff => "diff.start",
}
}
pub fn done_label(self) -> &'static str {
match self {
Verb::Get => "get.done",
Verb::Put => "put.done",
Verb::List => "list.done",
Verb::Delete => "delete.done",
Verb::Exists => "exists.done",
Verb::Cp => "cp.done",
Verb::Diff => "diff.done",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct AuditEvent {
pub ts: SystemTime,
pub event: &'static str,
pub url_scheme: String,
pub dst_scheme: Option<String>,
pub outcome: &'static str,
pub error_kind: Option<&'static str>,
}
impl AuditEvent {
pub fn start(verb: Verb, scheme: impl Into<String>) -> Self {
Self {
ts: SystemTime::now(),
event: verb.start_label(),
url_scheme: scheme.into(),
dst_scheme: None,
outcome: "started",
error_kind: None,
}
}
pub fn done(verb: Verb, scheme: impl Into<String>, outcome: &'static str) -> Self {
Self {
ts: SystemTime::now(),
event: verb.done_label(),
url_scheme: scheme.into(),
dst_scheme: None,
outcome,
error_kind: None,
}
}
pub fn with_dst_scheme(mut self, dst_scheme: impl Into<String>) -> Self {
self.dst_scheme = Some(dst_scheme.into());
self
}
pub fn with_error_kind(mut self, kind: &'static str) -> Self {
self.error_kind = Some(kind);
self
}
pub fn with_event(
event: &'static str,
scheme: impl Into<String>,
outcome: &'static str,
) -> Self {
Self {
ts: SystemTime::now(),
event,
url_scheme: scheme.into(),
dst_scheme: None,
outcome,
error_kind: None,
}
}
pub fn cache(kind: CacheEvent, scheme: impl Into<String>) -> Self {
let outcome: &'static str = match kind {
CacheEvent::Hit => "hit",
CacheEvent::Miss => "miss",
CacheEvent::Expire => "expire",
CacheEvent::Clear => "clear",
};
Self {
ts: SystemTime::now(),
event: kind.label(),
url_scheme: scheme.into(),
dst_scheme: None,
outcome,
error_kind: None,
}
}
pub fn to_json_line(&self) -> String {
let ts = self
.ts
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut obj = serde_json::Map::new();
obj.insert("event".into(), serde_json::Value::String(self.event.into()));
obj.insert("ts".into(), serde_json::Value::Number(ts.into()));
obj.insert(
"src_scheme".into(),
serde_json::Value::String(self.url_scheme.clone()),
);
if let Some(d) = &self.dst_scheme {
obj.insert("dst_scheme".into(), serde_json::Value::String(d.clone()));
}
obj.insert(
"outcome".into(),
serde_json::Value::String(self.outcome.into()),
);
if let Some(k) = self.error_kind {
obj.insert("error_kind".into(), serde_json::Value::String(k.into()));
}
serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()
}
}
pub trait AuditSink: Send + Sync {
fn emit(&self, event: &AuditEvent);
}
pub struct NoopSink;
impl AuditSink for NoopSink {
fn emit(&self, _event: &AuditEvent) {}
}
pub struct StderrSink;
impl AuditSink for StderrSink {
fn emit(&self, event: &AuditEvent) {
let line = event.to_json_line();
let _ = writeln!(io::stderr(), "{line}");
}
}
pub struct FileSink {
path: PathBuf,
file: Mutex<File>,
}
impl FileSink {
pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
let path = path.as_ref().to_path_buf();
let file;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
file = OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(&path)?;
}
#[cfg(not(unix))]
{
file = OpenOptions::new().create(true).append(true).open(&path)?;
}
Ok(Self {
path,
file: Mutex::new(file),
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl AuditSink for FileSink {
fn emit(&self, event: &AuditEvent) {
let line = event.to_json_line();
if let Ok(mut file) = self.file.lock() {
let _ = writeln!(file, "{line}");
let _ = file.flush();
}
}
}
#[cfg(unix)]
#[derive(Debug)]
pub struct SyslogSink {
priority: i32,
}
#[cfg(unix)]
impl SyslogSink {
pub fn open(ident: &str) -> io::Result<Self> {
let cstr = std::ffi::CString::new(ident).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("syslog ident contains NUL: {e}"),
)
})?;
let leaked: &'static std::ffi::CStr = Box::leak(cstr.into_boxed_c_str());
unsafe {
libc::openlog(leaked.as_ptr(), 0, libc::LOG_USER);
}
Ok(Self {
priority: libc::LOG_INFO | libc::LOG_USER,
})
}
}
#[cfg(unix)]
impl AuditSink for SyslogSink {
fn emit(&self, event: &AuditEvent) {
let line = event.to_json_line();
if let Ok(c) = std::ffi::CString::new(line) {
unsafe {
libc::syslog(self.priority, c"%s".as_ptr(), c.as_ptr());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn verb_labels_are_stable_strings() {
for v in [
Verb::Get,
Verb::Put,
Verb::List,
Verb::Delete,
Verb::Exists,
Verb::Cp,
Verb::Diff,
] {
assert!(v.start_label().ends_with(".start"));
assert!(v.done_label().ends_with(".done"));
}
}
#[test]
fn start_event_serializes_minimal_fields() {
let ev = AuditEvent::start(Verb::Get, "vault");
let json = ev.to_json_line();
assert!(json.contains("\"event\":\"get.start\""));
assert!(json.contains("\"src_scheme\":\"vault\""));
assert!(json.contains("\"outcome\":\"started\""));
assert!(!json.contains("dst_scheme"));
assert!(!json.contains("error_kind"));
}
#[test]
fn done_event_with_dst_and_error_kind() {
let ev = AuditEvent::done(Verb::Cp, "vault", "error")
.with_dst_scheme("aws-sm")
.with_error_kind("not_found");
let json = ev.to_json_line();
assert!(json.contains("\"event\":\"cp.done\""));
assert!(json.contains("\"src_scheme\":\"vault\""));
assert!(json.contains("\"dst_scheme\":\"aws-sm\""));
assert!(json.contains("\"outcome\":\"error\""));
assert!(json.contains("\"error_kind\":\"not_found\""));
}
#[test]
fn stderr_sink_is_object_safe() {
let s: Arc<dyn AuditSink> = Arc::new(StderrSink);
s.emit(&AuditEvent::start(Verb::Get, "env"));
}
#[test]
fn noop_sink_emits_nothing_observable() {
let s: Arc<dyn AuditSink> = Arc::new(NoopSink);
s.emit(&AuditEvent::start(Verb::Get, "env"));
}
#[test]
fn file_sink_appends_one_line_per_event() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.log");
let sink = FileSink::open(&path).unwrap();
sink.emit(&AuditEvent::start(Verb::Get, "env"));
sink.emit(&AuditEvent::done(Verb::Get, "env", "ok"));
let body = std::fs::read_to_string(&path).unwrap();
assert_eq!(body.lines().count(), 2);
assert!(body.lines().next().unwrap().contains("get.start"));
assert!(body.lines().nth(1).unwrap().contains("get.done"));
}
#[cfg(unix)]
#[test]
fn file_sink_sets_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.log");
let _sink = FileSink::open(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn syslog_sink_constructs_and_emits_without_panic() {
let sink: Arc<dyn AuditSink> = Arc::new(SyslogSink::open("hasp-test").expect("openlog"));
sink.emit(&AuditEvent::start(Verb::Get, "env"));
sink.emit(&AuditEvent::done(Verb::Get, "env", "ok"));
}
#[cfg(unix)]
#[test]
fn syslog_sink_rejects_ident_with_interior_nul() {
let err = SyslogSink::open("hasp\0bad").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
}