Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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);
    }
}