use std::ffi::OsString;
use std::io;
use std::process::Command;
use std::sync::OnceLock;
use super::{ProcessSandboxConfig, SandboxPrefix};
fn bwrap_install_hint() -> &'static str {
static HINT: OnceLock<String> = OnceLock::new();
HINT.get_or_init(|| {
let os_release = std::fs::read_to_string("/etc/os-release").unwrap_or_default();
let id = os_release
.lines()
.map(str::trim)
.find_map(|l| l.strip_prefix("ID="))
.unwrap_or("")
.trim_matches('"');
let id_like = os_release
.lines()
.map(str::trim)
.find_map(|l| l.strip_prefix("ID_LIKE="))
.unwrap_or("")
.trim_matches('"');
if id == "ubuntu"
|| id == "debian"
|| id == "pop"
|| id == "linuxmint"
|| id_like.contains("debian")
|| id_like.contains("ubuntu")
{
"Install with: sudo apt install bubblewrap".to_string()
} else if id == "fedora"
|| id == "rhel"
|| id == "centos"
|| id == "rocky"
|| id == "alma"
|| id_like.contains("fedora")
|| id_like.contains("rhel")
{
"Install with: sudo dnf install bubblewrap".to_string()
} else if id == "arch" || id == "manjaro" || id_like.contains("arch") {
"Install with: sudo pacman -S bubblewrap".to_string()
} else if id == "alpine" {
"Install with: sudo apk add bubblewrap".to_string()
} else if id == "opensuse" || id == "sles" || id_like.contains("suse") {
"Install with: sudo zypper install bubblewrap".to_string()
} else if id == "nixos" || id == "nix" {
"Add bubblewrap to environment.systemPackages or use: nix-env -iA nixpkgs.bubblewrap"
.to_string()
} else if id == "void" {
"Install with: sudo xbps-install bubblewrap".to_string()
} else {
"Install the 'bubblewrap' package using your system package manager".to_string()
}
})
}
fn interpret_bwrap_probe(result: io::Result<std::process::Output>) -> bool {
match result {
Ok(output) if output.status.success() => true,
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
exit_code = output.status.code(),
stderr = %stderr.trim(),
"bwrap sandbox unavailable: user namespace creation failed. \
On Ubuntu 24.04+, this is likely caused by \
kernel.apparmor_restrict_unprivileged_userns=1. \
Fix with: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \
Capsules will run without OS-level sandboxing."
);
false
},
Err(e) => {
let hint = bwrap_install_hint();
let msg = if e.kind() == io::ErrorKind::NotFound {
"bwrap binary not found."
} else {
"Failed to execute bwrap probe."
};
tracing::warn!(
error = %e,
install_hint = %hint,
"{msg} Capsules will run without OS-level sandboxing."
);
false
},
}
}
pub(super) fn bwrap_available() -> bool {
static RESULT: OnceLock<bool> = OnceLock::new();
*RESULT.get_or_init(|| {
let result = Command::new("bwrap")
.arg("--unshare-user")
.arg("--ro-bind")
.arg("/")
.arg("/")
.arg("--")
.arg("/bin/true")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output();
interpret_bwrap_probe(result)
})
}
impl ProcessSandboxConfig {
pub(super) fn build_bwrap_prefix(&self) -> SandboxPrefix {
let mut args: Vec<OsString> = Vec::new();
args.extend(["--ro-bind", "/", "/"].map(OsString::from));
args.extend(["--dev", "/dev"].map(OsString::from));
args.extend(["--proc", "/proc"].map(OsString::from));
args.extend(["--tmpfs", "/tmp"].map(OsString::from));
for path in &self.hidden_paths {
args.extend([OsString::from("--tmpfs"), path.as_os_str().into()]);
}
args.extend([
OsString::from("--bind"),
self.writable_root.as_os_str().into(),
self.writable_root.as_os_str().into(),
]);
for path in &self.extra_write_paths {
args.extend([
OsString::from("--bind"),
path.as_os_str().into(),
path.as_os_str().into(),
]);
}
args.push(OsString::from("--unshare-all"));
if self.allow_network {
args.push(OsString::from("--share-net"));
}
args.push(OsString::from("--die-with-parent"));
args.push(OsString::from("--"));
SandboxPrefix {
program: OsString::from("bwrap"),
args,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bwrap_prefix_basic() {
let config = ProcessSandboxConfig::new("/project");
let prefix = config.build_bwrap_prefix();
assert_eq!(prefix.program, OsString::from("bwrap"));
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(args_str.contains(&"--ro-bind".to_string()));
assert!(args_str.contains(&"--dev".to_string()));
assert!(args_str.contains(&"--proc".to_string()));
assert!(args_str.contains(&"--unshare-all".to_string()));
assert!(args_str.contains(&"--share-net".to_string()));
assert!(args_str.contains(&"--die-with-parent".to_string()));
assert!(args_str.contains(&"--".to_string()));
let bind_idx = args_str
.iter()
.position(|a| a == "--bind")
.expect("should have --bind");
assert_eq!(args_str[bind_idx + 1], "/project");
assert_eq!(args_str[bind_idx + 2], "/project");
}
#[test]
fn test_bwrap_prefix_no_network() {
let config = ProcessSandboxConfig::new("/project").with_network(false);
let prefix = config.build_bwrap_prefix();
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
assert!(args_str.contains(&"--unshare-all".to_string()));
assert!(!args_str.contains(&"--share-net".to_string()));
}
#[test]
fn test_bwrap_prefix_hidden_paths() {
let config = ProcessSandboxConfig::new("/project").with_hidden("/home/user/.astrid");
let prefix = config.build_bwrap_prefix();
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
let tmpfs_positions: Vec<usize> = args_str
.iter()
.enumerate()
.filter(|(_, a)| *a == "--tmpfs")
.map(|(i, _)| i)
.collect();
assert!(
tmpfs_positions.len() >= 2,
"should have at least 2 tmpfs mounts"
);
let hidden_tmpfs_found = tmpfs_positions
.iter()
.any(|&i| args_str.get(i + 1) == Some(&"/home/user/.astrid".to_string()));
assert!(hidden_tmpfs_found, "should have tmpfs for hidden path");
}
#[test]
fn test_bwrap_prefix_writable_inside_hidden_path() {
let config = ProcessSandboxConfig::new("/home/user/.astrid/capsules/openclaw-unicity")
.with_hidden("/home/user/.astrid");
let prefix = config.build_bwrap_prefix();
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
let hidden_tmpfs_pos = args_str
.iter()
.enumerate()
.filter(|(_, a)| *a == "--tmpfs")
.find(|(i, _)| args_str.get(i + 1) == Some(&"/home/user/.astrid".to_string()))
.map(|(i, _)| i)
.expect("should have --tmpfs for hidden path");
let writable_bind_pos = args_str
.iter()
.enumerate()
.filter(|(_, a)| *a == "--bind")
.find(|(i, _)| {
args_str.get(i + 1)
== Some(&"/home/user/.astrid/capsules/openclaw-unicity".to_string())
})
.map(|(i, _)| i)
.expect("should have --bind for writable root");
assert!(
writable_bind_pos > hidden_tmpfs_pos,
"writable --bind (pos {writable_bind_pos}) must come after \
hidden --tmpfs (pos {hidden_tmpfs_pos}) so capsule dir \
punches through the tmpfs overlay"
);
}
#[test]
fn test_bwrap_prefix_extra_paths() {
let config = ProcessSandboxConfig::new("/project")
.with_extra_read("/data")
.with_extra_write("/output");
let prefix = config.build_bwrap_prefix();
let args_str: Vec<String> = prefix
.args
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect();
let bind_positions: Vec<usize> = args_str
.iter()
.enumerate()
.filter(|(_, a)| *a == "--bind")
.map(|(i, _)| i)
.collect();
let has_output_bind = bind_positions
.iter()
.any(|&i| args_str.get(i + 1) == Some(&"/output".to_string()));
assert!(has_output_bind, "should have --bind for extra write path");
let ro_positions: Vec<usize> = args_str
.iter()
.enumerate()
.filter(|(_, a)| *a == "--ro-bind")
.map(|(i, _)| i)
.collect();
let has_data_explicit = ro_positions
.iter()
.any(|&i| args_str.get(i + 1) == Some(&"/data".to_string()));
assert!(
!has_data_explicit,
"extra_read_paths should NOT produce --ro-bind on Linux (covered by --ro-bind / /)"
);
}
fn mock_output(code: i32, stderr: &str) -> io::Result<std::process::Output> {
use std::os::unix::process::ExitStatusExt;
Ok(std::process::Output {
status: std::process::ExitStatus::from_raw(code << 8),
stdout: Vec::new(),
stderr: stderr.as_bytes().to_vec(),
})
}
#[test]
fn test_bwrap_probe_success() {
assert!(interpret_bwrap_probe(mock_output(0, "")));
}
#[test]
fn test_bwrap_probe_namespace_denied() {
let result = mock_output(1, "bwrap: setting up uid map: Permission denied\n");
assert!(!interpret_bwrap_probe(result));
}
#[test]
fn test_bwrap_probe_not_found() {
let result: io::Result<std::process::Output> = Err(io::Error::new(
io::ErrorKind::NotFound,
"No such file or directory",
));
assert!(!interpret_bwrap_probe(result));
}
#[test]
fn test_bwrap_install_hint_returns_nonempty() {
let hint = bwrap_install_hint();
assert!(!hint.is_empty(), "install hint should never be empty");
assert!(
hint.contains("bubblewrap"),
"install hint should mention 'bubblewrap': {hint}"
);
}
}