Skip to main content

pixelactions_core/
audit.rs

1//! The record a run leaves behind.
2//!
3//! Observable polling, the watchdog and the corner kill switch all make a
4//! run safe to *watch*. This makes one safe to **not** watch: a flow that
5//! ran at 3am, or one a model drove, otherwise answers "what did it
6//! actually do" with nothing at all.
7//!
8//! One line per event, NDJSON — the format the line protocol already
9//! speaks, appendable and greppable. Everything here is pure: the caller
10//! supplies the clock and owns the file, because neither belongs in a
11//! crate with no platform dependencies.
12//!
13//! # What a record can never contain
14//!
15//! **Typed text.** A `type` step carries whatever was typed, which is how
16//! passwords end up in log files. Nothing here strips it, because nothing
17//! here ever sees it: [`Step::summary`](crate::flow::Step::summary)
18//! renders `Type` as `"type N chars"`, and these records are built from
19//! [`StepReport`], which carries that summary and never the step. The
20//! property holds by construction rather than by remembering, and there
21//! is a test pinning it.
22
23use serde::{Deserialize, Serialize};
24
25use crate::convert::ResolvedPoint;
26use crate::report::{StepOutcome, StepReport};
27
28/// One line of the log.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "event", rename_all = "snake_case")]
31pub enum Event {
32    /// Opens a run. Everything after it, until the next `run`, belongs to
33    /// this one.
34    Run {
35        /// RFC 3339, supplied by the caller — this crate holds no clock.
36        utc: String,
37        session: String,
38        /// False for a resolved-but-not-performed run, so a reader can
39        /// never mistake a plan for something that happened.
40        executed: bool,
41    },
42    /// One step, after it finished.
43    Step {
44        utc: String,
45        index: usize,
46        /// The redacted label — never the step itself. See the module
47        /// docs.
48        summary: String,
49        outcome: StepOutcome,
50        /// Where the input actually went, **after** space conversion.
51        /// The saved coordinate is in the session; this is the one that
52        /// was sent, and the only one worth having when a run went wrong.
53        points: Vec<ResolvedPoint>,
54        elapsed_ms: u64,
55        #[serde(skip_serializing_if = "Option::is_none")]
56        detail: Option<String>,
57    },
58}
59
60impl Event {
61    /// Open a run.
62    #[must_use]
63    pub fn run(utc: String, session: String, executed: bool) -> Self {
64        Self::Run {
65            utc,
66            session,
67            executed,
68        }
69    }
70
71    /// Record a finished step.
72    ///
73    /// Takes the report rather than the step, which is what makes typed
74    /// text unreachable from here.
75    #[must_use]
76    pub fn step(utc: String, report: &StepReport) -> Self {
77        Self::Step {
78            utc,
79            index: report.index,
80            summary: report.summary.clone(),
81            outcome: report.outcome,
82            points: report.points.clone(),
83            elapsed_ms: report.elapsed_ms,
84            detail: report.detail.clone(),
85        }
86    }
87
88    /// The line to append, newline included.
89    ///
90    /// Serialization of these types cannot fail — every field is a
91    /// string, a number, a bool or a `Vec` of those — so a failure here
92    /// would be a bug in this module rather than bad input, and the
93    /// record says so instead of vanishing.
94    #[must_use]
95    pub fn line(&self) -> String {
96        match serde_json::to_string(self) {
97            Ok(json) => format!("{json}\n"),
98            Err(error) => format!("{{\"event\":\"broken\",\"detail\":\"{error}\"}}\n"),
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::convert::Space;
107    use crate::flow::Step;
108
109    fn point() -> ResolvedPoint {
110        ResolvedPoint {
111            x: 76.0,
112            y: 15.0,
113            space: Space::Logical,
114            monitor: 0,
115            scale: 2.0,
116        }
117    }
118
119    fn report(summary: &str) -> StepReport {
120        StepReport {
121            index: 0,
122            summary: summary.to_string(),
123            outcome: StepOutcome::Executed,
124            points: vec![point()],
125            detail: None,
126            elapsed_ms: 262,
127        }
128    }
129
130    #[test]
131    fn a_run_line_names_the_session_and_says_it_executed() {
132        let line = Event::run("2026-08-03T21:00:00Z".into(), "/tmp/s".into(), true).line();
133        assert!(line.contains(r#""event":"run""#), "{line}");
134        assert!(line.contains("/tmp/s"), "{line}");
135        assert!(line.contains(r#""executed":true"#), "{line}");
136        assert!(line.ends_with('\n'), "one line, newline included");
137    }
138
139    #[test]
140    fn a_step_line_carries_the_point_that_was_actually_sent() {
141        let line = Event::step("2026-08-03T21:00:00Z".into(), &report("click submit")).line();
142        // The coordinate after conversion is the forensic datum: the saved
143        // one is already in the session, this is the one that was posted.
144        assert!(line.contains("76"), "{line}");
145        assert!(line.contains("15"), "{line}");
146        assert!(line.contains(r#""outcome":"executed""#), "{line}");
147        assert!(line.contains("262"), "{line}");
148    }
149
150    /// The property the module doc claims, pinned.
151    ///
152    /// A `type` step's text is never in a `StepReport` to begin with —
153    /// `Step::summary` renders it as a character count — so the log cannot
154    /// leak it even though nothing here strips anything.
155    #[test]
156    fn typed_text_cannot_reach_the_log() {
157        let secret = "hunter2-correct-horse-battery-staple";
158        let step = Step::Type {
159            text: secret.to_string(),
160        };
161        let summary = step.summary();
162        assert!(!summary.contains(secret), "summary leaked it: {summary}");
163        assert_eq!(summary, "type 36 chars");
164
165        let line = Event::step("2026-08-03T21:00:00Z".into(), &report(&summary)).line();
166        assert!(!line.contains(secret), "the log leaked it: {line}");
167        assert!(
168            !line.contains("hunter2"),
169            "the log leaked part of it: {line}"
170        );
171    }
172
173    #[test]
174    fn a_failure_carries_its_detail_and_a_success_omits_it() {
175        let mut failed = report("changed panel");
176        failed.outcome = StepOutcome::Failed;
177        failed.detail = Some("did not change".into());
178        assert!(
179            Event::step("t".into(), &failed)
180                .line()
181                .contains("did not change")
182        );
183        assert!(
184            !Event::step("t".into(), &report("click x"))
185                .line()
186                .contains("detail")
187        );
188    }
189
190    #[test]
191    fn every_line_is_one_line() {
192        let line = Event::step("t".into(), &report("click submit")).line();
193        assert_eq!(
194            line.matches('\n').count(),
195            1,
196            "NDJSON is one record per line"
197        );
198    }
199}