use std::path::{Component, Path};
use anyhow::Result;
use tokio::process::Command;
use crate::ResolvedBashSandbox;
pub fn sandbox_available() -> bool {
#[cfg(target_os = "linux")]
{
which("bwrap")
}
#[cfg(target_os = "macos")]
{
which("sandbox-exec")
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
false
}
}
pub fn sandbox_tool_name() -> &'static str {
#[cfg(target_os = "linux")]
{
"bwrap (bubblewrap)"
}
#[cfg(target_os = "macos")]
{
"sandbox-exec"
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
"unsupported"
}
}
pub fn build_sandboxed_command(
script: &str,
env: &[(String, String)],
cwd: Option<&str>,
sandbox: &ResolvedBashSandbox,
) -> Result<Command> {
#[cfg(target_os = "linux")]
{
build_linux_command(script, env, cwd, sandbox)
}
#[cfg(target_os = "macos")]
{
build_macos_command(script, env, cwd, sandbox)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (script, env, cwd, sandbox);
anyhow::bail!(
"bash sandbox is not supported on this platform; \
only Linux (bwrap) and macOS (sandbox-exec) are supported"
);
}
}
#[cfg(target_os = "linux")]
fn build_linux_command(
script: &str,
env: &[(String, String)],
cwd: Option<&str>,
sandbox: &ResolvedBashSandbox,
) -> Result<Command> {
let mut cmd = Command::new("bwrap");
for dir in &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"] {
if Path::new(dir).exists() {
cmd.args(["--ro-bind", dir, dir]);
}
}
cmd.args(["--tmpfs", "/tmp"]);
cmd.args(["--dev", "/dev"]);
cmd.args(["--proc", "/proc"]);
cmd.arg("--unshare-pid");
cmd.arg("--unshare-ipc");
cmd.arg("--unshare-uts");
if !sandbox.network {
cmd.arg("--unshare-net");
}
cmd.arg("--die-with-parent");
if let Some(dir) = cwd {
validate_sandbox_cwd(dir)?;
cmd.args(["--ro-bind", dir, dir]);
cmd.args(["--chdir", dir]);
for writable in &sandbox.writable_paths {
let full_path = Path::new(dir).join(writable);
let full_str = full_path.to_string_lossy();
if full_path.exists() {
cmd.args(["--bind", &full_str, &full_str]);
}
}
}
cmd.args(["--", "bash", "-c", script]);
for (key, value) in env {
cmd.env(key, value);
}
Ok(cmd)
}
#[cfg(target_os = "macos")]
fn build_macos_command(
script: &str,
env: &[(String, String)],
cwd: Option<&str>,
sandbox: &ResolvedBashSandbox,
) -> Result<Command> {
let profile = build_seatbelt_profile(cwd, sandbox);
let mut cmd = Command::new("sandbox-exec");
cmd.args(["-p", &profile, "bash", "-c", script]);
for (key, value) in env {
cmd.env(key, value);
}
if let Some(dir) = cwd {
validate_sandbox_cwd(dir)?;
cmd.current_dir(dir);
}
Ok(cmd)
}
#[cfg(target_os = "macos")]
fn build_seatbelt_profile(cwd: Option<&str>, sandbox: &ResolvedBashSandbox) -> String {
let mut profile = String::from(
"(version 1)\n\
(deny default)\n",
);
if sandbox.network {
profile.push_str("(allow network*)\n");
} else {
profile.push_str("(deny network*)\n");
}
profile.push_str("(allow process-exec)\n");
profile.push_str("(allow process-fork)\n");
profile.push_str("(allow sysctl-read)\n");
profile.push_str("(allow file-read*)\n");
if let Some(dir) = cwd {
for writable in &sandbox.writable_paths {
let full_path = std::path::Path::new(dir).join(writable);
let full_str = full_path.to_string_lossy();
profile.push_str(&format!("(allow file-write* (subpath \"{full_str}\"))\n"));
}
}
profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
profile.push_str("(allow file-write* (literal \"/dev/tty\"))\n");
for service in &[
"com.apple.system.logger",
"com.apple.system.notification_center",
"com.apple.SecurityServer",
"com.apple.CoreServices.coreservicesd",
"com.apple.lsd.mapdb",
] {
profile.push_str(&format!(
"(allow mach-lookup (global-name \"{service}\"))\n"
));
}
profile
}
pub fn validate_sandbox_cwd(dir: &str) -> Result<()> {
if Path::new(dir)
.components()
.any(|c| matches!(c, Component::ParentDir))
{
anyhow::bail!("sandbox cwd must not contain `..` path components");
}
Ok(())
}
fn which(name: &str) -> bool {
std::process::Command::new("which")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}