#![allow(dead_code)]
use crate::runtime::advanced_options::SecurityOptions;
use crate::runtime::layout::FilesystemLayout;
use crate::util::find_binary;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
static BWRAP_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
fn get_bwrap_path() -> Option<&'static PathBuf> {
BWRAP_PATH
.get_or_init(|| {
if let Ok(output) = Command::new("bwrap").arg("--version").output()
&& output.status.success()
{
tracing::debug!("Using system bwrap from PATH");
return Some(PathBuf::from("bwrap"));
}
match find_binary("bwrap") {
Ok(bundled_path) if bundled_path.exists() => {
tracing::debug!(
path = %bundled_path.display(),
"Using bundled bwrap"
);
Some(bundled_path)
}
Ok(bundled_path) => {
tracing::warn!(
path = %bundled_path.display(),
"Bundled bwrap path found but file does not exist"
);
None
}
Err(e) => {
tracing::debug!(
error = %e,
"Bundled bwrap not found"
);
None
}
}
})
.as_ref()
}
pub fn is_available() -> bool {
get_bwrap_path().is_some()
}
pub fn can_create_user_namespace() -> Result<(), String> {
let bwrap_path = match get_bwrap_path() {
Some(p) => p,
None => return Err("bwrap binary not found (neither system nor bundled)".to_string()),
};
let clone_errno = match super::credentials::can_create_process_in_new_user_ns() {
Ok(()) => None,
Err(errno) => {
tracing::debug!(
errno = errno,
"clone(CLONE_NEWUSER) failed — will still try bwrap (may have AppArmor profile)"
);
Some(errno)
}
};
let output = Command::new(bwrap_path)
.args(["--unshare-user", "--ro-bind", "/", "/", "--", "true"])
.output();
match output {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
let bwrap_source = if is_system_bwrap(bwrap_path) {
"system"
} else {
"bundled"
};
Err(build_diagnostic(
clone_errno,
bwrap_source,
bwrap_path,
&stderr,
))
}
Err(e) => Err(format!("failed to run bwrap: {}", e)),
}
}
fn is_system_bwrap(path: &Path) -> bool {
path == Path::new("bwrap")
}
fn build_diagnostic(
clone_errno: Option<i32>,
bwrap_source: &str,
bwrap_path: &Path,
bwrap_stderr: &str,
) -> String {
let mut msg = format!(
"bwrap --unshare-user failed ({} bwrap at {})",
bwrap_source,
bwrap_path.display()
);
if !bwrap_stderr.is_empty() {
msg.push_str(&format!("\nbwrap stderr: {}", bwrap_stderr));
}
if let Some(errno) = clone_errno {
msg.push_str(&format!(
"\nclone(CLONE_NEWUSER) errno: {} ({})",
errno,
std::io::Error::from_raw_os_error(errno)
));
}
if read_sysctl("kernel/apparmor_restrict_unprivileged_userns").as_deref() == Some("1") {
msg.push_str(
"\n\nCause: AppArmor restricts user namespaces \
(kernel.apparmor_restrict_unprivileged_userns=1).",
);
if bwrap_source == "bundled" {
match boxlite_apparmor_dir()
.and_then(|dir| super::apparmor::write_bwrap_profile(bwrap_path, &dir))
{
Ok(profile_path) => {
msg.push_str(&format!(
"\nBundled bwrap has no AppArmor profile.\n\
BoxLite generated one at: {}\n\n\
Fix (one command):\n \
sudo apparmor_parser -r {}\n\n\
Alternative: Install system bubblewrap:\n \
sudo apt install bubblewrap",
profile_path.display(),
profile_path.display()
));
}
Err(e) => {
tracing::warn!(error = %e, "Failed to generate AppArmor profile");
msg.push_str("\nBundled bwrap has no AppArmor profile.");
msg.push_str("\n\nFix (recommended): Install system bubblewrap:");
msg.push_str("\n sudo apt install bubblewrap");
}
}
} else {
msg.push_str("\nSystem bwrap needs an AppArmor profile with 'userns' permission.");
msg.push_str("\n\nFix (recommended): Install/reinstall bubblewrap:");
msg.push_str("\n sudo apt install --reinstall bubblewrap");
}
msg.push_str(
"\n\nFix (quick, less secure): \
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0",
);
} else if read_sysctl("kernel/unprivileged_userns_clone").as_deref() == Some("0") {
msg.push_str(
"\n\nCause: Unprivileged user namespaces disabled \
(kernel.unprivileged_userns_clone=0).",
);
msg.push_str("\n\nFix: sudo sysctl -w kernel.unprivileged_userns_clone=1");
msg.push_str(
"\n # Persist: echo 'kernel.unprivileged_userns_clone=1' \
| sudo tee /etc/sysctl.d/99-boxlite-userns.conf",
);
} else if read_sysctl("user/max_user_namespaces").as_deref() == Some("0") {
msg.push_str(
"\n\nCause: Max user namespaces set to zero \
(user.max_user_namespaces=0).",
);
msg.push_str("\n\nFix: sudo sysctl -w user.max_user_namespaces=15000");
msg.push_str(
"\n # Persist: echo 'user.max_user_namespaces=15000' \
| sudo tee /etc/sysctl.d/99-boxlite-userns.conf",
);
} else {
msg.push_str("\n\nCause: Unknown restriction.");
msg.push_str("\n Check: dmesg | grep -i 'apparmor\\|selinux\\|userns'");
msg.push_str("\n See: https://boxlite.dev/docs/faq#sandbox-userns");
}
msg
}
fn boxlite_apparmor_dir() -> Result<PathBuf, String> {
let home = std::env::var(crate::runtime::constants::envs::BOXLITE_HOME)
.map(PathBuf::from)
.or_else(|_| {
dirs::home_dir()
.map(|h| h.join(crate::runtime::layout::dirs::BOXLITE_DIR))
.ok_or_else(|| "cannot determine home directory".to_string())
})?;
Ok(home.join("apparmor"))
}
fn read_sysctl(name: &str) -> Option<String> {
std::fs::read_to_string(format!("/proc/sys/{}", name))
.ok()
.map(|s| s.trim().to_string())
}
#[allow(dead_code)]
pub fn version() -> Option<String> {
let bwrap_path = get_bwrap_path()?;
Command::new(bwrap_path)
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct BwrapCommand {
args: Vec<String>,
env_vars: Vec<(String, String)>,
}
impl BwrapCommand {
pub fn new() -> Self {
Self {
args: Vec::new(),
env_vars: Vec::new(),
}
}
pub fn with_default_namespaces(&mut self) -> &mut Self {
self.args.push("--unshare-user".to_string());
self.args.push("--unshare-pid".to_string());
self.args.push("--unshare-ipc".to_string());
self.args.push("--unshare-uts".to_string());
self
}
pub fn with_die_with_parent(&mut self) -> &mut Self {
self.args.push("--die-with-parent".to_string());
self
}
pub fn with_new_session(&mut self) -> &mut Self {
self.args.push("--new-session".to_string());
self
}
pub fn ro_bind(&mut self, src: impl AsRef<Path>, dest: impl AsRef<Path>) -> &mut Self {
self.args.push("--ro-bind".to_string());
self.args.push(src.as_ref().to_string_lossy().to_string());
self.args.push(dest.as_ref().to_string_lossy().to_string());
self
}
pub fn ro_bind_if_exists(
&mut self,
src: impl AsRef<Path>,
dest: impl AsRef<Path>,
) -> &mut Self {
if src.as_ref().exists() {
self.args.push("--ro-bind".to_string());
self.args.push(src.as_ref().to_string_lossy().to_string());
self.args.push(dest.as_ref().to_string_lossy().to_string());
}
self
}
pub fn bind(&mut self, src: impl AsRef<Path>, dest: impl AsRef<Path>) -> &mut Self {
self.args.push("--bind".to_string());
self.args.push(src.as_ref().to_string_lossy().to_string());
self.args.push(dest.as_ref().to_string_lossy().to_string());
self
}
pub fn dev_bind(&mut self, src: impl AsRef<Path>, dest: impl AsRef<Path>) -> &mut Self {
self.args.push("--dev-bind".to_string());
self.args.push(src.as_ref().to_string_lossy().to_string());
self.args.push(dest.as_ref().to_string_lossy().to_string());
self
}
pub fn dev_bind_if_exists(
&mut self,
src: impl AsRef<Path>,
dest: impl AsRef<Path>,
) -> &mut Self {
if src.as_ref().exists() {
self.args.push("--dev-bind".to_string());
self.args.push(src.as_ref().to_string_lossy().to_string());
self.args.push(dest.as_ref().to_string_lossy().to_string());
}
self
}
pub fn with_dev(&mut self) -> &mut Self {
self.args.push("--dev".to_string());
self.args.push("/dev".to_string());
self
}
pub fn with_proc(&mut self) -> &mut Self {
self.args.push("--proc".to_string());
self.args.push("/proc".to_string());
self
}
pub fn tmpfs(&mut self, path: impl AsRef<Path>) -> &mut Self {
self.args.push("--tmpfs".to_string());
self.args.push(path.as_ref().to_string_lossy().to_string());
self
}
pub fn with_clearenv(&mut self) -> &mut Self {
self.args.push("--clearenv".to_string());
self
}
pub fn setenv(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.args.push("--setenv".to_string());
self.args.push(key.into());
self.args.push(value.into());
self
}
pub fn with_seccomp_fd(&mut self, fd: i32) -> &mut Self {
self.args.push("--seccomp".to_string());
self.args.push(fd.to_string());
self
}
pub fn chdir(&mut self, path: impl AsRef<Path>) -> &mut Self {
self.args.push("--chdir".to_string());
self.args.push(path.as_ref().to_string_lossy().to_string());
self
}
pub fn build(&self, executable: impl AsRef<Path>, args: &[String]) -> Command {
let bwrap_path = get_bwrap_path().expect(
"BwrapCommand::build() called but bwrap is not available. Check is_available() first.",
);
let mut cmd = Command::new(bwrap_path);
cmd.args(&self.args);
cmd.arg("--");
cmd.arg(executable.as_ref());
cmd.args(args);
cmd
}
pub fn get_args(&self) -> &[String] {
&self.args
}
}
impl Default for BwrapCommand {
fn default() -> Self {
Self::new()
}
}
pub fn build_shim_command(
shim_path: &Path,
shim_args: &[String],
layout: &FilesystemLayout,
_security: &SecurityOptions,
) -> Command {
let mut bwrap = BwrapCommand::new();
bwrap
.with_default_namespaces()
.with_die_with_parent()
.with_new_session();
bwrap
.ro_bind_if_exists("/usr", "/usr")
.ro_bind_if_exists("/lib", "/lib")
.ro_bind_if_exists("/lib64", "/lib64")
.ro_bind_if_exists("/bin", "/bin")
.ro_bind_if_exists("/sbin", "/sbin");
bwrap
.with_dev()
.dev_bind_if_exists("/dev/kvm", "/dev/kvm")
.dev_bind_if_exists("/dev/net/tun", "/dev/net/tun");
bwrap.with_proc();
bwrap.tmpfs("/tmp");
bwrap.bind(layout.home_dir(), layout.home_dir());
if let Some(shim_dir) = shim_path.parent() {
let shim_dir_str = shim_dir.to_string_lossy();
if !shim_dir_str.starts_with("/usr")
&& !shim_dir_str.starts_with("/lib")
&& !shim_dir_str.starts_with("/bin")
{
bwrap.ro_bind(shim_dir, shim_dir);
tracing::debug!(
shim_dir = %shim_dir.display(),
"Mounted shim directory in sandbox"
);
}
}
bwrap.with_clearenv();
bwrap
.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
.setenv("HOME", "/root");
if let Ok(ld_library_path) = std::env::var("LD_LIBRARY_PATH") {
bwrap.setenv("LD_LIBRARY_PATH", ld_library_path);
tracing::debug!("Preserved LD_LIBRARY_PATH in sandbox");
} else if let Some(shim_dir) = shim_path.parent() {
bwrap.setenv("LD_LIBRARY_PATH", shim_dir.to_string_lossy().to_string());
tracing::debug!(
shim_dir = %shim_dir.display(),
"Set LD_LIBRARY_PATH to shim directory (fallback)"
);
}
if let Ok(rust_log) = std::env::var("RUST_LOG") {
bwrap.setenv("RUST_LOG", rust_log);
}
if let Ok(rust_backtrace) = std::env::var("RUST_BACKTRACE") {
bwrap.setenv("RUST_BACKTRACE", rust_backtrace);
}
bwrap.chdir("/");
bwrap.build(shim_path, shim_args)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bwrap_available() {
let available = is_available();
println!("bwrap available: {}", available);
if available {
println!("bwrap version: {:?}", version());
}
}
#[test]
fn test_bwrap_command_builder() {
let mut bwrap = BwrapCommand::new();
bwrap
.with_default_namespaces()
.with_die_with_parent()
.ro_bind("/usr", "/usr")
.with_dev()
.with_proc()
.tmpfs("/tmp")
.with_clearenv()
.setenv("PATH", "/usr/bin:/bin");
let args = bwrap.get_args();
assert!(args.contains(&"--unshare-user".to_string()));
assert!(args.contains(&"--unshare-pid".to_string()));
assert!(args.contains(&"--die-with-parent".to_string()));
assert!(args.contains(&"--clearenv".to_string()));
assert!(!args.contains(&"--unshare-net".to_string()));
}
#[test]
fn test_build_command() {
if !is_available() {
println!("Skipping test: bwrap not available");
return;
}
let mut bwrap = BwrapCommand::new();
bwrap
.with_default_namespaces()
.with_clearenv()
.setenv("FOO", "bar");
let cmd = bwrap.build(
Path::new("/usr/bin/echo"),
&["hello".to_string(), "world".to_string()],
);
let program = cmd.get_program().to_string_lossy();
assert!(
program.ends_with("bwrap") || program == "bwrap",
"Expected program to be bwrap, got: {}",
program
);
}
#[test]
fn test_bwrap_non_consuming_pattern() {
let mut bwrap = BwrapCommand::new();
bwrap.with_default_namespaces();
bwrap.ro_bind("/usr", "/usr");
bwrap.with_clearenv();
let args = bwrap.get_args();
assert!(args.contains(&"--unshare-user".to_string()));
assert!(args.contains(&"--ro-bind".to_string()));
assert!(args.contains(&"--clearenv".to_string()));
}
#[test]
fn test_bwrap_conditional_mount() {
let mut bwrap = BwrapCommand::new();
bwrap.ro_bind_if_exists("/nonexistent", "/nonexistent");
bwrap.dev_bind_if_exists("/nonexistent_dev", "/nonexistent_dev");
let args = bwrap.get_args();
assert!(!args.contains(&"/nonexistent".to_string()));
assert!(!args.contains(&"/nonexistent_dev".to_string()));
}
#[test]
fn test_can_create_user_namespace() {
let result = can_create_user_namespace();
match result {
Ok(()) => {
}
Err(e) => {
assert!(!e.is_empty(), "Error message should not be empty");
assert!(
e.contains("bwrap"),
"Diagnostic should mention bwrap: {}",
e
);
}
}
}
}