probe/src/plan.rs
//! The **plan wire** — the host-authored, guest-executed case list.
//!
//! The host (`skidbladnir::vmsuite`) renders a plan; the guest's
//! `skidbladnir-scenario` parses and executes it. The format is deliberately
//! line-oriented ASCII with hex payloads, because the guest side may depend on
//! nothing but `std` (see the crate docs) and a hand-rolled parser for a
//! line-oriented grammar is something you can actually read and test — a hand-rolled
//! JSON parser is not.
//!
//! ```text
//! # comment
//! CASE clean-install
//! WRITE /etc/systemd/system/skidtest.service 0644 5b556e6974...
//! RUN systemctl daemon-reload
//! SLEEP 2000
//! COLLECT skidtest.service /usr/local/bin/skidbladnir-testsvc
//! END
//! ```
//!
//! Every verb is a *step*; `COLLECT` is the one that captures the guest's applied
//! state and emits a facts line. `POWEROFF` ends the run.
//!
//! **Escaping:** file bodies ride as hex, so no unit-file byte (newline, `=`, `#`,
//! quote) can ever be re-interpreted as grammar. `RUN` argv is split on ASCII
//! whitespace and executed WITHOUT a shell, so a metacharacter in an argument is
//! data. Neither of those is a nicety: the suite's whole job is planting hostile
//! drop-in files, and a format that mangled them would fake its own red.
use crate::hex_decode;
/// One executable step inside a case.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
/// Run `argv` with no shell.
Run(Vec<String>),
/// Write `contents` to `path` with octal `mode`, creating parent dirs.
Write { path: String, mode: u32, contents: Vec<u8> },
/// Remove a single file (absent is not an error).
Rm(String),
/// Remove a directory tree (absent is not an error).
RmTree(String),
/// Sleep, to let a just-started unit settle.
Sleep(u64),
/// Capture the applied state of `unit` (and the process running `exec`) and
/// emit this case's facts line.
Collect { unit: String, exec: String },
/// Run `argv` with no shell and keep its **stdout** under `key`, for the next
/// `COLLECT` to carry out as a fact.
///
/// `RUN` records only an exit code, and LAW 2 is explicit that a malformed
/// command's empty output is shaped exactly like a clean result. A suite that
/// wants to assert *applied output* — a parquet footer, an object count, the
/// roster a client got back — needs the bytes, not the status.
Capture { key: String, argv: Vec<String> },
/// Poll `argv` (no shell) every `poll_ms` until it exits 0 or `timeout_ms`
/// elapses, and record how long that took.
///
/// This is what lets a case assert an outcome that arrives *on the service's own
/// schedule* without a fixed `SLEEP` long enough to hide a regression: the
/// recorded latency IS the measurement. A timeout is recorded as a non-zero
/// exit, never as a silent continue.
WaitFor { timeout_ms: u64, poll_ms: u64, argv: Vec<String> },
/// Power the guest off (ends the run).
PowerOff,
}
/// One case: an id plus the steps to run for it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Case {
pub id: String,
pub steps: Vec<Step>,
}
/// A parsed plan.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Plan {
pub cases: Vec<Case>,
}
/// Parse the plan wire. Returns the offending line (1-based) on a malformed input —
/// a plan the guest cannot fully understand must abort loudly, because a silently
/// skipped case is a case that reports nothing and therefore never fails.
pub fn parse_plan(text: &str) -> Result<Plan, String> {
let mut plan = Plan::default();
let mut cur: Option<Case> = None;
for (i, raw) in text.lines().enumerate() {
let lineno = i + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (verb, rest) = match line.split_once(' ') {
Some((v, r)) => (v, r.trim()),
None => (line, ""),
};
match verb {
"CASE" => {
if cur.is_some() {
return Err(format!("line {lineno}: CASE inside an unterminated case (missing END)"));
}
if rest.is_empty() {
return Err(format!("line {lineno}: CASE needs an id"));
}
cur = Some(Case { id: rest.to_string(), steps: Vec::new() });
}
"END" => {
let c = cur.take().ok_or_else(|| format!("line {lineno}: END outside a CASE"))?;
plan.cases.push(c);
}
_ => {
let case = cur
.as_mut()
.ok_or_else(|| format!("line {lineno}: `{verb}` outside a CASE"))?;
let step = parse_step(verb, rest).map_err(|e| format!("line {lineno}: {e}"))?;
case.steps.push(step);
}
}
}
if cur.is_some() {
return Err("unterminated CASE at end of plan (missing END)".into());
}
Ok(plan)
}
fn parse_step(verb: &str, rest: &str) -> Result<Step, String> {
match verb {
"RUN" => {
let argv: Vec<String> = rest.split_whitespace().map(|s| s.to_string()).collect();
if argv.is_empty() {
return Err("RUN needs an argv".into());
}
Ok(Step::Run(argv))
}
"WRITE" => {
let f: Vec<&str> = rest.split_whitespace().collect();
if f.len() != 3 {
return Err("WRITE needs <path> <octal-mode> <hex-contents>".into());
}
let mode = u32::from_str_radix(f[1], 8).map_err(|_| format!("bad octal mode `{}`", f[1]))?;
let contents = hex_decode(f[2]).ok_or_else(|| "WRITE contents are not valid hex".to_string())?;
Ok(Step::Write { path: f[0].to_string(), mode, contents })
}
"RM" => {
if rest.is_empty() {
return Err("RM needs a path".into());
}
Ok(Step::Rm(rest.to_string()))
}
"RMTREE" => {
if rest.is_empty() {
return Err("RMTREE needs a path".into());
}
Ok(Step::RmTree(rest.to_string()))
}
"SLEEP" => rest
.parse::<u64>()
.map(Step::Sleep)
.map_err(|_| format!("SLEEP needs milliseconds, got `{rest}`")),
"COLLECT" => {
let f: Vec<&str> = rest.split_whitespace().collect();
if f.len() != 2 {
return Err("COLLECT needs <unit> <exec-path>".into());
}
Ok(Step::Collect { unit: f[0].to_string(), exec: f[1].to_string() })
}
"CAPTURE" => {
let (key, rest) = rest.split_once(' ').ok_or("CAPTURE needs <key> <argv…>")?;
if key.is_empty() || key.contains('=') {
return Err(format!("CAPTURE key `{key}` must be non-empty and contain no `=`"));
}
let argv: Vec<String> = rest.split_whitespace().map(|s| s.to_string()).collect();
if argv.is_empty() {
return Err("CAPTURE needs an argv after the key".into());
}
Ok(Step::Capture { key: key.to_string(), argv })
}
"WAITFOR" => {
let f: Vec<&str> = rest.split_whitespace().collect();
if f.len() < 3 {
return Err("WAITFOR needs <timeout-ms> <poll-ms> <argv…>".into());
}
let timeout_ms =
f[0].parse::<u64>().map_err(|_| format!("WAITFOR timeout `{}` is not ms", f[0]))?;
let poll_ms =
f[1].parse::<u64>().map_err(|_| format!("WAITFOR poll `{}` is not ms", f[1]))?;
if poll_ms == 0 {
return Err("WAITFOR poll interval must be > 0".into());
}
Ok(Step::WaitFor {
timeout_ms,
poll_ms,
argv: f[2..].iter().map(|s| s.to_string()).collect(),
})
}
"POWEROFF" => Ok(Step::PowerOff),
other => Err(format!("unknown verb `{other}`")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hex_encode;
/// The GOLDEN plan sample. The host-side renderer
/// (`skidbladnir::vmsuite::render_plan`) has a test asserting it emits exactly
/// this shape, so the two sides of the wire cannot drift apart silently — the
/// host and the guest live in different cargo workspaces and share no type.
pub const GOLDEN: &str = "\
# skidbladnir vm-suite plan
CASE clean-install
WRITE /etc/systemd/system/skidtest.service 0644 5b556e69745d0a
RUN systemctl daemon-reload
WAITFOR 60000 500 systemctl is-active skidtest.service
CAPTURE footer tail -c 4 /var/lib/skid/nodes.parquet
SLEEP 2000
COLLECT skidtest.service /usr/local/bin/skidbladnir-testsvc
END
CASE done
POWEROFF
END
";
#[test]
fn the_golden_plan_parses_into_the_shape_the_host_meant() {
let p = parse_plan(GOLDEN).expect("golden plan parses");
assert_eq!(p.cases.len(), 2);
assert_eq!(p.cases[0].id, "clean-install");
assert_eq!(
p.cases[0].steps[0],
Step::Write {
path: "/etc/systemd/system/skidtest.service".into(),
mode: 0o644,
contents: b"[Unit]\n".to_vec(),
}
);
assert_eq!(p.cases[0].steps[1], Step::Run(vec!["systemctl".into(), "daemon-reload".into()]));
assert_eq!(
p.cases[0].steps[2],
Step::WaitFor {
timeout_ms: 60_000,
poll_ms: 500,
argv: vec!["systemctl".into(), "is-active".into(), "skidtest.service".into()],
}
);
assert_eq!(
p.cases[0].steps[3],
Step::Capture {
key: "footer".into(),
argv: vec![
"tail".into(),
"-c".into(),
"4".into(),
"/var/lib/skid/nodes.parquet".into()
],
}
);
assert_eq!(p.cases[0].steps[4], Step::Sleep(2000));
assert_eq!(
p.cases[0].steps[5],
Step::Collect {
unit: "skidtest.service".into(),
exec: "/usr/local/bin/skidbladnir-testsvc".into(),
}
);
assert_eq!(p.cases[1].steps, vec![Step::PowerOff]);
}
/// A unit body with newlines, `#`, `=` and quotes survives the wire intact —
/// this is the property that lets the suite plant a hostile drop-in verbatim.
#[test]
fn a_hostile_dropin_body_round_trips_byte_for_byte() {
let body = "[Service]\n# override\nUser=rickard\nEnvironment=\"K=v\"\n";
let text = format!(
"CASE x\nWRITE /etc/systemd/system/s.service.d/99.conf 0644 {}\nEND\n",
hex_encode(body.as_bytes())
);
let p = parse_plan(&text).unwrap();
match &p.cases[0].steps[0] {
Step::Write { contents, mode, path } => {
assert_eq!(String::from_utf8_lossy(contents), body);
assert_eq!(*mode, 0o644);
assert_eq!(path, "/etc/systemd/system/s.service.d/99.conf");
}
s => panic!("expected WRITE, got {s:?}"),
}
}
// ───────────────────────── the parser must be able to say NO ─────────────────
#[test]
fn a_malformed_plan_is_an_error_not_a_silently_shorter_run() {
// Silently dropping a case is the failure mode that matters: the case that
// never runs is the case that never fails.
for (bad, why) in [
("RUN echo hi\n", "step outside a CASE"),
("CASE a\nRUN echo\n", "unterminated CASE"),
("CASE a\nCASE b\nEND\n", "nested CASE"),
("END\n", "END outside a CASE"),
("CASE a\nWOBBLE x\nEND\n", "unknown verb"),
("CASE a\nSLEEP soon\nEND\n", "non-numeric SLEEP"),
("CASE a\nWRITE /p 0644 zz\nEND\n", "non-hex contents"),
("CASE a\nWRITE /p 999 00\nEND\n", "bad octal mode"),
("CASE a\nCOLLECT only-one-field\nEND\n", "COLLECT arity"),
("CASE\nEND\n", "CASE without an id"),
("CASE a\nCAPTURE onlykey\nEND\n", "CAPTURE with no argv"),
("CASE a\nCAPTURE k=v true\nEND\n", "a CAPTURE key containing `=` would alias the KEY=VALUE wire"),
("CASE a\nWAITFOR 1000 true\nEND\n", "WAITFOR arity"),
("CASE a\nWAITFOR soon 500 true\nEND\n", "non-numeric WAITFOR timeout"),
("CASE a\nWAITFOR 1000 0 true\nEND\n", "a zero poll interval would spin"),
] {
assert!(parse_plan(bad).is_err(), "must reject ({why}): {bad:?}");
}
}
#[test]
fn comments_and_blank_lines_are_ignored_but_nothing_else_is() {
let p = parse_plan("\n# hi\nCASE a\n\n # mid\n RUN true\nEND\n").unwrap();
assert_eq!(p.cases.len(), 1);
assert_eq!(p.cases[0].steps.len(), 1, "only the RUN counted");
}
/// An empty plan is legal (nothing to do) but must not be confused with a
/// parse failure.
#[test]
fn an_empty_plan_is_zero_cases_not_an_error() {
assert_eq!(parse_plan("").unwrap().cases.len(), 0);
assert_eq!(parse_plan("# only a comment\n").unwrap().cases.len(), 0);
}
}