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 || {
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))]
let _ = pid;
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;
match rss_bytes(pid) {
Some(rss) if rss > max => {
flag.store(MEM, Ordering::SeqCst);
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
return;
}
Some(_) => {}
None => return, }
}
})),
_ => None,
}
};
#[cfg(not(unix))]
let mem_monitor: Option<tokio::task::JoinHandle<()>> = None;
let wall = spec.limits.max_wall_secs;
let output = match wall {
Some(secs) => {
match tokio::time::timeout(
std::time::Duration::from_secs(secs),
child.wait_with_output(),
)
.await
{
Ok(res) => res?,
Err(_elapsed) => {
flag.store(WALL, Ordering::SeqCst);
#[cfg(unix)]
if let Some(pid) = pid {
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
}
return Ok(SandboxOutcome {
backend,
argv,
exit_code: None,
cap_hit: Some(Cap::Wall),
stdout: String::new(),
stderr: String::new(),
});
}
}
}
None => child.wait_with_output().await?,
};
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>) {
if let Some(v) = value {
let lim = libc::rlimit {
rlim_cur: v as libc::rlim_t,
rlim_max: v as libc::rlim_t,
};
unsafe {
libc::setrlimit(resource as _, &lim);
}
}
}
#[cfg(unix)]
fn rss_bytes(pid: u32) -> Option<u64> {
let out = std::process::Command::new("ps")
.args(["-o", "rss=", "-p", &pid.to_string()])
.output()
.ok()?;
let kb: u64 = String::from_utf8_lossy(&out.stdout).trim().parse().ok()?;
Some(kb * 1024)
}
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 macos;
pub mod linux;
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);
}
#[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());
}
#[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());
}
#[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");
}
}