mod builder;
mod command;
mod common;
mod error;
mod pre_exec;
pub(crate) mod sandbox;
pub(crate) mod shim_copy;
#[cfg(target_os = "linux")]
pub(crate) mod apparmor;
#[cfg(target_os = "linux")]
pub(crate) mod bwrap;
#[cfg(target_os = "linux")]
pub(crate) mod cgroup;
#[cfg(target_os = "linux")]
pub(crate) mod credentials;
#[cfg(target_os = "linux")]
pub mod landlock;
#[cfg(target_os = "linux")]
pub mod seccomp;
pub use crate::runtime::advanced_options::{ResourceLimits, SecurityOptions};
pub use builder::JailerBuilder;
pub use error::{ConfigError, IsolationError, JailerError, SystemError};
pub use sandbox::{
CompositeSandbox, NoopSandbox, PathAccess, PlatformSandbox, Sandbox, SandboxContext,
};
pub use crate::runtime::options::VolumeSpec;
#[cfg(target_os = "linux")]
pub use bwrap::{build_shim_command, is_available as is_bwrap_available};
#[cfg(target_os = "linux")]
pub use landlock::{build_landlock_ruleset, is_landlock_available};
#[cfg(target_os = "linux")]
pub use sandbox::{BwrapSandbox, LandlockSandbox};
#[cfg(target_os = "linux")]
pub use seccomp::SeccompRole;
#[cfg(target_os = "macos")]
pub use sandbox::SeatbeltSandbox;
#[cfg(target_os = "macos")]
pub use sandbox::seatbelt::{
SANDBOX_EXEC_PATH, get_base_policy, get_network_policy, is_sandbox_available,
};
use boxlite_shared::errors::BoxliteResult;
use std::path::Path;
use std::process::Command;
pub trait Jail: Send + Sync {
fn prepare(&self) -> BoxliteResult<()>;
fn command(&self, binary: &Path, args: &[String]) -> Command;
}
use crate::disk::read_backing_chain;
use crate::runtime::layout::BoxFilesystemLayout;
use std::path::PathBuf;
fn build_path_access(layout: &BoxFilesystemLayout, volumes: &[VolumeSpec]) -> Vec<PathAccess> {
let mut paths = Vec::new();
for dir in [layout.sockets_dir(), layout.tmp_dir(), layout.logs_dir()] {
if dir.exists() {
paths.push(PathAccess {
path: dir,
writable: true,
});
}
}
for file in [
layout.exit_file_path(),
layout.disk_path(),
layout.guest_rootfs_disk_path(),
] {
if file.exists() {
paths.push(PathAccess {
path: file,
writable: true,
});
}
}
for qcow2 in [layout.disk_path(), layout.guest_rootfs_disk_path()] {
if !qcow2.exists() {
continue;
}
for backing_path in read_backing_chain(&qcow2) {
if let Some(parent) = backing_path.parent().filter(|p| p.exists()) {
paths.push(PathAccess {
path: parent.to_path_buf(),
writable: false,
});
}
paths.push(PathAccess {
path: backing_path,
writable: false,
});
}
}
let bin_dir = layout.bin_dir();
if bin_dir.exists() {
paths.push(PathAccess {
path: bin_dir,
writable: false,
});
}
let shared_dir = layout.shared_dir();
if shared_dir.exists() {
paths.push(PathAccess {
path: shared_dir,
writable: true,
});
}
if let Some(bases_dir) = layout
.root()
.parent()
.and_then(|boxes| boxes.parent())
.map(|home| home.join("bases"))
.filter(|p| p.exists())
{
paths.push(PathAccess {
path: bases_dir,
writable: false,
});
}
for vol in volumes {
let p = PathBuf::from(&vol.host_path);
if p.exists() {
paths.push(PathAccess {
path: p,
writable: !vol.read_only,
});
}
}
paths
}
#[derive(Debug)]
pub struct Jailer<S: Sandbox> {
sandbox: S,
pub(crate) security: SecurityOptions,
pub(crate) volumes: Vec<VolumeSpec>,
pub(crate) box_id: String,
pub(crate) layout: BoxFilesystemLayout,
pub(crate) preserved_fds: Vec<(std::os::fd::RawFd, i32)>,
}
impl<S: Sandbox> Jail for Jailer<S> {
fn prepare(&self) -> BoxliteResult<()> {
if !self.security.jailer_enabled {
return Ok(());
}
self.sandbox.setup(&self.context())
}
fn command(&self, binary: &Path, args: &[String]) -> Command {
if self.security.jailer_enabled {
let _ = std::fs::create_dir_all(self.layout.logs_dir());
for path in [
self.layout.exit_file_path(),
self.layout.console_output_path(),
] {
if !path.exists() {
let _ = std::fs::File::create(&path);
}
}
}
let mut ctx = self.context();
#[allow(clippy::collapsible_if)]
if self.security.jailer_enabled {
if let Some(lib_dir) = binary.parent().filter(|d| d.exists()) {
ctx.paths.push(PathAccess {
path: lib_dir.to_path_buf(),
writable: false,
});
}
}
let effective_binary = if self.security.jailer_enabled {
match shim_copy::copy_shim_to_box(binary, self.layout.root()) {
Ok(copied) => {
tracing::info!(
original = %binary.display(),
copied = %copied.display(),
"Using copied shim binary (Firecracker pattern)"
);
copied
}
Err(e) => {
tracing::warn!(error = %e, "Failed to copy shim, using original");
binary.to_path_buf()
}
}
} else {
binary.to_path_buf()
};
let mut cmd = Command::new(&effective_binary);
cmd.args(args);
if self.security.jailer_enabled && self.sandbox.is_available() {
tracing::info!(sandbox = self.sandbox.name(), "Applying sandbox isolation");
self.sandbox.apply(&ctx, &mut cmd);
} else if self.security.jailer_enabled {
tracing::warn!("Sandbox not available, falling back to direct command");
} else {
tracing::info!("Jailer disabled, running shim without sandbox isolation");
}
let resource_limits = self.security.resource_limits.clone();
let pid_file = self.pid_file_path();
pre_exec::add_pre_exec_hook(
&mut cmd,
resource_limits,
pid_file,
self.preserved_fds.clone(),
);
cmd
}
}
impl<S: Sandbox> Jailer<S> {
pub fn security(&self) -> &SecurityOptions {
&self.security
}
pub fn security_mut(&mut self) -> &mut SecurityOptions {
&mut self.security
}
pub fn volumes(&self) -> &[VolumeSpec] {
&self.volumes
}
pub fn box_id(&self) -> &str {
&self.box_id
}
pub fn box_dir(&self) -> &Path {
self.layout.root()
}
pub fn layout(&self) -> &BoxFilesystemLayout {
&self.layout
}
pub fn resource_limits(&self) -> &ResourceLimits {
&self.security.resource_limits
}
fn context(&self) -> SandboxContext<'_> {
let paths = build_path_access(&self.layout, &self.volumes);
tracing::debug!(
box_id = %self.box_id,
path_count = paths.len(),
paths = ?paths,
"Built sandbox path access list"
);
if std::env::var_os("BOXLITE_DEBUG_PRINT_SEATBELT").is_some() {
eprintln!("BOXLITE_DEBUG paths for {}: {:#?}", self.box_id, paths);
}
SandboxContext {
id: &self.box_id,
paths,
resource_limits: &self.security.resource_limits,
network_enabled: self.security.network_enabled,
sandbox_profile: self.security.sandbox_profile.as_deref(),
}
}
fn pid_file_path(&self) -> Option<std::ffi::CString> {
let pid_file = self.layout.pid_file_path();
std::ffi::CString::new(pid_file.to_string_lossy().as_bytes()).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::layout::FsLayoutConfig;
use tempfile::tempdir;
fn test_layout(box_dir: PathBuf) -> BoxFilesystemLayout {
BoxFilesystemLayout::new(box_dir, FsLayoutConfig::without_bind_mount(), false)
}
#[test]
fn test_build_path_access_empty_box_dir() {
let dir = tempdir().unwrap();
let layout = test_layout(dir.path().to_path_buf());
let paths = build_path_access(&layout, &[]);
assert!(paths.is_empty(), "No paths for empty box dir");
}
#[test]
fn test_build_path_access_writable_dirs() {
let dir = tempdir().unwrap();
let box_dir = dir.path().to_path_buf();
let layout = test_layout(box_dir.clone());
std::fs::create_dir_all(layout.sockets_dir()).unwrap();
std::fs::create_dir_all(layout.tmp_dir()).unwrap();
std::fs::create_dir_all(layout.logs_dir()).unwrap();
let paths = build_path_access(&layout, &[]);
let writable_dirs: Vec<_> = paths
.iter()
.filter(|p| p.writable && p.path.is_dir())
.collect();
assert_eq!(
writable_dirs.len(),
3,
"Should have 3 writable dirs (sockets, tmp, logs)"
);
for pa in &writable_dirs {
assert!(pa.writable);
}
let tmp = paths.iter().find(|p| p.path == layout.tmp_dir());
assert!(tmp.is_some(), "tmp/ should be included");
assert!(tmp.unwrap().writable, "tmp/ should be writable");
}
#[test]
fn test_build_path_access_writable_files() {
let dir = tempdir().unwrap();
let box_dir = dir.path().to_path_buf();
let layout = test_layout(box_dir.clone());
std::fs::File::create(layout.exit_file_path()).unwrap();
let paths = build_path_access(&layout, &[]);
let writable_files: Vec<_> = paths
.iter()
.filter(|p| p.writable && p.path.is_file())
.collect();
assert_eq!(
writable_files.len(),
1,
"exit only (console.log covered by logs/ subpath)"
);
}
#[test]
fn test_build_path_access_ro_dirs() {
let dir = tempdir().unwrap();
let box_dir = dir.path().to_path_buf();
let layout = test_layout(box_dir.clone());
std::fs::create_dir_all(layout.bin_dir()).unwrap();
std::fs::create_dir_all(layout.shared_dir()).unwrap();
let paths = build_path_access(&layout, &[]);
let bin = paths.iter().find(|p| p.path == layout.bin_dir());
assert!(bin.is_some(), "bin/ should be included");
assert!(!bin.unwrap().writable, "bin/ should be read-only");
let shared = paths.iter().find(|p| p.path == layout.shared_dir());
assert!(shared.is_some(), "shared/ should be included");
assert!(shared.unwrap().writable, "shared/ should be writable");
}
#[test]
fn test_build_path_access_shared_bases_dir() {
let dir = tempdir().unwrap();
let home_dir = dir.path().to_path_buf();
let boxes_dir = home_dir.join("boxes");
let box_dir = boxes_dir.join("test-box");
std::fs::create_dir_all(&box_dir).unwrap();
let bases_dir = home_dir.join("bases");
std::fs::create_dir_all(&bases_dir).unwrap();
let layout = test_layout(box_dir);
let paths = build_path_access(&layout, &[]);
let bases_paths: Vec<_> = paths.iter().filter(|p| p.path == bases_dir).collect();
assert_eq!(bases_paths.len(), 1, "Should include home_dir/bases/");
assert!(!bases_paths[0].writable);
}
#[test]
fn test_build_path_access_includes_qcow2_backing_file() {
use crate::disk::{BackingFormat, Qcow2Helper};
let dir = tempdir().unwrap();
let home_dir = dir.path().to_path_buf();
let boxes_dir = home_dir.join("boxes");
let box_dir = boxes_dir.join("test-box");
std::fs::create_dir_all(&box_dir).unwrap();
let disk_images_dir = home_dir.join("images").join("disk-images");
std::fs::create_dir_all(&disk_images_dir).unwrap();
let base_disk = disk_images_dir.join("sha256-test.ext4");
std::fs::write(&base_disk, vec![0u8; 1024 * 1024]).unwrap();
let layout = test_layout(box_dir);
let child_disk = Qcow2Helper::create_cow_child_disk(
&base_disk,
BackingFormat::Raw,
&layout.disk_path(),
16 * 1024 * 1024,
)
.unwrap();
let paths = build_path_access(&layout, &[]);
let expected_backing = base_disk.canonicalize().unwrap_or(base_disk);
let backing_paths: Vec<_> = paths
.iter()
.filter(|p| {
p.path.canonicalize().unwrap_or_else(|_| p.path.clone()) == expected_backing
})
.collect();
assert_eq!(
backing_paths.len(),
1,
"Expected qcow2 backing file to be included in sandbox paths"
);
assert!(!backing_paths[0].writable, "Backing file must be read-only");
let _ = child_disk.path();
}
#[test]
fn test_build_path_access_volumes() {
let dir = tempdir().unwrap();
let box_dir = dir.path().to_path_buf();
let layout = test_layout(box_dir);
let vol_ro = dir.path().join("input");
let vol_rw = dir.path().join("output");
std::fs::create_dir_all(&vol_ro).unwrap();
std::fs::create_dir_all(&vol_rw).unwrap();
let volumes = vec![
VolumeSpec {
host_path: vol_ro.to_string_lossy().to_string(),
guest_path: "/mnt/input".to_string(),
read_only: true,
},
VolumeSpec {
host_path: vol_rw.to_string_lossy().to_string(),
guest_path: "/mnt/output".to_string(),
read_only: false,
},
];
let paths = build_path_access(&layout, &volumes);
let vol_paths: Vec<_> = paths
.iter()
.filter(|p| p.path == vol_ro || p.path == vol_rw)
.collect();
assert_eq!(vol_paths.len(), 2, "Both volumes should be listed");
let ro_vol = vol_paths.iter().find(|p| p.path == vol_ro).unwrap();
assert!(!ro_vol.writable, "RO volume should be read-only");
let rw_vol = vol_paths.iter().find(|p| p.path == vol_rw).unwrap();
assert!(rw_vol.writable, "RW volume should be writable");
}
#[test]
fn test_build_path_access_nonexistent_volume_skipped() {
let dir = tempdir().unwrap();
let layout = test_layout(dir.path().to_path_buf());
let volumes = vec![VolumeSpec {
host_path: "/does/not/exist".to_string(),
guest_path: "/mnt/data".to_string(),
read_only: true,
}];
let paths = build_path_access(&layout, &volumes);
assert!(
paths.iter().all(|p| p.path != Path::new("/does/not/exist")),
"Nonexistent volume should be skipped"
);
}
#[test]
fn test_build_path_access_no_whole_box_dir() {
let dir = tempdir().unwrap();
let box_dir = dir.path().to_path_buf();
let layout = test_layout(box_dir.clone());
std::fs::create_dir_all(layout.sockets_dir()).unwrap();
std::fs::create_dir_all(layout.mounts_dir()).unwrap();
std::fs::create_dir_all(layout.logs_dir()).unwrap();
std::fs::create_dir_all(layout.bin_dir()).unwrap();
let paths = build_path_access(&layout, &[]);
assert!(
paths.iter().all(|p| p.path != box_dir),
"box_dir should not be listed wholesale — only granular paths"
);
}
#[test]
fn test_build_path_access_mounts_dir_excluded() {
let dir = tempdir().unwrap();
let layout = test_layout(dir.path().to_path_buf());
let mounts_base = layout.shared_layout().base().to_path_buf();
std::fs::create_dir_all(&mounts_base).unwrap();
std::fs::create_dir_all(layout.sockets_dir()).unwrap();
std::fs::create_dir_all(layout.logs_dir()).unwrap();
let paths = build_path_access(&layout, &[]);
assert!(
paths.iter().all(|p| p.path != mounts_base),
"mounts_dir must NOT appear in path access"
);
assert!(
paths.iter().any(|p| p.path == layout.sockets_dir()),
"sockets_dir should be present"
);
}
#[test]
fn test_build_path_access_shared_dir_is_writable() {
let dir = tempdir().unwrap();
let layout = test_layout(dir.path().to_path_buf());
std::fs::create_dir_all(layout.shared_dir()).unwrap();
let paths = build_path_access(&layout, &[]);
let shared = paths.iter().find(|p| p.path == layout.shared_dir());
assert!(shared.is_some(), "shared_dir should be in path access");
assert!(shared.unwrap().writable, "shared_dir must be writable");
}
#[test]
fn test_build_path_access_captures_all_precreated_files() {
let dir = tempdir().unwrap();
let layout = test_layout(dir.path().to_path_buf());
std::fs::create_dir_all(layout.logs_dir()).unwrap();
std::fs::File::create(layout.exit_file_path()).unwrap();
std::fs::File::create(layout.console_output_path()).unwrap();
let paths = build_path_access(&layout, &[]);
let logs = paths.iter().find(|p| p.path == layout.logs_dir());
assert!(logs.is_some(), "logs_dir should be in path access");
assert!(logs.unwrap().writable, "logs_dir should be writable");
let exit = paths.iter().find(|p| p.path == layout.exit_file_path());
assert!(exit.is_some(), "exit_file should be in path access");
assert!(exit.unwrap().writable, "exit_file should be writable");
let console = paths
.iter()
.find(|p| p.path == layout.console_output_path());
assert!(
console.is_none(),
"console.log should not be a standalone path access (covered by logs/)"
);
}
#[test]
fn test_jailer_full_flow_with_real_tempdir() {
use crate::jailer::builder::JailerBuilder;
use crate::runtime::advanced_options::SecurityOptions;
let dir = tempdir().unwrap();
let box_dir = dir.path().to_path_buf();
let layout = test_layout(box_dir.clone());
let vol_dir = dir.path().join("my-volume");
std::fs::create_dir_all(&vol_dir).unwrap();
let security = SecurityOptions {
jailer_enabled: true,
..SecurityOptions::default()
};
let jail = JailerBuilder::new()
.with_box_id("e2e-test")
.with_layout(layout.clone())
.with_security(security)
.with_volumes(vec![VolumeSpec {
host_path: vol_dir.to_string_lossy().to_string(),
guest_path: "/mnt/data".to_string(),
read_only: false,
}])
.build()
.unwrap();
jail.prepare().unwrap();
let _cmd = jail.command(
std::path::Path::new("/usr/bin/boxlite-shim"),
&["--engine".to_string(), "Libkrun".to_string()],
);
assert!(
layout.logs_dir().exists(),
"logs_dir should be created by command()"
);
assert!(
layout.exit_file_path().exists(),
"exit file should be created by command()"
);
assert!(
layout.console_output_path().exists(),
"console.log should be created by command()"
);
}
}