1use crate::context::Context;
6use crate::{home, time};
7use std::fs::OpenOptions;
8use std::io::Write;
9use std::os::unix::fs::OpenOptionsExt;
10use std::path::PathBuf;
11
12const LIMIT_BYTES: u64 = 256 * 1024;
14
15fn path(ctx: &Context) -> PathBuf {
16 home::dir(ctx).join("audit.log")
17}
18
19pub fn record(ctx: &Context, verb: &str, subject: &str, outcome: &str) {
21 let _ = append(ctx, &line(time::now(), &ctx.caller, verb, subject, outcome));
22}
23
24fn line(at: i64, caller: &str, verb: &str, subject: &str, outcome: &str) -> String {
25 let clean = |s: &str| s.replace(['\n', '\r', '\t'], " ");
26 format!(
27 "{}\t{}\t{}\t{}\t{}\n",
28 time::local(at, "%Y-%m-%dT%H:%M:%S%:z"),
29 clean(caller),
30 clean(verb),
31 clean(subject),
32 clean(outcome)
33 )
34}
35
36fn append(ctx: &Context, line: &str) -> std::io::Result<()> {
37 if !std::fs::metadata(home::dir(ctx)).is_ok_and(|m| m.is_dir()) {
40 return Ok(());
41 }
42 let path = path(ctx);
43 if std::fs::metadata(&path).is_ok_and(|m| m.len() > LIMIT_BYTES) {
44 std::fs::rename(&path, path.with_extension("log.1"))?;
45 }
46 OpenOptions::new()
47 .create(true)
48 .append(true)
49 .mode(0o600)
50 .open(&path)?
51 .write_all(line.as_bytes())
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Entry {
57 pub at: String,
59 pub caller: String,
61 pub verb: String,
62 pub subject: String,
63 pub outcome: String,
65}
66
67pub fn read(ctx: &Context, limit: usize) -> Vec<Entry> {
70 let read = |p: PathBuf| std::fs::read_to_string(p).unwrap_or_default();
71 let mut text = read(path(ctx).with_extension("log.1"));
72 text.push_str(&read(path(ctx)));
73 let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
74 lines[lines.len().saturating_sub(limit)..]
75 .iter()
76 .map(|line| {
77 let mut fields = line.split('\t');
78 let mut next = || fields.next().unwrap_or_default().to_string();
79 let (at, second, third, fourth) = (next(), next(), next(), next());
80 match fields.next() {
81 None => Entry {
83 at,
84 caller: "unknown".into(),
85 verb: second,
86 subject: third,
87 outcome: fourth,
88 },
89 Some(outcome) => Entry {
90 at,
91 caller: second,
92 verb: third,
93 subject: fourth,
94 outcome: outcome.to_string(),
95 },
96 }
97 })
98 .collect()
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 #[test]
108 fn a_line_from_before_the_caller_column_still_reads() {
109 let four = "2026-09-22T01:00:00+07:00\tuse\twork\tok";
110 let five = "2026-09-22T01:01:00+07:00\tapp\tuse\tpersonal\tok";
111 let home = tempdir("audit-shapes");
112 let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
113 std::fs::create_dir_all(&home).unwrap();
114 std::fs::write(home.join("audit.log"), format!("{four}\n{five}\n")).unwrap();
115
116 let entries = read(&ctx, 10);
117 assert_eq!(entries.len(), 2);
118 assert_eq!(entries[0].caller, "unknown");
119 assert_eq!(entries[0].verb, "use");
120 assert_eq!(entries[0].subject, "work");
121 assert_eq!(entries[0].outcome, "ok");
122 assert_eq!(entries[1].caller, "app");
123 assert_eq!(entries[1].verb, "use");
124 assert_eq!(entries[1].outcome, "ok");
125 }
126
127 fn tempdir(name: &str) -> PathBuf {
128 let dir = std::env::temp_dir().join(format!("pitboard-{name}-{}", std::process::id()));
129 let _ = std::fs::remove_dir_all(&dir);
130 dir
131 }
132
133 #[test]
134 fn a_line_is_one_line_whatever_the_input() {
135 let l = line(1_789_935_600, "cli", "use", "work\ninjected", "ok");
136 assert_eq!(
137 l.matches('\n').count(),
138 1,
139 "a label must not be able to forge entries"
140 );
141 assert_eq!(l.split('\t').count(), 5);
142 }
143}