use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Backend {
MacosSandboxExec,
LinuxNamespaces,
WindowsJobObject,
PortableFloor,
}
impl Backend {
pub fn as_str(&self) -> &'static str {
match self {
Backend::MacosSandboxExec => "macos-sandbox-exec",
Backend::LinuxNamespaces => "linux-namespaces",
Backend::WindowsJobObject => "windows-job-object",
Backend::PortableFloor => "portable-floor",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Cap {
Cpu,
Memory,
Wall,
}
impl Cap {
pub fn as_str(&self) -> &'static str {
match self {
Cap::Cpu => "cpu",
Cap::Memory => "memory",
Cap::Wall => "wall",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SandboxLimits {
pub max_cpu_secs: Option<u64>,
pub max_wall_secs: Option<u64>,
pub max_memory_bytes: Option<u64>,
pub max_processes: Option<u64>,
pub max_open_files: Option<u64>,
}
impl Default for SandboxLimits {
fn default() -> Self {
Self {
max_cpu_secs: Some(60),
max_wall_secs: Some(120),
max_memory_bytes: Some(2 * 1024 * 1024 * 1024), max_processes: None,
max_open_files: Some(512),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SandboxConfig {
#[serde(default)]
pub limits: SandboxLimits,
#[serde(default)]
pub allow_network: bool,
#[serde(default)]
pub force_floor: bool,
}
impl SandboxConfig {
pub fn new() -> Self {
Self::default()
}
pub fn floor_only(mut self) -> Self {
self.force_floor = true;
self
}
}
pub struct RunSpec<'a> {
pub argv: &'a [String],
pub workdir: &'a Path,
pub limits: &'a SandboxLimits,
pub allow_network: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxOutcome {
pub backend: Backend,
pub argv: Vec<String>,
pub exit_code: Option<i32>,
pub cap_hit: Option<Cap>,
pub stdout: String,
pub stderr: String,
}
impl SandboxOutcome {
pub fn success(&self) -> bool {
self.cap_hit.is_none() && self.exit_code == Some(0)
}
}
pub trait Sandbox {
fn run(
&self,
spec: RunSpec<'_>,
) -> impl std::future::Future<Output = Result<SandboxOutcome>> + Send;
fn backend(&self) -> Backend;
}
pub enum Selected {
Floor(FloorSandbox),
#[cfg(target_os = "macos")]
Macos(macos::MacosSandbox),
#[cfg(target_os = "linux")]
Linux(linux::LinuxSandbox),
#[cfg(target_os = "windows")]
Windows(windows::WindowsSandbox),
}
impl Sandbox for Selected {
async fn run(&self, spec: RunSpec<'_>) -> Result<SandboxOutcome> {
match self {
Selected::Floor(s) => s.run(spec).await,
#[cfg(target_os = "macos")]
Selected::Macos(s) => s.run(spec).await,
#[cfg(target_os = "linux")]
Selected::Linux(s) => s.run(spec).await,
#[cfg(target_os = "windows")]
Selected::Windows(s) => s.run(spec).await,
}
}
fn backend(&self) -> Backend {
match self {
Selected::Floor(s) => s.backend(),
#[cfg(target_os = "macos")]
Selected::Macos(s) => s.backend(),
#[cfg(target_os = "linux")]
Selected::Linux(s) => s.backend(),
#[cfg(target_os = "windows")]
Selected::Windows(s) => s.backend(),
}
}
}
pub fn select(config: &SandboxConfig) -> Selected {
if !config.force_floor {
#[cfg(target_os = "macos")]
return Selected::Macos(macos::MacosSandbox);
#[cfg(target_os = "linux")]
return Selected::Linux(linux::LinuxSandbox);
#[cfg(target_os = "windows")]
return Selected::Windows(windows::WindowsSandbox);
}
Selected::Floor(FloorSandbox)
}
pub struct FloorSandbox;
impl Sandbox for FloorSandbox {
async fn run(&self, spec: RunSpec<'_>) -> Result<SandboxOutcome> {
run_capped(Backend::PortableFloor, spec, |_cmd| {}).await
}
fn backend(&self) -> Backend {
Backend::PortableFloor
}
}
async fn run_capped(
backend: Backend,
spec: RunSpec<'_>,
configure: impl FnOnce(&mut tokio::process::Command),
) -> Result<SandboxOutcome> {
use std::process::Stdio;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
let argv: Vec<String> = spec.argv.to_vec();
let mut cmd = tokio::process::Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(spec.workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if !spec.allow_network {
for k in [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] {
cmd.env_remove(k);
}
}
#[cfg(unix)]
{
let cpu = spec.limits.max_cpu_secs;
let nofile = spec.limits.max_open_files;
unsafe {
cmd.pre_exec(move || {
#[allow(clippy::unnecessary_cast)]
{
set_rlimit(libc::RLIMIT_CPU as u32, cpu)?;
set_rlimit(libc::RLIMIT_NOFILE as u32, nofile)?;
}
Ok(())
});
}
}
configure(&mut cmd);
let child = cmd.spawn().map_err(|e| crate::error::Error::Sandbox {
reason: format!("could not spawn {}: {e}", argv[0]),
})?;
let pid = child.id();
#[cfg(not(unix))]
if spec.limits.max_cpu_secs.is_some() || spec.limits.max_memory_bytes.is_some() {
static SAID: std::sync::Once = std::sync::Once::new();
SAID.call_once(|| {
tracing::warn!(
"sandbox: the CPU and memory caps are unix-only mechanisms and are NOT applied \
on this platform; only the wall-clock cap is enforced"
)
});
}
const NONE: u8 = 0;
const MEM: u8 = 1;
const WALL: u8 = 2;
let flag = Arc::new(AtomicU8::new(NONE));
#[cfg(unix)]
let mem_monitor = {
let max = spec.limits.max_memory_bytes;
let flag = Arc::clone(&flag);
match (pid, max) {
(Some(pid), Some(max)) => Some(tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(40)).await;
let Some(tree) = process_tree(pid) else {
continue;
};
if tree.is_empty() {
return; }
if tree.iter().map(|(_, rss)| rss).sum::<u64>() > max {
flag.store(MEM, Ordering::SeqCst);
for (p, _) in tree.iter().rev() {
unsafe { libc::kill(*p as libc::pid_t, libc::SIGKILL) };
}
return;
}
}
})),
_ => None,
}
};
#[cfg(not(unix))]
let mem_monitor: Option<tokio::task::JoinHandle<()>> = None;
let waiter = tokio::spawn(async move { child.wait_with_output().await });
let wall = spec.limits.max_wall_secs;
let waited = match wall {
Some(secs) => {
match tokio::time::timeout(std::time::Duration::from_secs(secs), waiter).await {
Ok(joined) => joined,
Err(_elapsed) => {
flag.store(WALL, Ordering::SeqCst);
kill_tree(pid);
if let Some(m) = mem_monitor {
m.abort();
}
return Ok(SandboxOutcome {
backend,
argv,
exit_code: None,
cap_hit: Some(Cap::Wall),
stdout: String::new(),
stderr: String::new(),
});
}
}
}
None => waiter.await,
};
let output = waited.map_err(|e| crate::error::Error::Sandbox {
reason: format!("the sandbox wait task did not finish: {e}"),
})??;
if let Some(m) = mem_monitor {
m.abort();
}
let cap_hit = match flag.load(Ordering::SeqCst) {
MEM => Some(Cap::Memory),
WALL => Some(Cap::Wall),
_ => cpu_capped(&output.status).then_some(Cap::Cpu),
};
Ok(SandboxOutcome {
backend,
argv,
exit_code: output.status.code(),
cap_hit,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
#[cfg(unix)]
fn cpu_capped(status: &std::process::ExitStatus) -> bool {
use std::os::unix::process::ExitStatusExt;
status.signal() == Some(libc::SIGXCPU)
}
#[cfg(not(unix))]
fn cpu_capped(_status: &std::process::ExitStatus) -> bool {
false
}
#[cfg(unix)]
fn set_rlimit(resource: u32, value: Option<u64>) -> std::io::Result<()> {
let Some(v) = value else { return Ok(()) };
let v = v as libc::rlim_t;
let mut lim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
unsafe {
if libc::getrlimit(resource as _, &mut lim) != 0 {
return Err(std::io::Error::last_os_error());
}
lim.rlim_cur = v.min(lim.rlim_max);
lim.rlim_max = lim.rlim_max.min(v.saturating_add(1));
if libc::setrlimit(resource as _, &lim) != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
fn kill_tree(pid: Option<u32>) {
let Some(pid) = pid else { return };
#[cfg(unix)]
{
for (p, _) in process_tree(pid).unwrap_or_default().iter().rev() {
unsafe { libc::kill(*p as libc::pid_t, libc::SIGKILL) };
}
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
}
#[cfg(windows)]
{
use std::process::Stdio;
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
#[cfg(unix)]
fn process_tree(pid: u32) -> Option<Vec<(u32, u64)>> {
let out = std::process::Command::new("ps")
.args(["-eo", "pid=,ppid=,rss="])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let rows: Vec<(u32, u32, u64)> = text
.lines()
.filter_map(|l| {
let mut f = l.split_whitespace();
let p = f.next()?.parse().ok()?;
let pp = f.next()?.parse().ok()?;
let kb = f.next()?.parse::<u64>().ok()?;
Some((p, pp, kb * 1024))
})
.collect();
if rows.is_empty() {
return None; }
let mut tree: Vec<(u32, u64)> = rows
.iter()
.filter(|(p, _, _)| *p == pid)
.map(|(p, _, rss)| (*p, *rss))
.collect();
let mut i = 0;
while i < tree.len() {
let parent = tree[i].0;
for (p, pp, rss) in &rows {
if *pp == parent && *p != parent && !tree.iter().any(|(t, _)| t == p) {
tree.push((*p, *rss));
}
}
i += 1;
}
Some(tree)
}
pub fn workdir() -> Result<tempfile::TempDir> {
Ok(tempfile::tempdir()?)
}
pub async fn copy_back(
workdir: &Path,
dest_root: &Path,
files: &[PathBuf],
allowed: impl Fn(&Path) -> bool,
) -> Result<Vec<PathBuf>> {
let mut copied = Vec::new();
for rel in files {
if !allowed(rel) {
continue;
}
let src = workdir.join(rel);
if !src.exists() {
continue;
}
let dest = dest_root.join(rel);
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::copy(&src, &dest).await?;
copied.push(rel.clone());
}
Ok(copied)
}
pub mod linux;
pub mod macos;
pub mod windows;
#[cfg(test)]
mod tests {
use super::*;
fn spec<'a>(argv: &'a [String], dir: &'a Path, limits: &'a SandboxLimits) -> RunSpec<'a> {
RunSpec {
argv,
workdir: dir,
limits,
allow_network: false,
}
}
#[tokio::test]
async fn floor_runs_a_command_and_captures_output() {
let dir = tempfile::tempdir().unwrap();
let argv = vec!["sh".into(), "-c".into(), "echo hello; exit 0".into()];
let out = FloorSandbox
.run(spec(&argv, dir.path(), &SandboxLimits::default()))
.await
.unwrap();
assert!(out.success());
assert_eq!(out.backend, Backend::PortableFloor);
assert!(out.stdout.contains("hello"));
assert_eq!(out.exit_code, Some(0));
}
#[tokio::test]
async fn floor_reports_a_nonzero_exit() {
let dir = tempfile::tempdir().unwrap();
let argv = vec!["sh".into(), "-c".into(), "exit 3".into()];
let out = FloorSandbox
.run(spec(&argv, dir.path(), &SandboxLimits::default()))
.await
.unwrap();
assert!(!out.success());
assert_eq!(out.exit_code, Some(3));
assert_eq!(out.cap_hit, None);
}
#[tokio::test]
async fn force_floor_selects_the_portable_backend() {
let sb = select(&SandboxConfig::new().floor_only());
assert_eq!(sb.backend(), Backend::PortableFloor);
}
#[test]
fn config_and_limits_round_trip_through_serde() {
let cfg = SandboxConfig::new();
let json = serde_json::to_string(&cfg).unwrap();
let back: SandboxConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, back);
}
#[cfg(unix)]
#[tokio::test]
async fn cpu_cap_kills_a_busy_loop_and_names_the_cpu_cap() {
let dir = tempfile::tempdir().unwrap();
let argv = vec!["sh".into(), "-c".into(), "while :; do :; done".into()];
let limits = SandboxLimits {
max_cpu_secs: Some(1),
max_wall_secs: Some(30), ..SandboxLimits::default()
};
let out = FloorSandbox
.run(spec(&argv, dir.path(), &limits))
.await
.unwrap();
assert_eq!(out.cap_hit, Some(Cap::Cpu), "expected CPU cap, got {out:?}");
assert!(!out.success());
}
#[cfg(unix)]
#[tokio::test]
async fn memory_cap_kills_a_heap_hog_and_names_the_memory_cap() {
let dir = tempfile::tempdir().unwrap();
let argv = vec![
"sh".into(),
"-c".into(),
"perl -e '$x=\"a\"x(400*1024*1024); sleep 5'".into(),
];
let limits = SandboxLimits {
max_memory_bytes: Some(64 * 1024 * 1024), max_wall_secs: Some(30),
..SandboxLimits::default()
};
let out = FloorSandbox
.run(spec(&argv, dir.path(), &limits))
.await
.unwrap();
assert_eq!(
out.cap_hit,
Some(Cap::Memory),
"expected memory cap, got {out:?}"
);
assert!(!out.success());
}
#[cfg(unix)]
#[tokio::test]
async fn memory_cap_kills_a_hog_the_shell_forked() {
let dir = tempfile::tempdir().unwrap();
let argv = vec![
"sh".into(),
"-c".into(),
"perl -e '$x=\"a\"x(400*1024*1024); sleep 5' & wait".into(),
];
let limits = SandboxLimits {
max_memory_bytes: Some(64 * 1024 * 1024), max_wall_secs: Some(30),
..SandboxLimits::default()
};
let out = FloorSandbox
.run(spec(&argv, dir.path(), &limits))
.await
.unwrap();
assert_eq!(
out.cap_hit,
Some(Cap::Memory),
"a forked hog must still hit the memory cap, got {out:?}"
);
assert!(!out.success());
}
#[cfg(unix)]
#[tokio::test]
async fn the_wall_clock_kill_reaches_the_children_the_run_forked() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("survived");
let argv = vec![
"sh".into(),
"-c".into(),
"(sleep 4; touch survived) & wait".into(),
];
let limits = SandboxLimits {
max_wall_secs: Some(1),
max_cpu_secs: None,
..SandboxLimits::default()
};
let out = FloorSandbox
.run(spec(&argv, dir.path(), &limits))
.await
.unwrap();
assert_eq!(
out.cap_hit,
Some(Cap::Wall),
"wall must kill it, got {out:?}"
);
assert!(!out.success());
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
assert!(
!marker.exists(),
"a forked child must not outlive the wall-clock kill"
);
}
#[cfg(windows)]
#[tokio::test]
async fn windows_enforces_the_wall_clock_and_claims_no_cap_it_did_not_apply() {
let dir = tempfile::tempdir().unwrap();
let argv: Vec<String> = [
"cmd", "/C", "for", "/L", "%i", "in", "(1,0,2)", "do", "@rem",
]
.iter()
.map(|s| s.to_string())
.collect();
let limits = SandboxLimits {
max_cpu_secs: Some(1),
max_memory_bytes: Some(1024 * 1024),
max_wall_secs: Some(5),
..SandboxLimits::default()
};
let out = FloorSandbox
.run(spec(&argv, dir.path(), &limits))
.await
.unwrap();
assert_eq!(
out.cap_hit,
Some(Cap::Wall),
"the wall clock is the only cap Windows applies, got {out:?}"
);
assert!(!out.success());
}
#[cfg(unix)]
#[tokio::test]
async fn a_capped_run_keeps_its_hard_limit_above_the_soft_one() {
let dir = tempfile::tempdir().unwrap();
let argv = vec![
"sh".into(),
"-c".into(),
"ulimit -S -n; ulimit -H -n".into(),
];
let limits = SandboxLimits {
max_open_files: Some(64),
..SandboxLimits::default()
};
let out = FloorSandbox
.run(spec(&argv, dir.path(), &limits))
.await
.unwrap();
let seen: Vec<u64> = out
.stdout
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
assert_eq!(seen.len(), 2, "expected soft and hard, got {out:?}");
assert_eq!(seen[0], 64, "soft limit must be what was asked for");
assert!(
seen[1] > seen[0],
"hard limit must stay above the soft one, got {out:?}"
);
}
#[tokio::test]
async fn workdir_is_removed_on_drop() {
let path = {
let wd = workdir().unwrap();
let p = wd.path().to_path_buf();
assert!(p.exists());
p
};
assert!(!path.exists(), "sandbox workdir must be gone after drop");
}
#[tokio::test]
async fn copy_back_honours_the_write_policy() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
tokio::fs::write(src.path().join("keep.txt"), "y")
.await
.unwrap();
tokio::fs::write(src.path().join("secret.txt"), "n")
.await
.unwrap();
let files = vec![PathBuf::from("keep.txt"), PathBuf::from("secret.txt")];
let copied = copy_back(src.path(), dst.path(), &files, |p| {
p != Path::new("secret.txt")
})
.await
.unwrap();
assert_eq!(copied, vec![PathBuf::from("keep.txt")]);
assert!(dst.path().join("keep.txt").exists());
assert!(
!dst.path().join("secret.txt").exists(),
"denied file must not be copied back"
);
}
}