use std::os::fd::OwnedFd;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use landlock::{
Access, AccessFs, BitFlags, PathBeneath, PathFd, RestrictionStatus, Ruleset, RulesetAttr,
RulesetCreatedAttr,
};
use seccompiler::{BpfProgram, SeccompAction, SeccompFilter, TargetArch};
use tokio::process::Command;
use super::{Sandbox, SandboxError, SandboxPolicy, SandboxProfile};
#[derive(Debug)]
pub struct LinuxSandbox {
bwrap_path: PathBuf,
bpf_bytes: Vec<u8>,
pending_fd: Mutex<Option<OwnedFd>>,
}
impl LinuxSandbox {
pub fn new(strict: bool) -> Result<Self, SandboxError> {
match locate_bwrap() {
Some(path) => {
let bpf_bytes = compile_bpf_bytes()?;
Ok(Self {
bwrap_path: path,
bpf_bytes,
pending_fd: Mutex::new(None),
})
}
None if strict => Err(SandboxError::Unavailable {
reason: "bwrap not found on PATH; install bubblewrap or set strict=false to fall back to noop".into(),
}),
None => {
tracing::warn!(
"bwrap not found — Linux sandbox falling back to noop (strict=false)"
);
Err(SandboxError::Unavailable {
reason: "bwrap not found".into(),
})
}
}
}
}
impl Sandbox for LinuxSandbox {
fn name(&self) -> &'static str {
"linux-bwrap-landlock"
}
fn supports(&self, _policy: &SandboxPolicy) -> Result<(), SandboxError> {
Ok(())
}
fn wrap(&self, cmd: &mut Command, policy: &SandboxPolicy) -> Result<(), SandboxError> {
if policy.profile == SandboxProfile::Off {
return Ok(());
}
let (owned_fd, fd_num) = write_bytes_to_tmpfd(&self.bpf_bytes)?;
*self.pending_fd.lock().expect("pending_fd lock poisoned") = Some(owned_fd);
rewrite_with_bwrap(cmd, &self.bwrap_path, policy, fd_num);
Ok(())
}
}
fn locate_bwrap() -> Option<PathBuf> {
for candidate in &["/usr/bin/bwrap", "/usr/local/bin/bwrap"] {
let p = PathBuf::from(candidate);
if p.exists() {
return Some(p);
}
}
std::env::var_os("PATH").and_then(|path_var| {
std::env::split_paths(&path_var).find_map(|dir| {
let candidate = dir.join("bwrap");
if candidate.exists() {
Some(candidate)
} else {
None
}
})
})
}
fn compile_bpf_bytes() -> Result<Vec<u8>, SandboxError> {
let arch = target_arch();
let rules = escalation_deny_rules();
let filter = SeccompFilter::new(
rules,
SeccompAction::Allow,
SeccompAction::Errno(libc_eperm()),
arch,
)
.map_err(|e| SandboxError::Policy(format!("seccomp filter build failed: {e}")))?;
let prog: BpfProgram = filter
.try_into()
.map_err(|e| SandboxError::Policy(format!("seccomp BPF compilation failed: {e}")))?;
let mut bytes = Vec::with_capacity(prog.len() * 8);
for insn in &prog {
bytes.extend_from_slice(&insn.code.to_ne_bytes());
bytes.push(insn.jt);
bytes.push(insn.jf);
bytes.extend_from_slice(&insn.k.to_ne_bytes());
}
Ok(bytes)
}
fn target_arch() -> TargetArch {
#[cfg(target_arch = "aarch64")]
{
TargetArch::aarch64
}
#[cfg(not(target_arch = "aarch64"))]
{
TargetArch::x86_64
}
}
fn escalation_deny_syscalls() -> &'static [i64] {
#[cfg(target_arch = "aarch64")]
{
&[
217, 280, 273, 105, 104, 219, 40, 241, 41, 117, 142, 218, 225, 224, 39, 282, ]
}
#[cfg(not(target_arch = "aarch64"))]
{
&[
248, 321, 313, 175, 246, 250, 165, 298, 155, 101, 169, 249, 168, 167, 166, 323, ]
}
}
fn escalation_deny_rules() -> std::collections::BTreeMap<i64, Vec<seccompiler::SeccompRule>> {
escalation_deny_syscalls()
.iter()
.filter_map(|&nr| {
seccompiler::SeccompRule::new(vec![])
.ok()
.map(|rule| (nr, vec![rule]))
})
.collect()
}
const fn libc_eperm() -> u32 {
1
}
fn write_bytes_to_tmpfd(bpf_bytes: &[u8]) -> Result<(OwnedFd, i32), SandboxError> {
use std::io::Write as _;
let mut tmp = tempfile::tempfile().map_err(SandboxError::Setup)?;
tmp.write_all(bpf_bytes).map_err(SandboxError::Setup)?;
tmp.flush().map_err(SandboxError::Setup)?;
use std::io::Seek as _;
tmp.seek(std::io::SeekFrom::Start(0))
.map_err(SandboxError::Setup)?;
use std::os::unix::io::AsRawFd as _;
let owned: OwnedFd = tmp.into();
let fd_num = owned.as_raw_fd();
Ok((owned, fd_num))
}
fn rewrite_with_bwrap(cmd: &mut Command, bwrap: &Path, policy: &SandboxPolicy, seccomp_fd: i32) {
let std_cmd = cmd.as_std_mut();
let original_program = std_cmd.get_program().to_os_string();
let original_args: Vec<std::ffi::OsString> =
std_cmd.get_args().map(|a| a.to_os_string()).collect();
let mut bwrap_args: Vec<std::ffi::OsString> = Vec::new();
bwrap_args.extend(
[
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
]
.map(Into::into),
);
if !policy.allow_network && policy.profile != SandboxProfile::NetworkAllowAll {
bwrap_args.push("--unshare-net".into());
}
for ro in &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"] {
let p = Path::new(ro);
if p.exists() {
bwrap_args.extend(["--ro-bind".into(), ro.into(), ro.into()]);
}
}
bwrap_args.extend(["--proc".into(), "/proc".into()]);
bwrap_args.extend(["--dev".into(), "/dev".into()]);
bwrap_args.extend(["--tmpfs".into(), "/tmp".into()]);
for path in &policy.allow_read {
let p = path.display().to_string();
bwrap_args.extend(["--ro-bind".into(), p.clone().into(), p.into()]);
}
for path in &policy.allow_write {
let p = path.display().to_string();
bwrap_args.extend(["--bind".into(), p.clone().into(), p.into()]);
}
bwrap_args.push("--seccomp".into());
bwrap_args.push(seccomp_fd.to_string().into());
bwrap_args.push("--".into());
bwrap_args.push(original_program);
for arg in original_args {
bwrap_args.push(arg);
}
*std_cmd = std::process::Command::new(bwrap);
for arg in bwrap_args {
std_cmd.arg(arg);
}
}
pub fn apply_landlock(policy: &SandboxPolicy) -> Result<RestrictionStatus, SandboxError> {
let abi = landlock::ABI::V4;
let ruleset = Ruleset::default()
.handle_access(AccessFs::from_all(abi))
.map_err(|e| SandboxError::Policy(format!("landlock handle_access: {e}")))?
.create()
.map_err(|e| SandboxError::Policy(format!("landlock create: {e}")))?;
let mut ruleset = ruleset;
let read_access = AccessFs::ReadFile | AccessFs::ReadDir | AccessFs::Execute;
for path in &policy.allow_read {
if path.exists() {
let fd =
PathFd::new(path).map_err(|e| SandboxError::Setup(std::io::Error::other(e)))?;
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_access))
.map_err(|e| SandboxError::Policy(format!("landlock add_rule read: {e}")))?;
}
}
let write_access = read_access | AccessFs::WriteFile | AccessFs::MakeDir | AccessFs::MakeReg;
for path in &policy.allow_write {
if path.exists() {
let fd =
PathFd::new(path).map_err(|e| SandboxError::Setup(std::io::Error::other(e)))?;
ruleset = ruleset
.add_rule(PathBeneath::new(fd, write_access))
.map_err(|e| SandboxError::Policy(format!("landlock add_rule write: {e}")))?;
}
}
let sys_read = AccessFs::ReadFile | AccessFs::ReadDir | AccessFs::Execute;
for sys_path in &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"] {
let p = Path::new(sys_path);
if p.exists() {
let fd = PathFd::new(p).map_err(|e| SandboxError::Setup(std::io::Error::other(e)))?;
ruleset = ruleset
.add_rule(PathBeneath::new(fd, sys_read))
.map_err(|e| {
SandboxError::Policy(format!("landlock add_rule sys {sys_path}: {e}"))
})?;
}
}
let status = ruleset
.restrict_self()
.map_err(|e| SandboxError::Policy(format!("landlock restrict_self: {e}")))?;
Ok(status)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locate_bwrap_returns_none_when_absent() {
let _ = locate_bwrap();
}
#[cfg(all(target_os = "linux", feature = "sandbox"))]
#[test]
fn landlock_restriction_isolated_to_spawned_thread() {
use std::sync::Arc;
assert!(
std::path::Path::new("/etc/hostname").exists(),
"/etc/hostname must exist for this test"
);
let policy = SandboxPolicy {
profile: SandboxProfile::Workspace,
allow_read: vec![std::path::PathBuf::from("/tmp")],
allow_write: vec![std::path::PathBuf::from("/tmp")],
allow_network: false,
..Default::default()
};
let policy = Arc::new(policy);
let policy_clone = Arc::clone(&policy);
let handle = std::thread::spawn(move || {
let status = apply_landlock(&policy_clone);
status
});
let result = handle.join().expect("landlock thread should not panic");
drop(result);
assert!(
std::path::Path::new("/etc/hostname").exists(),
"main thread must retain filesystem access after spawned thread applies landlock"
);
}
#[cfg(all(target_os = "linux", feature = "sandbox"))]
#[test]
fn bwrap_landlock_path_isolation() {
use std::fs;
use std::process::Stdio;
if locate_bwrap().is_none() {
eprintln!("bwrap not installed — skipping bwrap_landlock_path_isolation");
return;
}
let tmp = tempfile::TempDir::new().expect("TempDir");
let allowed_path = tmp.path().join("sandbox-ro-allowed");
let denied_path = tmp.path().join("sandbox-ro-denied");
fs::write(&allowed_path, "allowed-content").expect("write allowed");
fs::write(&denied_path, "denied-content").expect("write denied");
let bwrap = locate_bwrap().expect("bwrap present");
let out_a = std::process::Command::new(&bwrap)
.args([
"--unshare-user",
"--unshare-pid",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/bin",
"/bin",
"--ro-bind",
"/lib",
"/lib",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--ro-bind",
allowed_path.to_str().expect("utf8"),
allowed_path.to_str().expect("utf8"),
"--",
"cat",
])
.arg(&allowed_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("spawn bwrap for allowed read");
assert!(
out_a.status.success(),
"child should read allowed path; stderr: {}",
String::from_utf8_lossy(&out_a.stderr)
);
assert!(
String::from_utf8_lossy(&out_a.stdout).contains("allowed-content"),
"allowed path content mismatch"
);
let out_b = std::process::Command::new(&bwrap)
.args([
"--unshare-user",
"--unshare-pid",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/bin",
"/bin",
"--ro-bind",
"/lib",
"/lib",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--",
"cat",
])
.arg(&denied_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("spawn bwrap for denied read");
assert!(
!out_b.status.success(),
"child should fail to read denied path; stdout: {}",
String::from_utf8_lossy(&out_b.stdout)
);
assert!(
std::path::Path::new("/etc/hostname").exists(),
"parent test process must retain full filesystem access after child exits"
);
}
}