use std::{
collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString},
path::{Path, PathBuf},
process::{ExitStatus, Output, Stdio},
sync::{
OnceLock,
atomic::{AtomicUsize, Ordering},
},
};
use thiserror::Error;
use tokio::{
io::AsyncReadExt,
process::{Child, ChildStderr, ChildStdin, ChildStdout, Command},
};
#[derive(Debug, Error)]
pub enum SandboxCommandError {
#[error("sandbox initialization failed: {0}")]
Initialization(String),
#[error("sandbox command failed to start: {0}")]
Spawn(#[source] std::io::Error),
#[error("sandbox command path is invalid: {0}")]
InvalidPath(String),
}
struct PreparedCommand {
command: Command,
cleanup: Option<PathBuf>,
}
pub struct SandboxChild {
child: Child,
pub stdin: Option<ChildStdin>,
pub stdout: Option<ChildStdout>,
pub stderr: Option<ChildStderr>,
cleanup: Option<PathBuf>,
}
impl SandboxChild {
pub fn id(&self) -> Option<u32> {
self.child.id()
}
pub fn take_stdin(&mut self) -> Option<ChildStdin> {
self.stdin.take()
}
pub fn take_stdout(&mut self) -> Option<ChildStdout> {
self.stdout.take()
}
pub fn take_stderr(&mut self) -> Option<ChildStderr> {
self.stderr.take()
}
pub async fn kill(&mut self) -> std::io::Result<()> {
self.child.kill().await
}
pub fn start_kill(&mut self) -> std::io::Result<()> {
self.child.start_kill()
}
pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
let result = self.child.wait().await;
if result.is_ok() {
self.clean_up();
}
result
}
pub fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
let result = self.child.try_wait();
if matches!(result, Ok(Some(_))) {
self.clean_up();
}
result
}
pub async fn wait_with_output(mut self) -> std::io::Result<Output> {
let _ = self.stdin.take();
let mut stdout = self.stdout.take();
let mut stderr = self.stderr.take();
let stdout_read = async {
let mut output = Vec::new();
if let Some(stdout) = stdout.as_mut() {
stdout.read_to_end(&mut output).await?;
}
Ok::<_, std::io::Error>(output)
};
let stderr_read = async {
let mut output = Vec::new();
if let Some(stderr) = stderr.as_mut() {
stderr.read_to_end(&mut output).await?;
}
Ok::<_, std::io::Error>(output)
};
let (stdout, stderr) = tokio::try_join!(stdout_read, stderr_read)?;
let status = self.wait().await?;
Ok(Output {
status,
stdout,
stderr,
})
}
fn clean_up(&mut self) {
remove_profile(self.cleanup.take());
}
}
impl Drop for SandboxChild {
fn drop(&mut self) {
self.clean_up();
}
}
pub struct SandboxCommand {
program: OsString,
args: Vec<OsString>,
env: BTreeMap<OsString, OsString>,
removed_env: BTreeSet<OsString>,
clear_env: bool,
current_dir: Option<PathBuf>,
stdin: Option<Stdio>,
stdout: Option<Stdio>,
stderr: Option<Stdio>,
kill_on_drop: bool,
rw_binds: Vec<PathBuf>,
ro_binds: Vec<PathBuf>,
}
impl SandboxCommand {
pub fn initialize() -> Result<(), SandboxCommandError> {
platform().map(|_| ())
}
pub fn new(program: impl AsRef<OsStr>) -> Self {
Self {
program: program.as_ref().to_owned(),
args: Vec::new(),
env: BTreeMap::new(),
removed_env: BTreeSet::new(),
clear_env: false,
current_dir: None,
stdin: None,
stdout: None,
stderr: None,
kill_on_drop: false,
rw_binds: Vec::new(),
ro_binds: Vec::new(),
}
}
pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
self.args.push(arg.as_ref().to_owned());
self
}
pub fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args
.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
self
}
pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
let key = key.as_ref().to_owned();
self.removed_env.remove(&key);
self.env.insert(key, value.as_ref().to_owned());
self
}
pub fn envs<I, K, V>(&mut self, envs: I) -> &mut Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
for (key, value) in envs {
self.env(key, value);
}
self
}
pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
let key = key.as_ref().to_owned();
self.env.remove(&key);
self.removed_env.insert(key);
self
}
pub fn env_clear(&mut self) -> &mut Self {
self.clear_env = true;
self.env.clear();
self.removed_env.clear();
self
}
pub fn current_dir(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.current_dir = Some(path.into());
self
}
pub fn stdin(&mut self, cfg: Stdio) -> &mut Self {
self.stdin = Some(cfg);
self
}
pub fn stdout(&mut self, cfg: Stdio) -> &mut Self {
self.stdout = Some(cfg);
self
}
pub fn stderr(&mut self, cfg: Stdio) -> &mut Self {
self.stderr = Some(cfg);
self
}
pub fn kill_on_drop(&mut self, kill: bool) -> &mut Self {
self.kill_on_drop = kill;
self
}
pub fn bind_rw(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.rw_binds.push(path.into());
self
}
pub fn bind_ro(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.ro_binds.push(path.into());
self
}
pub fn spawn(&mut self) -> Result<SandboxChild, SandboxCommandError> {
let cwd = self
.current_dir
.clone()
.unwrap_or(std::env::current_dir().map_err(SandboxCommandError::Spawn)?);
let cwd = cwd.canonicalize().map_err(|error| {
SandboxCommandError::InvalidPath(format!("{}: {error}", cwd.display()))
})?;
let PreparedCommand {
mut command,
cleanup,
} = platform()?.command(self, &cwd)?;
command.current_dir(&cwd).kill_on_drop(self.kill_on_drop);
if let Some(stdin) = self.stdin.take() {
command.stdin(stdin);
}
if let Some(stdout) = self.stdout.take() {
command.stdout(stdout);
}
if let Some(stderr) = self.stderr.take() {
command.stderr(stderr);
}
if self.clear_env {
command.env_clear();
}
command.envs(&self.env);
for key in &self.removed_env {
command.env_remove(key);
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
remove_profile(cleanup);
return Err(SandboxCommandError::Spawn(error));
}
};
Ok(SandboxChild {
stdin: child.stdin.take(),
stdout: child.stdout.take(),
stderr: child.stderr.take(),
child,
cleanup,
})
}
}
fn remove_profile(path: Option<PathBuf>) {
if let Some(path) = path {
let _ = std::fs::remove_file(path);
}
}
enum Platform {
#[cfg(target_os = "linux")]
Linux { bwrap: PathBuf },
#[cfg(target_os = "macos")]
Macos,
}
impl Platform {
fn command(
&self,
command: &SandboxCommand,
cwd: &Path,
) -> Result<PreparedCommand, SandboxCommandError> {
match self {
#[cfg(target_os = "linux")]
Self::Linux { bwrap } => linux_command(bwrap, command, cwd),
#[cfg(target_os = "macos")]
Self::Macos => macos_command(command, cwd),
}
}
}
fn platform() -> Result<&'static Platform, SandboxCommandError> {
static PLATFORM: OnceLock<Result<Platform, String>> = OnceLock::new();
PLATFORM
.get_or_init(|| detect_platform().map_err(|error| error.to_string()))
.as_ref()
.map_err(|error| SandboxCommandError::Initialization(error.clone()))
}
#[cfg(target_os = "linux")]
fn detect_platform() -> Result<Platform, SandboxCommandError> {
let bwrap = find_program("bwrap").ok_or_else(|| {
SandboxCommandError::Initialization(
"required sandbox executable `bwrap` was not found".into(),
)
})?;
Ok(Platform::Linux { bwrap })
}
#[cfg(target_os = "macos")]
fn detect_platform() -> Result<Platform, SandboxCommandError> {
if !Path::new("/usr/bin/sandbox-exec").is_file() {
return Err(SandboxCommandError::Initialization(
"required sandbox executable `/usr/bin/sandbox-exec` was not found".into(),
));
}
Ok(Platform::Macos)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn detect_platform() -> Result<Platform, SandboxCommandError> {
Err(SandboxCommandError::Initialization(
"sandbox-command supports only Linux and macOS".into(),
))
}
#[cfg(target_os = "linux")]
fn find_program(program: &str) -> Option<PathBuf> {
std::env::var_os("PATH")?
.split_paths()
.map(|dir| dir.join(program))
.find(|path| path.is_file())
}
#[cfg(target_os = "linux")]
fn linux_command(
bwrap: &Path,
command: &SandboxCommand,
cwd: &Path,
) -> Result<PreparedCommand, SandboxCommandError> {
let mut wrapped = Command::new(bwrap);
let cwd = cwd.to_string_lossy();
wrapped.args([
"--bind",
&cwd,
&cwd,
"--chdir",
&cwd,
"--ro-bind",
"/usr",
"/usr",
"--symlink",
"usr/lib",
"/lib",
"--symlink",
"usr/bin",
"/bin",
"--symlink",
"usr/sbin",
"/sbin",
"--proc",
"/proc",
"--dev",
"/dev",
"--dir",
"/tmp",
"--share-net",
"--die-with-parent",
]);
if Path::new("/etc/resolv.conf").exists() {
wrapped.args(["--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf"]);
}
if Path::new("/etc/alternatives").is_dir() {
wrapped.args(["--ro-bind", "/etc/alternatives", "/etc/alternatives"]);
}
for path in &command.ro_binds {
let path = path.to_string_lossy();
wrapped.args(["--ro-bind", &path, &path]);
}
for path in &command.rw_binds {
let path = path.to_string_lossy();
wrapped.args(["--bind", &path, &path]);
}
wrapped.arg("--").arg(&command.program).args(&command.args);
Ok(PreparedCommand {
command: wrapped,
cleanup: None,
})
}
#[cfg(target_os = "macos")]
fn macos_command(
command: &SandboxCommand,
cwd: &Path,
) -> Result<PreparedCommand, SandboxCommandError> {
static PROFILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
let profile_path = std::env::temp_dir().join(format!(
"sandbox-command-{}-{}.sb",
std::process::id(),
PROFILE_COUNTER.fetch_add(1, Ordering::Relaxed)
));
let profile = profile_for(cwd, &command.rw_binds);
std::fs::write(&profile_path, profile).map_err(|error| {
SandboxCommandError::Initialization(format!("cannot write sandbox profile: {error}"))
})?;
let mut wrapped = Command::new("/usr/bin/sandbox-exec");
wrapped
.arg("-f")
.arg(profile_path.as_os_str())
.arg(&command.program)
.args(&command.args);
Ok(PreparedCommand {
command: wrapped,
cleanup: Some(profile_path),
})
}
#[cfg(target_os = "macos")]
fn profile_for(cwd: &Path, rw_binds: &[PathBuf]) -> String {
let quote = |path: &Path| {
format!(
"\"{}\"",
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
)
};
let mut profile = format!(
"(version 1)\n(deny default)\n(allow network*)\n(allow process-exec)\n(allow process-fork)\n(allow signal)\n(allow sysctl-read)\n(allow file-read-data file-read-metadata file-map-executable (subpath \"/\"))\n(allow file-write* (subpath \"/dev/null\"))\n(allow file-write* (subpath {}))\n(allow file-write* (subpath \"/private/tmp\"))\n(allow file-write* (subpath \"/tmp\"))\n(allow file-write* (subpath \"/var/folders\"))\n",
quote(cwd)
);
for path in rw_binds {
profile.push_str(&format!("(allow file-write* (subpath {}))\n", quote(path)));
}
profile
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn spawns_a_sandboxed_child() {
let root = tempfile::tempdir().unwrap();
let mut command = SandboxCommand::new("/usr/bin/printf");
command
.current_dir(root.path())
.arg("hello")
.stdout(Stdio::piped());
let child = match command.spawn() {
Ok(child) => child,
Err(SandboxCommandError::Initialization(_)) => return,
Err(error) => panic!("unexpected error: {error}"),
};
let output = child.wait_with_output().await.unwrap();
if !output.status.success() && !cfg!(target_os = "macos") {
return;
}
assert!(output.status.success());
assert_eq!(output.stdout, b"hello");
}
}