ptuf 0.1.0

PreToolUseFilter: a generic guardrail layer for coding agents
Documentation
//! JSONL writer used by [`super::JsonlSink`].
//!
//! Serialises one [`AuditRecord`] to a single line and appends it to
//! a file opened with `O_APPEND`. Cross-process atomicity is
//! guaranteed by the caller: `JsonlSink::record` takes an OS-level
//! advisory lock (`flock(2)` on Unix, `LockFileEx` on Windows) around
//! the write so concurrent ptuf processes cannot interleave records
//! even when a record exceeds a page or `write_all` has to loop on
//! partial writes.

use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::Path;

use super::record::AuditRecord;

/// Errors raised while opening or writing the JSONL file. Both
/// variants surface the offending path so the caller can decide
/// whether to fail the request or only emit a stderr warning.
#[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,
        }
    }
}

/// Open `path` for append, creating it (and any missing parent
/// directories) if needed.
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)
}

/// Encode one record as a JSON line and append it to `dst`. The
/// newline is appended to the JSON before the single `write_all` call
/// so the line and its terminator hit the underlying file together.
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");
    }

    // Skipped under root because root bypasses the POSIX permission
    // check, which would yield a false negative.
    #[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); // r--r--r--, no write/exec.
        std::fs::set_permissions(&locked, perms.clone()).expect("chmod 0444");

        let path = locked.join("audit.jsonl");
        let result = open_append(&path);

        // Restore permissions so TempDir can clean up.
        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:?}",
        );
    }

    // `id -u` avoids touching `unsafe` (the crate forbids it); on
    // systems without `id` we conservatively return false.
    #[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)
    }
}