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