probe/src/facts.rs
//! **Applied-state facts** — what the guest's init system actually resolved.
//!
//! This module is the reason the VM suite exists. A unit renderer tested by
//! string-comparing its own output asserts what it *wrote*; these facts are what
//! systemd *did* with it after merging every drop-in on the machine:
//!
//! * `systemctl show -p User -p Group -p Environment -p FragmentPath -p DropInPaths …`
//! — the **resolved** properties, i.e. the answer after the merge.
//! * `systemctl cat` — fragment **plus** drop-ins, which is where an override hides.
//! * `/proc/<MainPID>/environ` and `/proc/<MainPID>/status` — what the *live process*
//! got: `USER`, `HOME`, and the uid it is really running under.
//! * the on-disk unit fragment and its `.d/` directory — so an uninstall that left a
//! landmine behind is visible as data.
//!
//! The guest emits these; it does **not** grade them. Grading is
//! `skidbladnir::vmsuite`'s pure, host-side, RED-provable job.
use crate::{esc, iso8601_utc, json_str_array, run_argv};
/// The properties captured for every `COLLECT`. `User`/`Group`/`Environment` are the
/// ones a drop-in most commonly hijacks; `DropInPaths` names the hijacker;
/// `FragmentPath`/`LoadState` prove an uninstall really removed the unit; the two
/// `ActiveEnterTimestampMonotonic` reads are what make a boot-ordering claim
/// falsifiable.
pub const SHOW_PROPS: &[&str] = &[
"Id",
"LoadState",
"ActiveState",
"SubState",
"UnitFileState",
"FragmentPath",
"DropInPaths",
"User",
"Group",
"Environment",
"EnvironmentFiles",
"WorkingDirectory",
"After",
"Wants",
"MainPID",
"ExecMainStatus",
"Result",
"ActiveEnterTimestampMonotonic",
];
/// One case's captured state, ready to be emitted as a single JSON line.
#[derive(Debug, Default, Clone)]
pub struct Facts {
pub case: String,
/// `"<exit> <argv joined>"` for every step that ran a command, in order.
pub exits: Vec<String>,
/// `KEY=VALUE` lines exactly as `systemctl show` produced them, plus the
/// synthetic `NetworkOnlineActiveEnterTimestampMonotonic=` read from
/// `network-online.target` (the ordering reference point).
pub show: Vec<String>,
/// `systemctl cat <unit>` verbatim (fragment + every drop-in).
pub cat: String,
/// `KEY=VALUE` entries of `/proc/<MainPID>/environ`.
pub proc_env: Vec<String>,
/// The username `/proc/<MainPID>/status`' `Uid:` resolves to via `/etc/passwd`.
pub proc_user: String,
/// Unit-related paths that exist on disk right now (the landmine check).
pub files: Vec<String>,
/// `"<key>=<stdout>"` for every `CAPTURE` step of this case, in order — the
/// **bytes a command produced**, not its exit code.
///
/// Same flat `KEY=VALUE` shape as [`Facts::show`] on purpose: a captured value may
/// be multi-line (a journal excerpt, a `find` listing) and the JSON escaper already
/// handles that, so no second encoding is needed and no key may contain `=` (the
/// plan parser refuses one).
pub captures: Vec<String>,
pub at: String,
}
impl Facts {
/// Emit the one-line JSON the host parses. Hand-rolled (no serde in the guest):
/// every field is either a JSON string or an array of JSON strings, so the shape
/// is flat by construction and cannot drift into something serde would need a
/// custom impl for.
pub fn to_json_line(&self) -> String {
format!(
"{{\"case\":\"{}\",\"exits\":{},\"show\":{},\"cat\":\"{}\",\"proc_env\":{},\"proc_user\":\"{}\",\"files\":{},\"captures\":{},\"at\":\"{}\"}}",
esc(&self.case),
json_str_array(&self.exits),
json_str_array(&self.show),
esc(&self.cat),
json_str_array(&self.proc_env),
esc(&self.proc_user),
json_str_array(&self.files),
json_str_array(&self.captures),
esc(&self.at),
)
}
}
/// Parse `KEY=VALUE` lines (the `systemctl show` shape) picking out one key.
/// Shared with the host side's judge by construction of the format, not by code —
/// see `skidbladnir::vmsuite::show_value`.
pub fn show_value<'a>(lines: &'a [String], key: &str) -> Option<&'a str> {
lines.iter().find_map(|l| l.strip_prefix(key).and_then(|r| r.strip_prefix('=')))
}
/// The uid on `/proc/<pid>/status`' `Uid:` line (the *effective* uid is field 2; we
/// take field 1, the real uid, which is what `User=` sets).
fn proc_uid(pid: u32) -> Option<u32> {
let s = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
s.lines()
.find_map(|l| l.strip_prefix("Uid:"))
.and_then(|v| v.split_whitespace().next().and_then(|n| n.parse().ok()))
}
/// Resolve `uid` to a username through `/etc/passwd` (no libc `getpwuid`: this binary
/// is static musl and must not need NSS).
pub fn passwd_name_for_uid(passwd: &str, uid: u32) -> Option<String> {
for line in passwd.lines() {
let f: Vec<&str> = line.split(':').collect();
if f.len() >= 3 && f[2].parse::<u32>() == Ok(uid) {
return Some(f[0].to_string());
}
}
None
}
/// `/proc/<pid>/environ` as `KEY=VALUE` strings (NUL-separated on disk).
pub fn parse_environ(raw: &[u8]) -> Vec<String> {
raw.split(|b| *b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).to_string())
.collect()
}
/// Capture everything about `unit` (and its main process) that a host-side judge
/// might need. `exec` is the binary the unit is supposed to be running; it is
/// recorded in `files` when present so a missing payload is visible.
pub fn collect(
case: &str,
unit: &str,
exec: &str,
exits: Vec<String>,
captures: Vec<String>,
) -> Facts {
let mut show: Vec<String> = Vec::new();
let mut argv: Vec<String> = vec!["systemctl".into(), "show".into(), "--no-pager".into()];
for p in SHOW_PROPS {
argv.push("-p".into());
argv.push((*p).to_string());
}
argv.push(unit.to_string());
let (_, out, _) = run_argv(&argv);
for l in out.lines() {
if !l.trim().is_empty() {
show.push(l.trim_end().to_string());
}
}
// The ordering reference point: when did network-online.target activate? Renamed
// so it cannot collide with the unit's own key in the same flat list.
let (_, net, _) = run_argv(&[
"systemctl".into(),
"show".into(),
"--no-pager".into(),
"-p".into(),
"ActiveEnterTimestampMonotonic".into(),
"network-online.target".into(),
]);
if let Some(v) = net.lines().find_map(|l| l.strip_prefix("ActiveEnterTimestampMonotonic=")) {
show.push(format!("NetworkOnlineActiveEnterTimestampMonotonic={}", v.trim()));
}
let (_, cat, cat_err) = run_argv(&["systemctl".into(), "cat".into(), "--no-pager".into(), unit.to_string()]);
let cat = if cat.trim().is_empty() { cat_err } else { cat };
// The live process: what did it ACTUALLY get?
let pid: Option<u32> = show_value(&show, "MainPID").and_then(|v| v.trim().parse().ok()).filter(|p| *p != 0);
let (proc_env, proc_user) = match pid {
Some(p) => {
let env = std::fs::read(format!("/proc/{p}/environ")).map(|r| parse_environ(&r)).unwrap_or_default();
let user = proc_uid(p)
.and_then(|u| {
std::fs::read_to_string("/etc/passwd")
.ok()
.and_then(|pw| passwd_name_for_uid(&pw, u))
.or_else(|| Some(format!("uid:{u}")))
})
.unwrap_or_default();
(env, user)
}
None => (Vec::new(), String::new()),
};
// On-disk reality: the fragment, its drop-in dir and everything inside it, and
// the payload binary. This is what makes "uninstall left a landmine" visible.
let mut files: Vec<String> = Vec::new();
let frag = format!("/etc/systemd/system/{unit}");
if std::path::Path::new(&frag).exists() {
files.push(frag);
}
let dropin_dir = format!("/etc/systemd/system/{unit}.d");
if std::path::Path::new(&dropin_dir).is_dir() {
files.push(format!("{dropin_dir}/"));
if let Ok(rd) = std::fs::read_dir(&dropin_dir) {
let mut entries: Vec<String> =
rd.flatten().map(|e| e.path().to_string_lossy().to_string()).collect();
entries.sort();
files.extend(entries);
}
}
if !exec.is_empty() && std::path::Path::new(exec).exists() {
files.push(exec.to_string());
}
Facts {
case: case.to_string(),
exits,
show,
cat,
proc_env,
proc_user,
files,
captures,
at: iso8601_utc(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn show_value_picks_the_exact_key_not_a_prefix_match() {
let lines: Vec<String> = ["User=skidsvc", "UserName=other", "Group=", "MainPID=0"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(show_value(&lines, "User"), Some("skidsvc"));
assert_eq!(show_value(&lines, "Group"), Some(""), "an empty value is a value, not absence");
assert_eq!(show_value(&lines, "Nope"), None);
// The trap: `User` must not be answered by `UserName=other`.
let only_username: Vec<String> = vec!["UserName=other".into()];
assert_eq!(show_value(&only_username, "User"), None, "prefix collision must not match");
}
#[test]
fn environ_splits_on_nul_and_keeps_values_containing_equals() {
let raw = b"USER=skidsvc\0HOME=/var/lib/skidsvc\0OPTS=a=b=c\0";
let e = parse_environ(raw);
assert_eq!(e, vec!["USER=skidsvc", "HOME=/var/lib/skidsvc", "OPTS=a=b=c"]);
assert_eq!(parse_environ(b""), Vec::<String>::new());
}
#[test]
fn passwd_lookup_resolves_a_uid_and_refuses_an_unknown_one() {
let pw = "root:x:0:0:root:/root:/bin/bash\nskidsvc:x:997:997::/var/lib/skidsvc:/usr/sbin/nologin\n";
assert_eq!(passwd_name_for_uid(pw, 997).as_deref(), Some("skidsvc"));
assert_eq!(passwd_name_for_uid(pw, 0).as_deref(), Some("root"));
assert_eq!(passwd_name_for_uid(pw, 4242), None, "an unknown uid must not be guessed");
}
/// The emitted line must be ONE line and must survive a `systemctl cat` blob
/// (multi-line, quotes) — if it did not, the host's line-oriented reader would
/// see a truncated record and score a case it never actually read.
#[test]
fn the_facts_line_is_exactly_one_line_even_with_a_multiline_cat() {
let f = Facts {
case: "clean-install".into(),
exits: vec!["0 systemctl daemon-reload".into()],
show: vec!["User=skidsvc".into(), "DropInPaths=".into()],
cat: "# /etc/systemd/system/x.service\n[Service]\nUser=\"sk\"\n".into(),
proc_env: vec!["USER=skidsvc".into()],
proc_user: "skidsvc".into(),
files: vec!["/etc/systemd/system/x.service".into()],
// A journal excerpt is the realistic capture: multi-line, quoted, with an
// arrow. If any of that escaped the string, the host would read a
// truncated record and grade a case it never actually saw.
captures: vec!["journal=korp/tile-maps: 'vaduz' z12 → 41 tiles\n\"ok\"\n".into()],
at: "2026-08-03T00:00:00Z".into(),
};
let line = f.to_json_line();
assert_eq!(line.lines().count(), 1, "must be one line: {line}");
assert!(line.starts_with('{') && line.ends_with('}'));
assert!(line.contains("\\n[Service]\\nUser="), "cat newlines escaped: {line}");
assert!(line.contains("\"captures\":["), "captures ride on the wire: {line}");
assert!(line.contains("z12 \\u2192 41 tiles") || line.contains("z12 → 41 tiles"),
"a multi-line capture survives intact: {line}");
}
}