#![cfg_attr(not(test), deny(unsafe_code))]
use serde::{Deserialize, Serialize};
pub mod config;
pub mod escape;
pub use config::{
parse_str, Action, Assertion, AssertionKind, CliAction, Config, LoadError, LoadedConfig,
ResolvedAction, Scenario,
};
pub use escape::{decode_bytes_escape, EscapeError};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "type")]
pub enum WsMessage {
SerialIn { data: String },
SerialOut { data: String },
State { state: GuestState },
Launch,
Reset,
Hello { version: String },
KernelChanged {
ok: bool,
mtime: i64,
size: u64,
sha256_prefix: String,
reason: Option<String>,
},
ConfigUpdate { config: serde_json::Value },
ConfigInvalid {
error: String,
line: Option<u32>,
col: Option<u32>,
},
ScenarioStart { scenario: String },
ScenarioAbort { reason: String },
ScenarioResult {
verdict: String,
scenario: String,
started_at: String,
ended_at: String,
actions: serde_json::Value,
transcript: serde_json::Value,
error: Option<String>,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuestState {
Idle,
Loading,
Running,
Halted,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serial_in_roundtrip() {
let m = WsMessage::SerialIn {
data: "aGVsbG8=".into(),
};
let s = serde_json::to_string(&m).unwrap();
assert_eq!(s, r#"{"type":"SerialIn","data":"aGVsbG8="}"#);
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
}
#[test]
fn unit_variant_serializes_as_object_with_only_type() {
let launch = serde_json::to_string(&WsMessage::Launch).unwrap();
assert_eq!(launch, r#"{"type":"Launch"}"#);
let reset = serde_json::to_string(&WsMessage::Reset).unwrap();
assert_eq!(reset, r#"{"type":"Reset"}"#);
let back_launch: WsMessage = serde_json::from_str(&launch).unwrap();
assert_eq!(back_launch, WsMessage::Launch);
let back_reset: WsMessage = serde_json::from_str(&reset).unwrap();
assert_eq!(back_reset, WsMessage::Reset);
}
#[test]
fn state_message_contains_nested_state() {
let m = WsMessage::State {
state: GuestState::Running,
};
let s = serde_json::to_string(&m).unwrap();
assert_eq!(s, r#"{"type":"State","state":"Running"}"#);
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
}
#[test]
fn hello_message_carries_version_string() {
let m = WsMessage::Hello {
version: "0.1.0".into(),
};
let s = serde_json::to_string(&m).unwrap();
assert!(s.contains(r#""version":"0.1.0""#), "got: {s}");
assert!(s.contains(r#""type":"Hello""#), "got: {s}");
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
}
#[test]
fn guest_state_serializes_as_bare_string() {
let s = serde_json::to_string(&GuestState::Halted).unwrap();
assert_eq!(s, r#""Halted""#);
}
#[test]
fn wsmessage_implements_required_derives() {
let m = WsMessage::SerialIn {
data: "Zm9v".into(),
};
let cloned = m.clone();
assert_eq!(m, cloned);
}
#[test]
fn kernel_changed_ok_true_roundtrip() {
let m = WsMessage::KernelChanged {
ok: true,
mtime: 1_715_000_000,
size: 12_345_678,
sha256_prefix: "abc123def456".into(),
reason: None,
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
assert!(s.contains(r#""type":"KernelChanged""#), "got: {s}");
assert!(s.contains(r#""ok":true"#), "got: {s}");
assert!(s.contains(r#""reason":null"#), "got: {s}");
}
#[test]
fn kernel_changed_ok_false_with_reason_roundtrip() {
let m = WsMessage::KernelChanged {
ok: false,
mtime: 0,
size: 0,
sha256_prefix: String::new(),
reason: Some("not ELF".into()),
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
assert!(s.contains(r#""reason":"not ELF""#), "got: {s}");
}
#[test]
fn config_update_carries_opaque_value() {
let m = WsMessage::ConfigUpdate {
config: serde_json::json!({ "schema_version": 1, "actions": [] }),
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
assert!(s.contains(r#""type":"ConfigUpdate""#), "got: {s}");
}
#[test]
fn config_invalid_with_and_without_span() {
let with_span = WsMessage::ConfigInvalid {
error: "unknown field 'lable'".into(),
line: Some(12),
col: Some(1),
};
let s = serde_json::to_string(&with_span).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, with_span);
assert!(s.contains(r#""line":12"#), "got: {s}");
assert!(s.contains(r#""col":1"#), "got: {s}");
let without_span = WsMessage::ConfigInvalid {
error: "permission denied".into(),
line: None,
col: None,
};
let s = serde_json::to_string(&without_span).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, without_span);
assert!(s.contains(r#""line":null"#), "got: {s}");
assert!(s.contains(r#""col":null"#), "got: {s}");
}
#[test]
fn large_mtime_survives_i64() {
let m = WsMessage::KernelChanged {
ok: true,
mtime: 9_999_999_999_999_i64,
size: u64::MAX,
sha256_prefix: "deadbeefcafe".into(),
reason: None,
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
}
#[test]
fn scenario_start_roundtrip() {
let m = WsMessage::ScenarioStart {
scenario: "boot_smoke".into(),
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
assert!(s.contains(r#""type":"ScenarioStart""#), "got: {s}");
assert!(s.contains(r#""scenario":"boot_smoke""#), "got: {s}");
}
#[test]
fn scenario_abort_roundtrip() {
let m = WsMessage::ScenarioAbort {
reason: "outer timeout".into(),
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
assert!(s.contains(r#""type":"ScenarioAbort""#), "got: {s}");
}
#[test]
fn scenario_result_pass_roundtrip() {
let m = WsMessage::ScenarioResult {
verdict: "pass".into(),
scenario: "boot_smoke".into(),
started_at: "2026-05-19T14:32:01.123Z".into(),
ended_at: "2026-05-19T14:32:03.311Z".into(),
actions: serde_json::json!([{"label":"reboot","verdict":"pass"}]),
transcript: serde_json::json!([
{"ts":"2026-05-19T14:32:01.123Z","type":"scenario_start"}
]),
error: None,
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
assert!(s.contains(r#""type":"ScenarioResult""#), "got: {s}");
assert!(s.contains(r#""verdict":"pass""#), "got: {s}");
assert!(s.contains(r#""error":null"#), "got: {s}");
}
#[test]
fn scenario_result_timeout_roundtrip() {
let m = WsMessage::ScenarioResult {
verdict: "timeout".into(),
scenario: "boot_smoke".into(),
started_at: "2026-05-19T14:32:01.123Z".into(),
ended_at: "2026-05-19T14:32:31.999Z".into(),
actions: serde_json::json!([]),
transcript: serde_json::json!([]),
error: Some("no serial output observed".into()),
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
assert!(
s.contains(r#""error":"no serial output observed""#),
"got: {s}"
);
}
#[test]
fn scenario_result_opaque_payload_roundtrip() {
let actions = serde_json::json!([
{
"label": "reboot",
"verdict": "pass",
"assertions": [
{"kind": "regex", "pattern": "hello", "verdict": "pass"},
{"kind": "regex", "pattern": "world", "verdict": "pass"}
]
},
{
"label": "halt",
"verdict": "fail",
"assertions": [
{"kind": "regex", "pattern": "halted", "verdict": "fail"}
]
}
]);
let transcript = serde_json::json!([
{"ts": "2026-05-19T14:32:01.123Z", "type": "scenario_start", "scenario": "boot_smoke"},
{"ts": "2026-05-19T14:32:01.456Z", "type": "action_start", "label": "reboot"},
{"ts": "2026-05-19T14:32:02.789Z", "type": "serial_out", "data_b64": "aGVsbG8gd29ybGQK"},
{"ts": "2026-05-19T14:32:03.000Z", "type": "action_end", "label": "reboot", "verdict": "pass"},
{"ts": "2026-05-19T14:32:03.311Z", "type": "scenario_end", "verdict": "fail"}
]);
let m = WsMessage::ScenarioResult {
verdict: "fail".into(),
scenario: "boot_smoke".into(),
started_at: "2026-05-19T14:32:01.123Z".into(),
ended_at: "2026-05-19T14:32:03.311Z".into(),
actions,
transcript,
error: None,
};
let s = serde_json::to_string(&m).unwrap();
let back: WsMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back, m);
}
}