Files
openhuman/tests/cwd_jail_e2e.rs
T

275 lines
9.3 KiB
Rust

//! End-to-end tests for `openhuman::cwd_jail`.
//!
//! Each test goes through the public surface only — `Jail`, `spawn`,
//! `JailRegistry`, `default_backend` — and (where the platform allows it)
//! actually exercises the OS sandbox by trying to do something it should
//! be blocked from doing.
//!
//! Platform breakdown:
//! - **Common** (all OSes): registry CRUD + spawn via `NoopBackend`, jail
//! builder semantics. Runs in every CI matrix slot.
//! - **Linux**: `target_os = "linux"` gate exercises Landlock by spawning
//! `/bin/sh` and trying to write outside the jail.
//! - **macOS**: same shape, exercises Seatbelt via `/usr/bin/touch`.
//! - **Windows**: AppContainer integration is marked `#[ignore]` until
//! the raw-`HANDLE` → `Child` bridge lands (see TODO in
//! `src/openhuman/cwd_jail/windows.rs`).
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use openhuman_core::openhuman::cwd_jail::{
default_backend, spawn, spawn_with, Jail, JailRegistry, NoopBackend,
};
fn unique_tempdir(tag: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!(
"openhuman-e2e-{}-{}-{}",
tag,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
fs::create_dir_all(&p).unwrap();
p
}
// ── Common: runs on every platform ──────────────────────────────────
#[test]
fn registry_full_lifecycle_with_noop() {
let base = unique_tempdir("lifecycle");
let reg = JailRegistry::open(&base).unwrap();
// Create several jails in parallel.
let a = reg.create("agent-a").unwrap();
let b = reg.create("agent-b").unwrap();
let c = reg.create("agent-c").unwrap();
assert_eq!(reg.list().len(), 3);
// Rename + notes update timestamps.
let renamed = reg.rename(&a.id, "agent-a-renamed").unwrap();
assert_eq!(renamed.label, "agent-a-renamed");
let noted = reg
.set_notes(&a.id, Some("owner=stevent95".into()))
.unwrap();
assert_eq!(noted.notes.as_deref(), Some("owner=stevent95"));
// Spawn through the registry into one of the jails.
let mut cmd = noop_exit_zero_cmd();
cmd.stdout(Stdio::null()).stderr(Stdio::null());
let mut child = reg.spawn_in_with(&b.id, &NoopBackend, cmd).unwrap();
let status = child.wait().unwrap();
assert!(status.success() || cfg!(windows));
// Delete one, clear the rest.
reg.delete(&c.id).unwrap();
assert!(reg.get(&c.id).is_none());
let cleared = reg.clear().unwrap();
assert_eq!(cleared, 2);
assert!(reg.list().is_empty());
// Reopen — empty index round-trips.
drop(reg);
let reg2 = JailRegistry::open(&base).unwrap();
assert!(reg2.list().is_empty());
fs::remove_dir_all(&base).ok();
}
#[test]
fn jail_canonicalize_rejects_missing_root() {
let jail = Jail::new("/does/not/exist/at/all", "missing");
let err = spawn_with(&NoopBackend, &jail, noop_exit_zero_cmd()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn default_backend_is_named_and_available() {
let b = default_backend();
assert!(!b.name().is_empty());
// On every supported platform the auto-detected backend should be
// available — even noop returns true.
assert!(b.is_available());
}
#[test]
fn jail_builder_carries_intent_through_clone() {
// `spawn` clones the jail before canonicalize; verify a chained
// builder still produces the right shape.
let dir = unique_tempdir("builder");
let j = Jail::new(&dir, "build")
.add_read_only("/usr/lib")
.add_read_only("/usr/share")
.deny_net()
.deny_subprocess();
assert_eq!(j.read_only.len(), 2);
assert!(!j.allow_net);
assert!(!j.allow_subprocess);
fs::remove_dir_all(&dir).ok();
}
// ── Linux: Landlock real-sandbox enforcement ────────────────────────
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
#[test]
fn linux_landlock_blocks_write_outside_root() {
let root = unique_tempdir("ll-root");
let outside = unique_tempdir("ll-outside");
let outside_target = outside.join("forbidden.txt");
let jail = Jail::new(&root, "e2e.landlock");
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c")
.arg(format!("echo hi > {}", outside_target.display()))
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = spawn(&jail, cmd).expect("spawn under landlock");
let _ = child.wait().expect("wait");
assert!(
!outside_target.exists(),
"Landlock failed to block write to {}",
outside_target.display()
);
fs::remove_dir_all(&root).ok();
fs::remove_dir_all(&outside).ok();
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
#[test]
fn linux_landlock_allows_write_inside_root() {
let root = unique_tempdir("ll-root-write");
let inside = root.join("ok.txt");
let jail = Jail::new(&root, "e2e.landlock.ok");
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c")
.arg(format!("echo hi > {}", inside.display()))
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = spawn(&jail, cmd).expect("spawn under landlock");
let status = child.wait().expect("wait");
assert!(status.success(), "write inside root should succeed");
assert!(inside.exists());
fs::remove_dir_all(&root).ok();
}
// ── macOS: Seatbelt real-sandbox enforcement ────────────────────────
#[cfg(target_os = "macos")]
#[test]
fn macos_seatbelt_blocks_write_outside_root() {
if !PathBuf::from("/usr/bin/sandbox-exec").exists() {
return;
}
let root = unique_tempdir("sb-root");
let outside =
std::env::temp_dir().join(format!("openhuman-e2e-sb-forbidden-{}", std::process::id()));
let _ = fs::remove_file(&outside);
let jail = Jail::new(&root, "e2e.seatbelt");
let mut cmd = Command::new("/usr/bin/touch");
cmd.arg(&outside)
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = spawn(&jail, cmd).expect("spawn under seatbelt");
let _ = child.wait().expect("wait");
assert!(
!outside.exists(),
"Seatbelt failed to block write to {}",
outside.display()
);
fs::remove_dir_all(&root).ok();
}
#[cfg(target_os = "macos")]
#[test]
fn macos_seatbelt_allows_write_inside_root() {
if !PathBuf::from("/usr/bin/sandbox-exec").exists() {
return;
}
let root = unique_tempdir("sb-root-ok");
let inside = root.join("ok.txt");
let jail = Jail::new(&root, "e2e.seatbelt.ok");
let mut cmd = Command::new("/usr/bin/touch");
cmd.arg(&inside).stdout(Stdio::null()).stderr(Stdio::null());
let mut child = spawn(&jail, cmd).expect("spawn under seatbelt");
let status = child.wait().expect("wait");
assert!(status.success(), "writing inside root should succeed");
assert!(inside.exists());
fs::remove_dir_all(&root).ok();
}
#[cfg(target_os = "macos")]
#[test]
fn macos_seatbelt_blocks_network_when_denied() {
if !PathBuf::from("/usr/bin/sandbox-exec").exists() {
return;
}
// `nc -z 1.1.1.1 80` is a simple connect probe. Under deny_net the
// sandbox should refuse the socket; under allow_net (default) it may
// succeed *or* fail depending on environment, so we only assert the
// deny side here.
let root = unique_tempdir("sb-net");
let jail = Jail::new(&root, "e2e.seatbelt.nonet").deny_net();
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c")
.arg("/usr/bin/nc -z -w 1 1.1.1.1 80 2>/dev/null && echo OPEN")
.stdout(Stdio::piped())
.stderr(Stdio::null());
let child = spawn(&jail, cmd).expect("spawn under seatbelt");
let out = child.wait_with_output().expect("wait");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("OPEN"),
"Seatbelt failed to block network: stdout={stdout:?}"
);
fs::remove_dir_all(&root).ok();
}
// ── Windows: AppContainer (gated until HANDLE→Child bridge lands) ───
#[cfg(target_os = "windows")]
#[test]
#[ignore = "AppContainer spawn returns Unsupported pending Child handle bridge; see windows.rs TODO"]
fn windows_appcontainer_blocks_write_outside_root() {
let root = unique_tempdir("ac-root");
let outside = std::env::temp_dir().join(format!(
"openhuman-e2e-ac-forbidden-{}.txt",
std::process::id()
));
let _ = fs::remove_file(&outside);
let jail = Jail::new(&root, "e2e.appcontainer");
let mut cmd = Command::new("cmd");
cmd.args(["/C", &format!("echo hi > \"{}\"", outside.display())]);
// Once the Child bridge is implemented, flip this from `unwrap_err`
// to `wait` + assert(!outside.exists()).
let err = spawn(&jail, cmd).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
fs::remove_dir_all(&root).ok();
}
// ── Helpers ─────────────────────────────────────────────────────────
fn noop_exit_zero_cmd() -> Command {
if cfg!(windows) {
let mut c = Command::new("cmd");
c.args(["/C", "exit"]);
c
} else {
Command::new("true")
}
}