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::{home, time};
7use std::fs::OpenOptions;
8use std::io::Write;
9use std::os::unix::fs::OpenOptionsExt;
10use std::path::PathBuf;
11
12/// Rotated once past this size, keeping one previous file.
13const LIMIT_BYTES: u64 = 256 * 1024;
14
15fn path(ctx: &Context) -> PathBuf {
16    home::dir(ctx).join("audit.log")
17}
18
19/// A failure to audit never fails the operation it describes.
20pub 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    // Never makes the home itself. Every change settles first, which makes it; and after an
38    // uninstall there is no home to write into and nothing left to describe.
39    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/// One recorded change, as `read` hands it back.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Entry {
57    /// Local time, as it was written.
58    pub at: String,
59    /// Which front end asked. Lines written before this was recorded say `unknown`.
60    pub caller: String,
61    pub verb: String,
62    pub subject: String,
63    /// `ok`, or the stable code of whatever stopped it.
64    pub outcome: String,
65}
66
67/// The newest `limit` changes, oldest first. The rotated file is read too, so asking for
68/// more than the current file holds still answers.
69pub 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                // Written before the caller had a column of its own.
82                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    /// Lines written before the caller had a column of its own still read, because a log is
106    /// only useful if the version that wrote it does not matter.
107    #[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}