Skip to main content

pitboard_core/
audit.rs

1//! One line per change Pitboard makes: when, which front end asked, what was asked, of
2//! what, and how it ended. Labels, codes and times only, no email addresses or account
3//! identifiers, so it is safe to paste into a bug report.
4
5use crate::context::Context;
6use crate::service::Permit;
7use crate::{home, time};
8use std::io::Write;
9use std::path::PathBuf;
10
11/// Rotated once past this size, keeping one previous file.
12const LIMIT_BYTES: u64 = 256 * 1024;
13
14fn path(ctx: &Context) -> PathBuf {
15    home::dir(ctx).join("audit.log")
16}
17
18/// A failure to audit never fails the operation it describes. A change refused at the gate
19/// every change passes is not recorded: recording it would be a change.
20pub 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    // Never makes the home itself. Every change settles first, which makes it; and after an
42    // uninstall there is no home to write into and nothing left to describe.
43    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/// One recorded change, as `read` hands it back.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Entry {
56    /// Local time, as it was written.
57    pub at: String,
58    /// Which front end asked. Lines written before this was recorded say `unknown`.
59    pub caller: String,
60    pub verb: String,
61    pub subject: String,
62    /// `ok`, or the stable code of whatever stopped it.
63    pub outcome: String,
64}
65
66/// The newest `limit` changes, oldest first. The rotated file is read too, so asking for
67/// more than the current file holds still answers.
68pub 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                // Written before the caller had a column of its own.
81                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    /// Lines written before the caller had a column of its own still read, because a log is
105    /// only useful if the version that wrote it does not matter.
106    #[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    /// The seam the rest of this crate's time judgements hang on: what gets written is the
127    /// context's idea of now, not the machine's.
128    #[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}