probe/src/lib.rs
//! The injected-guest **shared core** for Skidbladnir's in-VM test binaries.
//!
//! Two static-musl binaries are dropped into every matrix guest and both need the
//! same primitives, so they live here ONCE (LAW 5 — reuse, do not twin) rather than
//! being copy-pasted into each `src/bin/*.rs`:
//!
//! * `skidbladnir-probe` — the one-shot health/liveness introspector.
//! * `skidbladnir-scenario` — the **plan interpreter**: it executes a host-authored
//! list of cases against the guest's real init system and reports, per case, the
//! *applied* state it observed (see [`plan`] and [`facts`]).
//!
//! ## Why the split matters (the whole point of the VM suite)
//!
//! The guest **collects facts**; it never judges them. Every verdict is computed
//! host-side by a pure function in `skidbladnir::vmsuite`, so each guard can be
//! driven RED on the host with synthetic facts *and* RED in a real guest with a
//! deliberately-broken case. A guest that scored itself could only ever report the
//! answer it was built to give.
//!
//! ## Deliberately `std`-only
//!
//! The parent `skidbladnir` crate (ring/zbus/rcgen) cannot cross-compile to
//! `x86_64-unknown-linux-musl` statically; this crate can only because it depends on
//! nothing. That constraint is load-bearing: ONE artifact must run in a glibc
//! Ubuntu/Debian/Fedora guest AND a musl Alpine guest with no shared-lib surprises.
//! So the wire formats are hand-rolled (`hex`, line-oriented plans, hand-emitted
//! JSON) instead of serde — and every hand-rolled piece is unit-tested here.
pub mod facts;
pub mod plan;
/// Minimal JSON string escaping (std-only — no serde in the guest binaries).
///
/// Shared by the probe's `ProbeReport` line and the scenario's `Facts` line, which
/// is why it lives here rather than in either binary.
pub fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'"' => o.push_str("\\\""),
'\\' => o.push_str("\\\\"),
'\n' => o.push_str("\\n"),
'\r' => o.push_str("\\r"),
'\t' => o.push_str("\\t"),
c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
c => o.push(c),
}
}
o
}
/// A JSON array of escaped strings.
pub fn json_str_array(items: &[String]) -> String {
let inner: Vec<String> = items.iter().map(|s| format!("\"{}\"", esc(s))).collect();
format!("[{}]", inner.join(","))
}
/// Run `cmd` and return stdout+stderr concatenated, or `None` if it could not be
/// spawned at all. (The probe's original signature — kept verbatim so its behaviour
/// is unchanged by the move.)
pub fn run(cmd: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(cmd).args(args).output().ok()?;
let mut s = String::from_utf8_lossy(&out.stdout).to_string();
s.push_str(&String::from_utf8_lossy(&out.stderr));
Some(s)
}
/// Run `argv` with **no shell** and return `(exit_code, stdout, stderr)`.
///
/// `-1` is the exit code for "could not spawn" and `-2` for "killed by a signal", so
/// a caller can tell those apart from a program that genuinely exited non-zero. No
/// shell is involved anywhere: `argv[0]` is the program, the rest are arguments, so
/// a value containing spaces or metacharacters can never be re-split or expanded.
pub fn run_argv(argv: &[String]) -> (i32, String, String) {
let Some((prog, args)) = argv.split_first() else {
return (-1, String::new(), "empty argv".into());
};
match std::process::Command::new(prog).args(args).output() {
Ok(o) => (
o.status.code().unwrap_or(-2),
String::from_utf8_lossy(&o.stdout).to_string(),
String::from_utf8_lossy(&o.stderr).to_string(),
),
Err(e) => (-1, String::new(), format!("spawn {prog}: {e}")),
}
}
/// Lowercase hex encoding — the plan wire's escape-free payload encoding.
pub fn hex_encode(bytes: &[u8]) -> String {
const D: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(D[(b >> 4) as usize] as char);
s.push(D[(b & 0x0f) as usize] as char);
}
s
}
/// Decode a lowercase/uppercase hex string. `None` on an odd length or a non-hex
/// digit — a corrupted plan must be a loud error, never a silently truncated file.
pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
let val = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len() / 2);
for pair in b.chunks(2) {
out.push((val(pair[0])? << 4) | val(pair[1])?);
}
Some(out)
}
/// A UTC RFC3339-ish stamp, via the guest's `date` (coreutils or busybox), falling
/// back to epoch seconds.
pub fn iso8601_utc() -> String {
if let Some(s) = run("date", &["-u", "+%Y-%m-%dT%H:%M:%SZ"]) {
let s = s.trim();
if s.ends_with('Z') {
return s.to_string();
}
}
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("epoch:{secs}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trips_including_the_bytes_that_break_line_formats() {
// A unit file body carries newlines, `=`, `#` and quotes — every one of which
// would need escaping in the plan's line-oriented grammar. Hex has none.
let body = b"[Service]\nUser=skidsvc\nEnvironment=\"A=b #c\"\n\0\xff";
let h = hex_encode(body);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {h}");
assert_eq!(hex_decode(&h).unwrap(), body);
}
#[test]
fn hex_decode_refuses_corruption_rather_than_truncating() {
assert_eq!(hex_decode("abc"), None, "odd length is corruption, not 1 byte");
assert_eq!(hex_decode("zz"), None, "non-hex digit is corruption");
assert_eq!(hex_decode(""), Some(Vec::new()), "empty is a legitimate empty file");
}
#[test]
fn json_escaping_survives_a_systemctl_cat_blob() {
let cat = "# /etc/systemd/system/x.service\n[Unit]\nDescription=\"a\\b\"\n";
let j = esc(cat);
assert!(!j.contains('\n'), "raw newlines would break the one-line report: {j}");
assert!(j.contains("\\\"a\\\\b\\\""), "quotes and backslashes both escaped: {j}");
}
#[test]
fn run_argv_reports_the_exit_code_not_just_success() {
let (code, out, _) = run_argv(&["/bin/sh".into(), "-c".into(), "echo hi".into()]);
assert_eq!(code, 0);
assert_eq!(out.trim(), "hi");
let (code, _, _) = run_argv(&["/bin/sh".into(), "-c".into(), "exit 7".into()]);
assert_eq!(code, 7, "a non-zero exit must be reported, not collapsed to a bool");
let (code, _, err) = run_argv(&["/nonexistent/binary".into()]);
assert_eq!(code, -1, "unspawnable is distinguishable from a non-zero exit");
assert!(err.contains("spawn"), "{err}");
}
/// `run_argv` must NOT go through a shell: a metacharacter in an argument is
/// data, never syntax.
#[test]
fn run_argv_never_interprets_a_shell_metacharacter() {
let (code, out, _) = run_argv(&["/bin/echo".into(), "a; rm -rf /".into()]);
assert_eq!(code, 0);
assert_eq!(out.trim(), "a; rm -rf /", "the `;` stayed data");
}
}