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

nordisk/Skidbladnir

tests/env_file_live.rs

//! **LIVE proof that a neutral `ServiceSpec` with secrets installs, runs WITH its
//! secret, and uninstalls without leaving the secret behind** (stinky #1/#3/#4/#24) —
//! and that every value reaches the process byte-exact through the env file (#9),
//! `Environment=` and the operator block (#6), and `ExecStart=` (#8).
//!
//! The pure tests pin the shapes; only a real `systemctl --user` manager proves that
//! the `EnvironmentFile=` the unit names is the file the install wrote — the user-scope
//! path mismatch was green in every pure test for exactly that reason.
//!
//! `#[ignore]`: needs a user session bus and writes `~/.config/systemd/user`. Run with
//!
//! ```bash
//! cargo test --test env_file_live -- --ignored --nocapture
//! ```

#![cfg(all(unix, target_os = "linux"))]

use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::time::{Duration, Instant};

use skidbladnir::service::{Lifecycle, RestartPolicy, ServiceSpec};
use skidbladnir::systemd::{Scope, Systemd};

const NAME: &str = "skid-envfile-selftest";

/// A value that exercises every quoting rule at once: leading/trailing whitespace, both
/// quotes, backslashes, `$VAR`, `%` specifiers, a backtick, a tab, a newline that would
/// start a new assignment, and a trailing backslash that would continue the line.
const SECRET: &str = " s3 \"cr'et $HOME %h %% `x`\tq\nINJECTED=1 \\ end\\";

fn scratch_root() -> PathBuf {
    // Not /tmp: the exec must look like an installed program, and the scratch must be
    // the caller's own.
    let home = PathBuf::from(std::env::var("HOME").expect("HOME"));
    let base = home.join(".cache");
    std::fs::create_dir_all(&base).expect("create ~/.cache");
    base
}

fn copied_shell(dir: &std::path::Path) -> PathBuf {
    let bin = dir.join("bin");
    std::fs::create_dir_all(&bin).unwrap();
    let sh = bin.join("sh");
    std::fs::copy(std::fs::canonicalize("/bin/sh").unwrap(), &sh).unwrap();
    sh
}

fn main_pid(unit: &str) -> u32 {
    let sd = Systemd::connect_scoped(Scope::User).expect("user bus");
    sd.await_job_settled(unit, Duration::from_secs(30));
    let deadline = Instant::now() + Duration::from_secs(10);
    loop {
        if let Ok(Some(p)) = sd.main_pid(unit) {
            if p != 0 {
                return p;
            }
        }
        assert!(Instant::now() < deadline, "{unit} never reported a MainPID");
        std::thread::sleep(Duration::from_millis(100));
    }
}

fn nul_fields(path: String) -> Vec<Vec<u8>> {
    std::fs::read(&path)
        .unwrap_or_else(|e| panic!("read {path}: {e}"))
        .split(|b| *b == 0)
        .map(<[u8]>::to_vec)
        .collect()
}

#[test]
#[ignore = "live: drives a real systemctl --user unit; needs a session bus"]
fn a_lifecycle_with_secrets_installs_runs_with_them_and_uninstalls_them() {
    assert!(
        std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some(),
        "this live test needs a user session bus (DBUS_SESSION_BUS_ADDRESS)"
    );

    let dir = tempfile::Builder::new()
        .prefix("skid-envfile-live")
        .tempdir_in(scratch_root())
        .expect("scratch dir");
    let sh = copied_shell(dir.path());
    // The shell stays the main process (no `exec`), so the install's running-binary
    // verification sees the binary it was pointed at. The environment is read back out
    // of `/proc/<MainPID>/environ` — nothing about the secret passes through ExecStart.
    let script = "while :; do sleep 1; done".to_string();
    let spec = ServiceSpec::new(NAME, "skidbladnir env-file self test", sh.display().to_string())
        .arg("-c")
        .arg(script)
        .scope(Scope::User)
        .working_dir(dir.path().display().to_string())
        .restart(RestartPolicy::No)
        .secret_env("SKID_LIVE_SECRET", SECRET);
    let life = Lifecycle::new(spec);

    let unit_dir = PathBuf::from(std::env::var("HOME").unwrap()).join(".config/systemd/user");
    let env_path = unit_dir.join(format!("{NAME}.env"));
    let unit_path = unit_dir.join(format!("{NAME}.service"));

    let installed = life.install();
    let result = std::panic::catch_unwind(|| {
        installed.expect("Lifecycle::install with secret_env");
        let mode = std::fs::metadata(&env_path)
            .expect("the env file the unit names exists")
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600, "env file must be private");
        let unit = std::fs::read_to_string(&unit_path).unwrap();
        assert!(!unit.contains(SECRET), "secret leaked into the unit:\n{unit}");

        let pid = main_pid(&format!("{NAME}.service"));
        let environ = std::fs::read(format!("/proc/{pid}/environ")).expect("read environ");
        let want = format!("SKID_LIVE_SECRET={SECRET}");
        assert!(
            environ
                .split(|b| *b == 0)
                .any(|kv| kv == want.as_bytes()),
            "the service must see the secret byte-exact; environ: {:?}",
            String::from_utf8_lossy(&environ)
        );
    });

    let report = life.uninstall().expect("uninstall");
    if let Err(e) = result {
        std::panic::resume_unwind(e);
    }
    assert!(!unit_path.exists(), "unit removed");
    assert!(!env_path.exists(), "the secret must not outlive the service");
    assert_eq!(report.env_file_removed.as_deref(), Some(env_path.as_path()));
}

/// `Environment=` (declared AND operator block) and `ExecStart=` carry the same tricky
/// value byte-exact — no env file involved, so nothing masks the unit's own quoting.
#[test]
#[ignore = "live: drives a real systemctl --user unit; needs a session bus"]
fn unit_env_operator_env_and_args_reach_the_process_byte_exact() {
    assert!(
        std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some(),
        "this live test needs a user session bus (DBUS_SESSION_BUS_ADDRESS)"
    );
    const NAME2: &str = "skid-unitquote-selftest";
    let dir = tempfile::Builder::new()
        .prefix("skid-unitquote-live")
        .tempdir_in(scratch_root())
        .expect("scratch dir");
    let sh = copied_shell(dir.path());
    // `sh -c SCRIPT ARG0 ARG1`: the tricky value rides as ARG1 and is read back out of
    // /proc/<pid>/cmdline.
    let spec = ServiceSpec::new(NAME2, "skidbladnir unit quoting self test", sh.display().to_string())
        .arg("-c")
        .arg("while :; do sleep 1; done")
        .arg("argv0")
        .arg(SECRET)
        .arg("%s")
        .scope(Scope::User)
        .working_dir(dir.path().display().to_string())
        .restart(RestartPolicy::No)
        .env("SKID_LIVE_PUBLIC", SECRET)
        .operator_env("SKID_LIVE_OPERATOR", SECRET);
    let life = Lifecycle::new(spec);
    let installed = life.install();
    let result = std::panic::catch_unwind(|| {
        installed.expect("Lifecycle::install");
        let pid = main_pid(&format!("{NAME2}.service"));
        let environ = nul_fields(format!("/proc/{pid}/environ"));
        for key in ["SKID_LIVE_PUBLIC", "SKID_LIVE_OPERATOR"] {
            let want = format!("{key}={SECRET}");
            assert!(
                environ.iter().any(|kv| kv == want.as_bytes()),
                "{key} must arrive byte-exact; environ: {:?}",
                environ.iter().map(|f| String::from_utf8_lossy(f)).collect::<Vec<_>>()
            );
        }
        assert!(
            !environ.iter().any(|kv| kv.starts_with(b"INJECTED=")),
            "a newline in a value injected a variable"
        );
        let argv = nul_fields(format!("/proc/{pid}/cmdline"));
        assert_eq!(argv.get(4).map(Vec::as_slice), Some(SECRET.as_bytes()), "{argv:?}");
        assert_eq!(argv.get(5).map(Vec::as_slice), Some(&b"%s"[..]), "{argv:?}");
    });
    life.uninstall().expect("uninstall");
    if let Err(e) = result {
        std::panic::resume_unwind(e);
    }
}