use crate::common::*;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
fn orphan_check(envs: &[(&str, &str)], tag: &str, graceless: bool) {
if !have_landlock() {
eprintln!("SKIP: no tier");
return;
}
let marker = format!("vetto-orphan-{}-{}", tag, std::process::id());
let proj = TempProject::new(tag);
let mut child = Command::new(vetto_bin())
.args([
"--tui=none",
"--",
"sh",
"-c",
&format!("sleep 30 # {marker}"),
])
.current_dir(proj.path())
.env("HOME", test_home())
.envs(envs.iter().copied())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn vetto");
std::thread::sleep(Duration::from_millis(1500));
assert!(child.try_wait().unwrap().is_none(), "vetto exited early");
if graceless {
child.kill().expect("kill vetto");
} else {
kill_term(child.id());
}
let _ = child.wait();
std::thread::sleep(Duration::from_millis(1500));
let pgrep = Command::new("pgrep")
.args(["-f", &marker])
.output()
.expect("pgrep");
assert!(
pgrep.stdout.is_empty(),
"orphans survived vetto death ({}): {}",
tag,
String::from_utf8_lossy(&pgrep.stdout)
);
}
fn kill_term(pid: u32) {
let r = Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status()
.expect("kill -TERM");
assert!(r.success(), "kill -TERM failed");
}
#[test]
fn no_orphans_full_tier_sigkill() {
if detected_tier().as_deref() != Some("full") {
eprintln!("SKIP: FULL tier unavailable");
return;
}
orphan_check(&[], "full", true);
}
#[test]
fn no_orphans_fs_only_tier_graceful() {
let tier = detected_tier();
if tier.is_none() {
eprintln!("SKIP: no enforcement tier");
return;
}
let forced = [("VETTO_FORCE_TIER", "fs-only")];
let envs = if tier.as_deref() == Some("full") {
forced.as_slice()
} else {
&[]
};
orphan_check(envs, "fsonly", false);
}
#[test]
fn no_fs_only_orphan_setsid_grandchild() {
if !have_landlock() {
eprintln!("SKIP: no tier");
return;
}
let proj = TempProject::new("orphan-setsid");
let out = run_vetto_env_in(
proj.path(),
&[
"--tui=none",
"--",
"sh",
"-c",
"setsid sleep 9999 >/dev/null 2>&1 & sleep 0.1; echo go",
],
&[("VETTO_FORCE_TIER", "fs-only")],
);
assert!(
!out.status.success(),
"setsid grandchild in FS-ONLY must not succeed; stdout: {} stderr: {}",
stdout(&out),
stderr(&out)
);
assert_eq!(
out.status.code(),
Some(125),
"FS-ONLY setsid escape must report fail-closed exit 125; stderr: {}",
stderr(&out)
);
let deadline = Instant::now() + Duration::from_secs(6);
loop {
let leaks = scan_cmdlines("sleep 9999");
if leaks.is_empty() {
break;
}
if Instant::now() >= deadline {
panic!(
"fs-only setsid grandchild survived vetto teardown: {}",
leaks.join("; ")
);
}
std::thread::sleep(Duration::from_millis(200));
}
}
#[test]
fn fs_only_blind_sweep_fails_closed() {
if !have_landlock() {
eprintln!("SKIP: no tier");
return;
}
let proj = TempProject::new("blind-sweep");
let out = run_vetto_env_in(
proj.path(),
&[
"--tui=none",
"--",
"sh",
"-c",
"setsid env -i sleep 9998 >/dev/null 2>&1 & sleep 0.1; echo go",
],
&[("VETTO_FORCE_TIER", "fs-only")],
);
assert_eq!(
out.status.code(),
Some(125),
"blind sweep with stripped environment must fail-closed with exit 125; stdout: {}, stderr: {}",
stdout(&out),
stderr(&out)
);
let deadline = Instant::now() + Duration::from_secs(4);
loop {
let leaks = scan_cmdlines("sleep 9998");
if leaks.is_empty() {
break;
}
if Instant::now() >= deadline {
for leak in &leaks {
if let Some(pid_str) = leak.split(':').next() {
if let Ok(pid) = pid_str.trim().parse::<i32>() {
unsafe { libc::kill(pid, libc::SIGKILL) };
}
}
}
break;
}
std::thread::sleep(Duration::from_millis(100));
}
}
#[test]
fn zombie_reaping_under_parallel_fork_storm() {
if !have_landlock() {
eprintln!("SKIP: no tier");
return;
}
let proj = TempProject::new("fork-storm-reap");
let script = r#"
for i in $(seq 1 50); do
(exit 0) &
done
sleep 0.2
"#;
let out = run_vetto_in(proj.path(), &["--tui=none", "--", "sh", "-c", script]);
assert!(
out.status.success(),
"fork storm execution failed; stdout: {}, stderr: {}",
stdout(&out),
stderr(&out)
);
assert_eq!(
out.status.code(),
Some(0),
"expected exit 0 for reaped fork storm; stderr: {}",
stderr(&out)
);
std::thread::sleep(Duration::from_millis(300));
let residual_zombies = scan_zombie_pids();
assert!(
residual_zombies.is_empty(),
"unreaped zombies lingered after session completion: {:?}",
residual_zombies
);
}
#[test]
fn supervisor_death_pdeathsig_linux_fs_only() {
let tier = detected_tier();
if tier.is_none() {
eprintln!("SKIP: no enforcement tier");
return;
}
let forced = [("VETTO_FORCE_TIER", "fs-only")];
let envs = if tier.as_deref() == Some("full") {
forced.as_slice()
} else {
&[]
};
orphan_check(envs, "fsonly-pdeathsig", true);
}
fn scan_cmdlines(needle: &str) -> Vec<String> {
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return found;
};
for entry in entries.flatten() {
let Ok(cmdline) = std::fs::read_to_string(entry.path().join("cmdline")) else {
continue; };
let joined = cmdline.replace('\0', " ");
if joined.contains(needle) {
found.push(format!(
"{}: {}",
entry.file_name().to_string_lossy(),
joined.trim()
));
}
}
found
}
fn scan_zombie_pids() -> Vec<String> {
let mut zombies = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return zombies;
};
let me_uid = unsafe { libc::geteuid() };
for entry in entries.flatten() {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if name.parse::<i32>().is_err() {
continue;
}
let Ok(status) = std::fs::read_to_string(entry.path().join("status")) else {
continue;
};
let is_my_uid = status.lines().any(|l| {
l.starts_with("Uid:") && l.split_whitespace().nth(1) == Some(&me_uid.to_string())
});
if !is_my_uid {
continue;
}
if status
.lines()
.any(|l| l.starts_with("State:") && l.contains('Z'))
{
zombies.push(format!("{}: {}", name, entry.path().display()));
}
}
zombies
}