Skip to main content

bootroom_core/
lib.rs

1//! bootroom-core: pure types and protocol definitions.
2//!
3//! Phase 2 adds `WsMessage` (the `/ws` protocol enum) and `GuestState`
4//! (status pill states). Phase 4's headless `bootroom run` driver reuses
5//! the same `WsMessage` enum unchanged.
6
7#![cfg_attr(not(test), deny(unsafe_code))]
8
9use serde::{Deserialize, Serialize};
10
11pub mod config;
12pub mod escape;
13pub use config::{
14    parse_str, Action, Assertion, AssertionKind, CliAction, Config, LoadError, LoadedConfig,
15    ResolvedAction, Scenario,
16};
17pub use escape::{decode_bytes_escape, EscapeError};
18
19/// Wire-level message exchanged over the `/ws` endpoint.
20///
21/// Externally tagged via `#[serde(tag = "type")]`, producing JSON of the form
22/// `{"type": "SerialIn", "data": "..."}`. Byte payloads (`SerialIn`,
23/// `SerialOut`) are base64-encoded so the protocol stays JSON-only on the
24/// wire — see `02-CONTEXT.md` decision "/ws message protocol — tagged JSON
25/// only".
26///
27/// Note: `#[serde(deny_unknown_fields)]` is intentionally NOT applied —
28/// Phase 4 may add variants additively and older clients should ignore
29/// unknown fields gracefully (02-RESEARCH.md Open Question 3).
30///
31/// Phase 4 (additive): `ScenarioStart`, `ScenarioAbort`, and
32/// `ScenarioResult` extend this enum without renaming or reordering any
33/// existing variant. The `bootroom run` driver awaits a single
34/// `ScenarioResult` frame per scenario and translates `verdict` to a
35/// process exit code (RUN-01, RUN-08).
36#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
37#[serde(tag = "type")]
38pub enum WsMessage {
39    /// Host -> guest. Bytes injected into guest stdin. `data` is base64.
40    SerialIn { data: String },
41    /// Guest -> host. Bytes the guest emitted on serial. `data` is base64.
42    /// Browser emits these for the server to log; server may forward in
43    /// Phase 4 headless mode.
44    SerialOut { data: String },
45    /// Server -> client. Authoritative guest status pill state. When the
46    /// `/ws` connection is live this overrides the browser's local view.
47    State { state: GuestState },
48    /// Client -> server. Asks the server (and observers) to log a Launch
49    /// action; the browser then page-reloads to re-instantiate qemu-wasm.
50    Launch,
51    /// Client -> server. Asks the server (and observers) to log a Reset
52    /// action; in Phase 2 this is identical to `Launch` from the
53    /// browser's perspective.
54    Reset,
55    /// Server -> client on connect. `version` is the server's
56    /// `CARGO_PKG_VERSION`. Mismatched clients log a warning but proceed.
57    Hello { version: String },
58    /// Server -> client. Watcher detected a kernel rebuild. `ok=true` means
59    /// size-stability and ELF magic both passed; `ok=false` carries `reason`
60    /// (e.g., `"not ELF"`). The browser shows a non-intrusive banner; Launch
61    /// is user-initiated. WCH-05.
62    KernelChanged {
63        ok: bool,
64        mtime: i64,
65        size: u64,
66        sha256_prefix: String,
67        reason: Option<String>,
68    },
69    /// Server -> client. `bootroom.toml` was edited and re-parsed
70    /// successfully. `config` is the same JSON projection `/api/config`
71    /// returns. CFG-10.
72    ConfigUpdate { config: serde_json::Value },
73    /// Server -> client. `bootroom.toml` was edited but re-parse failed.
74    /// The last-known-good config remains active. `line`/`col` are 1-based
75    /// when the error has a TOML span. CFG-10.
76    ConfigInvalid {
77        error: String,
78        line: Option<u32>,
79        col: Option<u32>,
80    },
81    /// Browser -> server (reserved). Sent by the scenario engine at scenario
82    /// kickoff. Phase 4 does not require the server to act on this frame —
83    /// URL-query detection (`?scenario=<name>`) is the canonical entry point
84    /// — but the variant is reserved so future server-driven re-runs
85    /// (`--watch`, v2) get the wire shape for free. Per 04-RESEARCH Open
86    /// Question 1: ship now, leave unused.
87    ScenarioStart { scenario: String },
88    /// Server -> client. Defensive cancellation. Phase 4 does not emit this
89    /// frame on any code path; reserved so a future per-server outer-timeout
90    /// path can request the browser to bail. Per 04-RESEARCH `WsMessage`
91    /// block "Server -> client. Defensive cancellation".
92    ScenarioAbort { reason: String },
93    /// Browser -> server. Final scenario verdict + full transcript. The
94    /// `bootroom run` driver awaits this frame on a `oneshot::Receiver`
95    /// (parked on `AppState`) and translates `verdict` to a process exit
96    /// code. Schema (RUN-01, RUN-08):
97    ///
98    /// - `verdict`: "pass" | "fail" | "timeout" | "error"
99    /// - `scenario`: the scenario name as run
100    /// - `started_at` / `ended_at`: ISO 8601 UTC timestamps with Z suffix
101    ///   (04-RESEARCH Open Question 3: UTC for machine-parseable logs)
102    /// - `actions`: opaque JSON — per-action verdicts + per-assertion verdicts
103    /// - `transcript`: opaque JSON — ordered event list (same shape as the
104    ///   `--log-file` JSONL stream defined in 04-06)
105    /// - `error`: optional structured message for `verdict` ∈ {"timeout", "error"}
106    ///
107    /// The two opaque-JSON fields use `serde_json::Value` (not concrete
108    /// nested structs) so the wire shape is forward-compatible: the
109    /// browser engine builds the JSON; the server only forwards bytes to
110    /// `--log-file` and translates `verdict`. Concrete nested structs
111    /// would force schema-version coupling on every event-shape change.
112    ScenarioResult {
113        verdict: String,
114        scenario: String,
115        started_at: String,
116        ended_at: String,
117        actions: serde_json::Value,
118        transcript: serde_json::Value,
119        error: Option<String>,
120    },
121}
122
123/// Status pill state machine. Default serde representation: bare string
124/// variant (`"Idle" | "Loading" | "Running" | "Halted"`).
125#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
126pub enum GuestState {
127    /// Initial render — before xterm + qemu init.
128    Idle,
129    /// xterm mounted, qemu-wasm Module not yet `onRuntimeInitialized`.
130    Loading,
131    /// `onRuntimeInitialized` fired AND first `SerialOut` byte seen —
132    /// the guest is actually executing.
133    Running,
134    /// `Module.onExit` / `onAbort`, OR server pushed `State { Halted }`.
135    Halted,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn serial_in_roundtrip() {
144        let m = WsMessage::SerialIn {
145            data: "aGVsbG8=".into(),
146        };
147        let s = serde_json::to_string(&m).unwrap();
148        assert_eq!(s, r#"{"type":"SerialIn","data":"aGVsbG8="}"#);
149        let back: WsMessage = serde_json::from_str(&s).unwrap();
150        assert_eq!(back, m);
151    }
152
153    #[test]
154    fn unit_variant_serializes_as_object_with_only_type() {
155        let launch = serde_json::to_string(&WsMessage::Launch).unwrap();
156        assert_eq!(launch, r#"{"type":"Launch"}"#);
157        let reset = serde_json::to_string(&WsMessage::Reset).unwrap();
158        assert_eq!(reset, r#"{"type":"Reset"}"#);
159
160        let back_launch: WsMessage = serde_json::from_str(&launch).unwrap();
161        assert_eq!(back_launch, WsMessage::Launch);
162        let back_reset: WsMessage = serde_json::from_str(&reset).unwrap();
163        assert_eq!(back_reset, WsMessage::Reset);
164    }
165
166    #[test]
167    fn state_message_contains_nested_state() {
168        let m = WsMessage::State {
169            state: GuestState::Running,
170        };
171        let s = serde_json::to_string(&m).unwrap();
172        assert_eq!(s, r#"{"type":"State","state":"Running"}"#);
173        let back: WsMessage = serde_json::from_str(&s).unwrap();
174        assert_eq!(back, m);
175    }
176
177    #[test]
178    fn hello_message_carries_version_string() {
179        let m = WsMessage::Hello {
180            version: "0.1.0".into(),
181        };
182        let s = serde_json::to_string(&m).unwrap();
183        assert!(s.contains(r#""version":"0.1.0""#), "got: {s}");
184        assert!(s.contains(r#""type":"Hello""#), "got: {s}");
185        let back: WsMessage = serde_json::from_str(&s).unwrap();
186        assert_eq!(back, m);
187    }
188
189    #[test]
190    fn guest_state_serializes_as_bare_string() {
191        let s = serde_json::to_string(&GuestState::Halted).unwrap();
192        assert_eq!(s, r#""Halted""#);
193    }
194
195    #[test]
196    fn wsmessage_implements_required_derives() {
197        let m = WsMessage::SerialIn {
198            data: "Zm9v".into(),
199        };
200        let cloned = m.clone();
201        assert_eq!(m, cloned);
202    }
203
204    #[test]
205    fn kernel_changed_ok_true_roundtrip() {
206        let m = WsMessage::KernelChanged {
207            ok: true,
208            mtime: 1_715_000_000,
209            size: 12_345_678,
210            sha256_prefix: "abc123def456".into(),
211            reason: None,
212        };
213        let s = serde_json::to_string(&m).unwrap();
214        let back: WsMessage = serde_json::from_str(&s).unwrap();
215        assert_eq!(back, m);
216        // Spot-check wire shape — type tag + field presence.
217        assert!(s.contains(r#""type":"KernelChanged""#), "got: {s}");
218        assert!(s.contains(r#""ok":true"#), "got: {s}");
219        assert!(s.contains(r#""reason":null"#), "got: {s}");
220    }
221
222    #[test]
223    fn kernel_changed_ok_false_with_reason_roundtrip() {
224        let m = WsMessage::KernelChanged {
225            ok: false,
226            mtime: 0,
227            size: 0,
228            sha256_prefix: String::new(),
229            reason: Some("not ELF".into()),
230        };
231        let s = serde_json::to_string(&m).unwrap();
232        let back: WsMessage = serde_json::from_str(&s).unwrap();
233        assert_eq!(back, m);
234        assert!(s.contains(r#""reason":"not ELF""#), "got: {s}");
235    }
236
237    #[test]
238    fn config_update_carries_opaque_value() {
239        let m = WsMessage::ConfigUpdate {
240            config: serde_json::json!({ "schema_version": 1, "actions": [] }),
241        };
242        let s = serde_json::to_string(&m).unwrap();
243        let back: WsMessage = serde_json::from_str(&s).unwrap();
244        assert_eq!(back, m);
245        assert!(s.contains(r#""type":"ConfigUpdate""#), "got: {s}");
246    }
247
248    #[test]
249    fn config_invalid_with_and_without_span() {
250        let with_span = WsMessage::ConfigInvalid {
251            error: "unknown field 'lable'".into(),
252            line: Some(12),
253            col: Some(1),
254        };
255        let s = serde_json::to_string(&with_span).unwrap();
256        let back: WsMessage = serde_json::from_str(&s).unwrap();
257        assert_eq!(back, with_span);
258        assert!(s.contains(r#""line":12"#), "got: {s}");
259        assert!(s.contains(r#""col":1"#), "got: {s}");
260
261        let without_span = WsMessage::ConfigInvalid {
262            error: "permission denied".into(),
263            line: None,
264            col: None,
265        };
266        let s = serde_json::to_string(&without_span).unwrap();
267        let back: WsMessage = serde_json::from_str(&s).unwrap();
268        assert_eq!(back, without_span);
269        assert!(s.contains(r#""line":null"#), "got: {s}");
270        assert!(s.contains(r#""col":null"#), "got: {s}");
271    }
272
273    #[test]
274    fn large_mtime_survives_i64() {
275        // Pitfall #8 (03-RESEARCH): millennium-scale Unix epoch survives i64.
276        let m = WsMessage::KernelChanged {
277            ok: true,
278            mtime: 9_999_999_999_999_i64,
279            size: u64::MAX,
280            sha256_prefix: "deadbeefcafe".into(),
281            reason: None,
282        };
283        let s = serde_json::to_string(&m).unwrap();
284        let back: WsMessage = serde_json::from_str(&s).unwrap();
285        assert_eq!(back, m);
286    }
287
288    #[test]
289    fn scenario_start_roundtrip() {
290        let m = WsMessage::ScenarioStart {
291            scenario: "boot_smoke".into(),
292        };
293        let s = serde_json::to_string(&m).unwrap();
294        let back: WsMessage = serde_json::from_str(&s).unwrap();
295        assert_eq!(back, m);
296        assert!(s.contains(r#""type":"ScenarioStart""#), "got: {s}");
297        assert!(s.contains(r#""scenario":"boot_smoke""#), "got: {s}");
298    }
299
300    #[test]
301    fn scenario_abort_roundtrip() {
302        let m = WsMessage::ScenarioAbort {
303            reason: "outer timeout".into(),
304        };
305        let s = serde_json::to_string(&m).unwrap();
306        let back: WsMessage = serde_json::from_str(&s).unwrap();
307        assert_eq!(back, m);
308        assert!(s.contains(r#""type":"ScenarioAbort""#), "got: {s}");
309    }
310
311    #[test]
312    fn scenario_result_pass_roundtrip() {
313        let m = WsMessage::ScenarioResult {
314            verdict: "pass".into(),
315            scenario: "boot_smoke".into(),
316            started_at: "2026-05-19T14:32:01.123Z".into(),
317            ended_at: "2026-05-19T14:32:03.311Z".into(),
318            actions: serde_json::json!([{"label":"reboot","verdict":"pass"}]),
319            transcript: serde_json::json!([
320                {"ts":"2026-05-19T14:32:01.123Z","type":"scenario_start"}
321            ]),
322            error: None,
323        };
324        let s = serde_json::to_string(&m).unwrap();
325        let back: WsMessage = serde_json::from_str(&s).unwrap();
326        assert_eq!(back, m);
327        assert!(s.contains(r#""type":"ScenarioResult""#), "got: {s}");
328        assert!(s.contains(r#""verdict":"pass""#), "got: {s}");
329        assert!(s.contains(r#""error":null"#), "got: {s}");
330    }
331
332    #[test]
333    fn scenario_result_timeout_roundtrip() {
334        let m = WsMessage::ScenarioResult {
335            verdict: "timeout".into(),
336            scenario: "boot_smoke".into(),
337            started_at: "2026-05-19T14:32:01.123Z".into(),
338            ended_at: "2026-05-19T14:32:31.999Z".into(),
339            actions: serde_json::json!([]),
340            transcript: serde_json::json!([]),
341            error: Some("no serial output observed".into()),
342        };
343        let s = serde_json::to_string(&m).unwrap();
344        let back: WsMessage = serde_json::from_str(&s).unwrap();
345        assert_eq!(back, m);
346        assert!(
347            s.contains(r#""error":"no serial output observed""#),
348            "got: {s}"
349        );
350    }
351
352    #[test]
353    fn scenario_result_opaque_payload_roundtrip() {
354        // Exercises `actions` + `transcript` with nested arrays-of-objects,
355        // matching the JSONL event shapes in 04-RESEARCH "JSONL transcript
356        // event shapes". `serde_json::Value` derives PartialEq, so
357        // assert_eq is sufficient — no manual field-walk needed.
358        let actions = serde_json::json!([
359            {
360                "label": "reboot",
361                "verdict": "pass",
362                "assertions": [
363                    {"kind": "regex", "pattern": "hello", "verdict": "pass"},
364                    {"kind": "regex", "pattern": "world", "verdict": "pass"}
365                ]
366            },
367            {
368                "label": "halt",
369                "verdict": "fail",
370                "assertions": [
371                    {"kind": "regex", "pattern": "halted", "verdict": "fail"}
372                ]
373            }
374        ]);
375        let transcript = serde_json::json!([
376            {"ts": "2026-05-19T14:32:01.123Z", "type": "scenario_start", "scenario": "boot_smoke"},
377            {"ts": "2026-05-19T14:32:01.456Z", "type": "action_start", "label": "reboot"},
378            {"ts": "2026-05-19T14:32:02.789Z", "type": "serial_out", "data_b64": "aGVsbG8gd29ybGQK"},
379            {"ts": "2026-05-19T14:32:03.000Z", "type": "action_end", "label": "reboot", "verdict": "pass"},
380            {"ts": "2026-05-19T14:32:03.311Z", "type": "scenario_end", "verdict": "fail"}
381        ]);
382        let m = WsMessage::ScenarioResult {
383            verdict: "fail".into(),
384            scenario: "boot_smoke".into(),
385            started_at: "2026-05-19T14:32:01.123Z".into(),
386            ended_at: "2026-05-19T14:32:03.311Z".into(),
387            actions,
388            transcript,
389            error: None,
390        };
391        let s = serde_json::to_string(&m).unwrap();
392        let back: WsMessage = serde_json::from_str(&s).unwrap();
393        assert_eq!(back, m);
394    }
395}