1use crate::context::Context;
6use crate::service::Permit;
7use crate::{home, time};
8use std::io::Write;
9use std::path::PathBuf;
10
11const LIMIT_BYTES: u64 = 256 * 1024;
13
14fn path(ctx: &Context) -> PathBuf {
15 home::dir(ctx).join("audit.log")
16}
17
18pub fn record(ctx: &Context, permit: Permit, verb: &str, subject: &str, outcome: &str) {
21 let _ = append(
22 ctx,
23 permit,
24 &line(ctx.now(), &ctx.caller, verb, subject, outcome),
25 );
26}
27
28fn line(at: i64, caller: &str, verb: &str, subject: &str, outcome: &str) -> String {
29 let clean = |s: &str| s.replace(['\n', '\r', '\t'], " ");
30 format!(
31 "{}\t{}\t{}\t{}\t{}\n",
32 time::local(at, "%Y-%m-%dT%H:%M:%S%:z"),
33 clean(caller),
34 clean(verb),
35 clean(subject),
36 clean(outcome)
37 )
38}
39
40fn append(ctx: &Context, permit: Permit, line: &str) -> std::io::Result<()> {
41 if !std::fs::metadata(home::dir(ctx)).is_ok_and(|m| m.is_dir()) {
44 return Ok(());
45 }
46 let path = path(ctx);
47 if std::fs::metadata(&path).is_ok_and(|m| m.len() > LIMIT_BYTES) {
48 crate::host::fs::rename(permit, &path, &path.with_extension("log.1"))?;
49 }
50 crate::host::fs::open_private_append(permit, &path)?.write_all(line.as_bytes())
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Entry {
56 pub at: String,
58 pub caller: String,
60 pub verb: String,
61 pub subject: String,
62 pub outcome: String,
64}
65
66pub fn read(ctx: &Context, limit: usize) -> Vec<Entry> {
69 let read = |p: PathBuf| std::fs::read_to_string(p).unwrap_or_default();
70 let mut text = read(path(ctx).with_extension("log.1"));
71 text.push_str(&read(path(ctx)));
72 let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
73 lines[lines.len().saturating_sub(limit)..]
74 .iter()
75 .map(|line| {
76 let mut fields = line.split('\t');
77 let mut next = || fields.next().unwrap_or_default().to_string();
78 let (at, second, third, fourth) = (next(), next(), next(), next());
79 match fields.next() {
80 None => Entry {
82 at,
83 caller: "unknown".into(),
84 verb: second,
85 subject: third,
86 outcome: fourth,
87 },
88 Some(outcome) => Entry {
89 at,
90 caller: second,
91 verb: third,
92 subject: fourth,
93 outcome: outcome.to_string(),
94 },
95 }
96 })
97 .collect()
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
107 fn a_line_from_before_the_caller_column_still_reads() {
108 let four = "2026-09-22T01:00:00+07:00\tuse\twork\tok";
109 let five = "2026-09-22T01:01:00+07:00\tapp\tuse\tpersonal\tok";
110 let home = tempdir("audit-shapes");
111 let ctx = Context::new(home.clone()).with_pitboard_home(home.clone());
112 std::fs::create_dir_all(&home).unwrap();
113 std::fs::write(home.join("audit.log"), format!("{four}\n{five}\n")).unwrap();
114
115 let entries = read(&ctx, 10);
116 assert_eq!(entries.len(), 2);
117 assert_eq!(entries[0].caller, "unknown");
118 assert_eq!(entries[0].verb, "use");
119 assert_eq!(entries[0].subject, "work");
120 assert_eq!(entries[0].outcome, "ok");
121 assert_eq!(entries[1].caller, "app");
122 assert_eq!(entries[1].verb, "use");
123 assert_eq!(entries[1].outcome, "ok");
124 }
125
126 #[test]
129 fn a_change_is_stamped_with_the_contexts_clock() {
130 use crate::time::FixedClock;
131 use std::sync::Arc;
132
133 let home = tempdir("audit-clock");
134 std::fs::create_dir_all(&home).unwrap();
135 let clock = Arc::new(FixedClock::at(1_760_000_000));
136 let ctx = Context::new(home.clone())
137 .with_pitboard_home(home.clone())
138 .with_caller("cli".into())
139 .with_clock(clock.clone());
140
141 record(&ctx, Permit::for_a_test(), "use", "work", "ok");
142 clock.advance(3600);
143 record(&ctx, Permit::for_a_test(), "use", "personal", "ok");
144
145 let entries = read(&ctx, 10);
146 assert_eq!(entries.len(), 2);
147 let stamp = |epoch| time::local(epoch, "%Y-%m-%dT%H:%M:%S%:z");
148 assert_eq!(entries[0].at, stamp(1_760_000_000));
149 assert_eq!(
150 entries[1].at,
151 stamp(1_760_003_600),
152 "an hour later, because we said so"
153 );
154 }
155
156 fn tempdir(name: &str) -> PathBuf {
157 let dir = std::env::temp_dir().join(format!("pitboard-{name}-{}", std::process::id()));
158 let _ = std::fs::remove_dir_all(&dir);
159 dir
160 }
161
162 #[test]
163 fn a_line_is_one_line_whatever_the_input() {
164 let l = line(1_789_935_600, "cli", "use", "work\ninjected", "ok");
165 assert_eq!(
166 l.matches('\n').count(),
167 1,
168 "a label must not be able to forge entries"
169 );
170 assert_eq!(l.split('\t').count(), 5);
171 }
172}