use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
use super::record::AuditRecord;
#[derive(Debug)]
pub enum WriteError {
Io(io::Error),
Serialize(String),
}
impl std::fmt::Display for WriteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "audit io error: {e}"),
Self::Serialize(m) => write!(f, "audit serialize error: {m}"),
}
}
}
impl std::error::Error for WriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
Self::Serialize(_) => None,
}
}
}
pub fn open_append(path: &Path) -> io::Result<File> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
OpenOptions::new().create(true).append(true).open(path)
}
pub fn append_record<W: Write>(dst: &mut W, record: &AuditRecord) -> Result<(), WriteError> {
let mut line =
serde_json::to_string(record).map_err(|e| WriteError::Serialize(e.to_string()))?;
line.push('\n');
dst.write_all(line.as_bytes()).map_err(WriteError::Io)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Decision;
use crate::config::Mode;
use crate::decision::Severity;
use crate::hook_input::HookInput;
use serde_json::json;
use std::time::UNIX_EPOCH;
fn rec() -> AuditRecord {
AuditRecord::build(
UNIX_EPOCH,
&Decision::Deny {
rule_id: "r".into(),
reason: "x".into(),
},
Mode::Enforce,
false,
&HookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": "rm -rf /"}),
},
None,
Some(Severity::Critical),
"rm -rf /".into(),
None,
"claude-code",
Vec::new(),
)
}
#[test]
fn append_record_writes_one_line_terminated_with_newline() {
let mut buf = Vec::new();
append_record(&mut buf, &rec()).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.ends_with('\n'));
assert_eq!(s.matches('\n').count(), 1);
assert!(s.contains("\"decision\":\"deny\""));
}
#[test]
fn open_append_creates_missing_parent_dirs() {
let dir = tempfile::TempDir::new().expect("tempdir");
let nested = dir.path().join("nested/deep/audit.jsonl");
let mut f = open_append(&nested).expect("open");
append_record(&mut f, &rec()).expect("write");
let body = std::fs::read_to_string(&nested).expect("read");
assert!(body.contains("\"decision\":\"deny\""));
}
#[test]
fn open_append_works_when_parent_already_exists() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("audit.jsonl");
let mut f = open_append(&path).expect("open");
append_record(&mut f, &rec()).expect("write");
}
#[test]
fn write_error_display_covers_both_variants() {
let io_err = WriteError::Io(io::Error::other("nope"));
let ser_err = WriteError::Serialize("bad".into());
assert!(format!("{io_err}").contains("audit io error"));
assert!(format!("{ser_err}").contains("audit serialize error"));
}
#[test]
fn write_error_source_matches_variant() {
let io_err = WriteError::Io(io::Error::other("nope"));
let ser_err = WriteError::Serialize("bad".into());
let dyn_err: &dyn std::error::Error = &io_err;
assert!(dyn_err.source().is_some());
let dyn_err: &dyn std::error::Error = &ser_err;
assert!(dyn_err.source().is_none());
}
#[test]
fn open_append_returns_io_error_when_parent_is_a_regular_file() {
let dir = tempfile::TempDir::new().expect("tempdir");
let blocker = dir.path().join("audit-not-a-dir");
std::fs::write(&blocker, b"x").expect("write blocker");
let path = blocker.join("audit.jsonl");
open_append(&path).expect_err("expected io error");
}
#[test]
fn open_append_returns_io_error_for_empty_path() {
open_append(Path::new("")).expect_err("expected io error");
}
#[cfg(unix)]
#[test]
fn open_append_returns_permission_denied_for_unwritable_parent() {
use std::os::unix::fs::PermissionsExt;
if euid_is_root() {
return;
}
let dir = tempfile::TempDir::new().expect("tempdir");
let locked = dir.path().join("locked");
std::fs::create_dir(&locked).expect("mkdir");
let mut perms = std::fs::metadata(&locked).expect("meta").permissions();
perms.set_mode(0o444); std::fs::set_permissions(&locked, perms.clone()).expect("chmod 0444");
let path = locked.join("audit.jsonl");
let result = open_append(&path);
let mut restore = perms;
restore.set_mode(0o755);
let _ = std::fs::set_permissions(&locked, restore);
let err = result.expect_err("expected permission denied");
assert_eq!(
err.kind(),
io::ErrorKind::PermissionDenied,
"unexpected error: {err:?}",
);
}
#[cfg(unix)]
fn euid_is_root() -> bool {
std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse::<u32>().ok())
== Some(0)
}
}