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

nordisk/Skidbladnir

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