use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Clone, Default)]
pub struct SandboxPolicy {
pub deny_network: bool,
pub confine_writes: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Enforcement {
SelfApplied { fs_enforced: bool },
ExecArgv(Vec<OsString>),
Ran(i32),
}
pub fn enforce(policy: &SandboxPolicy, argv: &[OsString]) -> anyhow::Result<Enforcement> {
if !policy.deny_network && policy.confine_writes.is_none() {
return Ok(Enforcement::SelfApplied { fs_enforced: true });
}
#[cfg(target_os = "linux")]
{
use anyhow::Context as _;
let _ = argv;
let mut fs_enforced = true;
if let Some(roots) = &policy.confine_writes {
fs_enforced =
linux::apply_fs_confinement(roots).context("filesystem sandbox unavailable")?;
}
if policy.deny_network {
linux::apply_network_killswitch().context("network sandbox unavailable")?;
}
Ok(Enforcement::SelfApplied { fs_enforced })
}
#[cfg(target_os = "macos")]
{
anyhow::ensure!(
macos::sandbox_exec_present(),
"{} not found; refusing to run the command unconfined",
macos::SANDBOX_EXEC
);
Ok(Enforcement::ExecArgv(macos::wrap_argv(policy, argv)))
}
#[cfg(target_os = "windows")]
{
use anyhow::Context as _;
anyhow::ensure!(
windows::appcontainer_available(),
"Windows AppContainer is unavailable on this system; refusing to run unconfined"
);
let exit_code = windows::run_in_appcontainer(policy, argv)
.context("Windows AppContainer sandbox execution failed")?;
Ok(Enforcement::Ran(exit_code))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = argv;
anyhow::bail!("no OS sandbox backend on this platform; refusing to run unconfined")
}
}
pub fn enforce_read_only(argv: &[OsString]) -> anyhow::Result<Enforcement> {
#[cfg(target_os = "linux")]
{
let _ = argv;
linux::apply_read_only()?;
Ok(Enforcement::SelfApplied { fs_enforced: true })
}
#[cfg(target_os = "macos")]
{
anyhow::ensure!(
macos::sandbox_exec_present(),
"{} not found; refusing to run the command unconfined",
macos::SANDBOX_EXEC
);
Ok(Enforcement::ExecArgv(macos::wrap_read_only_argv(argv)))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = argv;
anyhow::bail!("read-only containment is not available on this platform")
}
}
#[must_use]
pub fn read_only_containment_available() -> bool {
#[cfg(target_os = "linux")]
{
linux::read_only_supported()
}
#[cfg(target_os = "macos")]
{
macos::read_only_supported()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
false
}
}
#[must_use]
pub fn network_killswitch_available() -> bool {
#[cfg(target_os = "linux")]
{
linux::network_filter().is_ok()
}
#[cfg(target_os = "macos")]
{
macos::sandbox_exec_present()
}
#[cfg(target_os = "windows")]
{
windows::appcontainer_available()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
false
}
}
#[must_use]
pub fn fs_confinement_available() -> bool {
#[cfg(target_os = "linux")]
{
linux::fs_ruleset_builds()
}
#[cfg(target_os = "macos")]
{
macos::sandbox_exec_present()
}
#[cfg(target_os = "windows")]
{
windows::appcontainer_available()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
false
}
}
#[cfg(target_os = "windows")]
mod windows;
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod macos {
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use super::SandboxPolicy;
pub(super) const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
pub(super) fn sandbox_exec_present() -> bool {
Path::new(SANDBOX_EXEC).exists()
}
pub(super) fn profile(policy: &SandboxPolicy) -> (String, Vec<(String, PathBuf)>) {
let mut sbpl = String::from("(version 1)\n(allow default)\n");
if policy.deny_network {
sbpl.push_str("(deny network*)\n");
sbpl.push_str("(allow network* (remote unix))\n");
sbpl.push_str("(allow network* (local unix))\n");
}
let mut params: Vec<(String, PathBuf)> = Vec::new();
if let Some(roots) = &policy.confine_writes {
for (n, root) in roots.iter().enumerate() {
if !root.exists() {
continue;
}
params.push((format!("WR{n}"), root.clone()));
if let Ok(canonical) = std::fs::canonicalize(root)
&& canonical != *root
{
params.push((format!("WR{n}C"), canonical));
}
}
if params.is_empty() {
sbpl.push_str("(deny file-write*)\n");
} else {
sbpl.push_str("(deny file-write* (require-all");
for (name, _) in ¶ms {
sbpl.push_str(&format!(" (require-not (subpath (param \"{name}\")))"));
}
sbpl.push_str("))\n");
}
}
(sbpl, params)
}
pub(super) const READ_ONLY_PROFILE: &str = r#"(version 1)
(deny default)
(allow process-exec)
(allow process-fork)
(allow process-info* (target same-sandbox))
(allow signal (target same-sandbox))
(allow file-read*)
(allow sysctl-read)
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.cfprefsd.daemon")
(global-name "com.apple.cfprefsd.agent"))
(allow user-preference-read)
(allow file-write-data
(literal "/dev/null")
(literal "/dev/zero")
(literal "/dev/tty")
(regex #"^/dev/ttys[0-9]+$"))
(allow file-ioctl
(literal "/dev/tty")
(regex #"^/dev/ttys[0-9]+$"))
"#;
pub(super) fn wrap_read_only_argv(argv: &[OsString]) -> Vec<OsString> {
let mut wrapped: Vec<OsString> = vec![
SANDBOX_EXEC.into(),
"-p".into(),
READ_ONLY_PROFILE.into(),
"--".into(),
];
wrapped.extend(argv.iter().cloned());
wrapped
}
pub(super) fn read_only_supported() -> bool {
sandbox_exec_present()
&& std::process::Command::new(SANDBOX_EXEC)
.args(["-p", READ_ONLY_PROFILE, "--", "/bin/sh", "-c", ":"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
pub(super) fn wrap_argv(policy: &SandboxPolicy, argv: &[OsString]) -> Vec<OsString> {
let (sbpl, params) = profile(policy);
let mut wrapped: Vec<OsString> = vec![SANDBOX_EXEC.into(), "-p".into(), sbpl.into()];
for (name, value) in params {
wrapped.push("-D".into());
let mut kv = OsString::from(format!("{name}="));
kv.push(value.as_os_str());
wrapped.push(kv);
}
wrapped.push("--".into());
wrapped.extend(argv.iter().cloned());
wrapped
}
#[cfg(test)]
mod tests {
use super::*;
fn policy(deny_network: bool, confine_writes: Option<&[PathBuf]>) -> SandboxPolicy {
SandboxPolicy {
deny_network,
confine_writes: confine_writes.map(<[PathBuf]>::to_vec),
}
}
fn tempdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"mermaid-sbpl-test-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn network_denial_lines_present_iff_requested() {
let (with_net, _) = profile(&policy(true, None));
assert!(with_net.contains("(deny network*)"));
assert!(with_net.contains("(allow network* (remote unix))"));
assert!(with_net.contains("(allow network* (local unix))"));
assert!(!with_net.contains("file-write"));
let dir = tempdir("no-net");
let (without_net, _) = profile(&policy(false, Some(std::slice::from_ref(&dir))));
assert!(!without_net.contains("network"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn empty_confinement_denies_every_write() {
let (sbpl, params) = profile(&policy(false, Some(&[])));
assert!(params.is_empty(), "no roots to parameterize: {params:?}");
assert!(
sbpl.contains("(deny file-write*)"),
"an empty allowlist is a deny-all, not a no-op: {sbpl:?}"
);
}
#[test]
fn write_denial_nests_require_nots_under_require_all() {
let a = tempdir("ra-a");
let b = tempdir("ra-b");
let (sbpl, params) = profile(&policy(false, Some(&[a.clone(), b.clone()])));
assert!(params.iter().any(|(n, _)| n == "WR0"));
assert!(params.iter().any(|(n, _)| n == "WR1"));
let deny = sbpl
.lines()
.find(|l| l.starts_with("(deny file-write*"))
.expect("deny file-write* line");
assert!(deny.starts_with("(deny file-write* (require-all (require-not"));
assert_eq!(
deny.matches("(require-not (subpath (param ").count(),
params.len(),
"one require-not per emitted param: {deny}"
);
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn dual_literal_and_canonical_params_when_they_differ() {
let real = tempdir("canon");
let sub = real.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let alias = sub.join("..");
let (sbpl, params) = profile(&policy(false, Some(std::slice::from_ref(&alias))));
let literal = params.iter().find(|(n, _)| n == "WR0").expect("literal");
let canonical = params.iter().find(|(n, _)| n == "WR0C").expect("canonical");
assert_eq!(literal.1, alias);
assert_ne!(canonical.1, alias);
assert!(sbpl.contains("(param \"WR0\")"));
assert!(sbpl.contains("(param \"WR0C\")"));
let _ = std::fs::remove_dir_all(&real);
}
#[test]
fn nonexistent_roots_are_skipped_and_all_missing_denies_all_writes() {
let missing = std::env::temp_dir().join("mermaid-sbpl-test-definitely-missing");
let (sbpl, params) = profile(&policy(false, Some(std::slice::from_ref(&missing))));
assert!(params.is_empty());
assert!(sbpl.contains("(deny file-write*)\n"));
assert!(!sbpl.contains("require-all"));
}
#[test]
fn paths_never_reach_the_profile_string() {
let evil = tempdir("evil").join("x) (allow default) (deny");
std::fs::create_dir_all(&evil).unwrap();
let (sbpl, params) = profile(&policy(true, Some(std::slice::from_ref(&evil))));
assert!(!sbpl.contains("allow default) (deny"));
assert!(
params.iter().any(|(_, v)| *v == evil),
"the path must ride a param instead"
);
let _ = std::fs::remove_dir_all(evil.parent().unwrap());
}
#[test]
fn read_only_profile_denies_by_default_and_never_allows_writes_broadly() {
let profile = READ_ONLY_PROFILE;
assert!(profile.starts_with("(version 1)\n(deny default)\n"));
assert!(!profile.contains("network"));
assert!(!profile.contains("file-write*"));
assert!(!profile.contains("(allow default)"));
assert!(profile.contains("(allow signal (target same-sandbox))"));
assert!(!profile.contains("appleevent"));
}
#[test]
fn read_only_argv_puts_the_profile_before_the_command() {
let argv: Vec<OsString> = vec!["sh".into(), "-c".into(), "ls".into()];
let wrapped = wrap_read_only_argv(&argv);
assert_eq!(wrapped[0], OsString::from(SANDBOX_EXEC));
assert_eq!(wrapped[1], OsString::from("-p"));
assert_eq!(wrapped[2], OsString::from(READ_ONLY_PROFILE));
assert_eq!(wrapped[3], OsString::from("--"));
assert_eq!(&wrapped[4..], argv.as_slice());
}
#[test]
fn wrap_argv_shape_is_frozen() {
let dir = tempdir("argv");
let argv: Vec<OsString> = vec!["sh".into(), "-c".into(), "echo hi".into()];
let wrapped = wrap_argv(&policy(true, Some(std::slice::from_ref(&dir))), &argv);
assert_eq!(wrapped[0], OsString::from(SANDBOX_EXEC));
assert_eq!(wrapped[1], OsString::from("-p"));
let profile_arg = wrapped[2].to_string_lossy();
assert!(profile_arg.starts_with("(version 1)\n(allow default)\n"));
assert_eq!(wrapped[3], OsString::from("-D"));
let kv = wrapped[4].to_string_lossy();
assert!(kv.starts_with("WR0="), "param assignment: {kv}");
let sep = wrapped
.iter()
.position(|a| a == "--")
.expect("-- separator");
assert_eq!(&wrapped[sep + 1..], argv.as_slice());
let _ = std::fs::remove_dir_all(&dir);
}
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::collections::BTreeMap;
use anyhow::Context;
use seccompiler::{
BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
SeccompRule, apply_filter,
};
pub(super) fn network_filter() -> anyhow::Result<BpfProgram> {
let inet = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_INET as u64,
)?])?;
let inet6 = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_INET6 as u64,
)?])?;
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
rules.insert(libc::SYS_socket, vec![inet, inet6]);
let filter = SeccompFilter::new(
rules,
SeccompAction::Allow, SeccompAction::KillProcess, std::env::consts::ARCH
.try_into()
.context("seccomp: unsupported target arch")?,
)
.context("seccomp: build network filter")?;
let program: BpfProgram = filter.try_into().context("seccomp: assemble network BPF")?;
Ok(program)
}
pub fn apply_network_killswitch() -> anyhow::Result<()> {
let program = network_filter()?;
apply_filter(&program).context("seccomp: install network filter")?;
Ok(())
}
const LANDLOCK_ABI: landlock::ABI = landlock::ABI::V3;
pub(super) fn apply_fs_confinement(
allowed_writes: &[std::path::PathBuf],
) -> anyhow::Result<bool> {
use landlock::{
AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, RulesetCreatedAttr,
RulesetStatus, path_beneath_rules,
};
let write_access = AccessFs::from_write(LANDLOCK_ABI);
let file_access = write_access & AccessFs::from_file(LANDLOCK_ABI);
let (files, dirs): (Vec<_>, Vec<_>) = allowed_writes
.iter()
.cloned()
.partition(|p| p.is_file() || p.symlink_metadata().is_ok_and(|m| !m.is_dir()));
let status = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(write_access)
.context("landlock: handle write access")?
.create()
.context("landlock: create ruleset")?
.add_rules(path_beneath_rules(&dirs, write_access))
.context("landlock: add write rules")?
.add_rules(path_beneath_rules(&files, file_access))
.context("landlock: add file write rules")?
.restrict_self()
.context("landlock: restrict self")?;
Ok(status.ruleset != RulesetStatus::NotEnforced)
}
pub(super) fn fs_ruleset_builds() -> bool {
use landlock::{AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr};
Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(AccessFs::from_write(LANDLOCK_ABI))
.and_then(|r| r.create())
.is_ok()
}
const READ_ONLY_WRITABLE: &[&str] = &[
"/dev/null",
"/dev/zero",
"/dev/full",
"/dev/tty",
"/dev/ptmx",
"/dev/pts",
];
const SYS_FCHMODAT2: i64 = 452;
const SYS_SETXATTRAT: i64 = 463;
const SYS_REMOVEXATTRAT: i64 = 466;
const ATTR_SETTING_IOCTLS: &[u64] = &[0x4008_6602, 0x4004_6602, 0x401c_5820];
fn read_only_denied_syscalls() -> Vec<i64> {
let mut denied = legacy_metadata_syscalls();
denied.extend([
libc::SYS_socket,
libc::SYS_io_uring_setup,
libc::SYS_io_uring_enter,
libc::SYS_io_uring_register,
libc::SYS_msgget,
libc::SYS_msgsnd,
libc::SYS_msgrcv,
libc::SYS_msgctl,
libc::SYS_semget,
libc::SYS_semop,
libc::SYS_semtimedop,
libc::SYS_semctl,
libc::SYS_shmget,
libc::SYS_shmat,
libc::SYS_shmctl,
libc::SYS_mq_open,
libc::SYS_mq_unlink,
libc::SYS_mq_timedsend,
libc::SYS_mq_timedreceive,
libc::SYS_mq_notify,
libc::SYS_mq_getsetattr,
libc::SYS_add_key,
libc::SYS_request_key,
libc::SYS_keyctl,
libc::SYS_fchmod,
libc::SYS_fchmodat,
SYS_FCHMODAT2,
libc::SYS_fchown,
libc::SYS_fchownat,
libc::SYS_setxattr,
libc::SYS_lsetxattr,
libc::SYS_fsetxattr,
libc::SYS_removexattr,
libc::SYS_lremovexattr,
libc::SYS_fremovexattr,
SYS_SETXATTRAT,
SYS_REMOVEXATTRAT,
libc::SYS_utimensat,
]);
denied
}
fn legacy_metadata_syscalls() -> Vec<i64> {
#[cfg(target_arch = "x86_64")]
{
vec![
libc::SYS_chmod,
libc::SYS_chown,
libc::SYS_lchown,
libc::SYS_utime,
libc::SYS_utimes,
libc::SYS_futimesat,
]
}
#[cfg(not(target_arch = "x86_64"))]
{
Vec::new()
}
}
#[cfg(target_arch = "x86_64")]
fn x32_filter() -> BpfProgram {
use seccompiler::sock_filter;
const BPF_LD_W_ABS: u16 = 0x20;
const BPF_JMP_JGE_K: u16 = 0x35;
const BPF_RET_K: u16 = 0x06;
const X32_SYSCALL_BIT: u32 = 0x4000_0000;
let insn = |code, jt, jf, k| sock_filter { code, jt, jf, k };
vec![
insn(BPF_LD_W_ABS, 0, 0, 0),
insn(BPF_JMP_JGE_K, 0, 1, X32_SYSCALL_BIT),
insn(
BPF_RET_K,
0,
0,
libc::SECCOMP_RET_ERRNO | libc::EPERM as u32,
),
insn(BPF_RET_K, 0, 0, libc::SECCOMP_RET_ALLOW),
]
}
pub(super) fn read_only_filter() -> anyhow::Result<BpfProgram> {
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = read_only_denied_syscalls()
.into_iter()
.map(|nr| (nr, Vec::new()))
.collect();
let ioctl_rules = ATTR_SETTING_IOCTLS
.iter()
.map(|&request| {
SeccompRule::new(vec![SeccompCondition::new(
1,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
request,
)?])
})
.collect::<Result<Vec<_>, _>>()?;
rules.insert(libc::SYS_ioctl, ioctl_rules);
let filter = SeccompFilter::new(
rules,
SeccompAction::Allow,
SeccompAction::Errno(libc::EPERM as u32),
std::env::consts::ARCH
.try_into()
.context("seccomp: unsupported target arch")?,
)
.context("seccomp: build read-only filter")?;
let program: BpfProgram = filter
.try_into()
.context("seccomp: assemble read-only BPF")?;
Ok(program)
}
fn read_only_ruleset() -> anyhow::Result<landlock::RulesetCreated> {
use landlock::{
ABI, Access, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, Scope,
};
Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(AccessFs::from_write(LANDLOCK_ABI))
.context("landlock: handle write access")?
.scope(Scope::from_all(ABI::V6))
.context("landlock: scope signals and abstract sockets")?
.create()
.context("landlock: create read-only ruleset")
}
pub(super) fn read_only_supported() -> bool {
read_only_ruleset().is_ok() && network_filter().is_ok() && read_only_filter().is_ok()
}
fn drop_capabilities() -> anyhow::Result<()> {
use rustix::thread::{CapabilitySet, CapabilitySets, set_capabilities};
unsafe {
libc::prctl(
libc::PR_CAP_AMBIENT,
libc::PR_CAP_AMBIENT_CLEAR_ALL,
0,
0,
0,
);
for cap in 0..64 {
if libc::prctl(libc::PR_CAPBSET_DROP, cap, 0, 0, 0) != 0
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::EINVAL)
{
break;
}
}
}
set_capabilities(
None,
CapabilitySets {
effective: CapabilitySet::empty(),
permitted: CapabilitySet::empty(),
inheritable: CapabilitySet::empty(),
},
)
.context("capset: drop every capability")?;
Ok(())
}
pub(super) fn apply_read_only() -> anyhow::Result<()> {
use landlock::{AccessFs, RulesetCreatedAttr, RulesetStatus, path_beneath_rules};
drop_capabilities()?;
let file_write = AccessFs::from_write(LANDLOCK_ABI) & AccessFs::from_file(LANDLOCK_ABI);
let status = read_only_ruleset()?
.add_rules(path_beneath_rules(READ_ONLY_WRITABLE, file_write))
.context("landlock: allow device writes")?
.restrict_self()
.context("landlock: restrict self")?;
anyhow::ensure!(
status.ruleset == RulesetStatus::FullyEnforced,
"landlock: read-only ruleset not fully enforced ({:?})",
status.ruleset
);
apply_network_killswitch()?;
let program = read_only_filter()?;
apply_filter(&program).context("seccomp: install read-only filter")?;
#[cfg(target_arch = "x86_64")]
apply_filter(&x32_filter()).context("seccomp: install x32 filter")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn child_socket_status(bpf: &BpfProgram, domain: libc::c_int) -> libc::c_int {
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork failed");
if pid == 0 {
if apply_filter(bpf).is_err() {
libc::_exit(77);
}
let fd = libc::socket(domain, libc::SOCK_STREAM, 0);
if fd >= 0 {
libc::close(fd);
}
libc::_exit(0);
}
let mut status: libc::c_int = 0;
let waited = libc::waitpid(pid, &mut status, 0);
assert_eq!(waited, pid, "waitpid failed");
status
}
}
#[test]
fn inet_socket_is_killed_with_sigsys() {
let bpf = network_filter().expect("build filter");
let status = child_socket_status(&bpf, libc::AF_INET);
assert!(
libc::WIFSIGNALED(status),
"AF_INET socket should be signal-killed, status={status}"
);
assert_eq!(
libc::WTERMSIG(status),
libc::SIGSYS,
"AF_INET socket should die with SIGSYS"
);
}
#[test]
fn unix_socket_is_allowed() {
let bpf = network_filter().expect("build filter");
let status = child_socket_status(&bpf, libc::AF_UNIX);
assert!(
libc::WIFEXITED(status),
"AF_UNIX socket should exit cleanly, status={status}"
);
assert_eq!(
libc::WEXITSTATUS(status),
0,
"AF_UNIX socket must be allowed under the network kill-switch"
);
}
#[test]
fn fs_confinement_allows_inside_and_denies_outside_writes() {
let base = std::env::temp_dir().join(format!(
"mermaid-landlock-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let allowed = base.join("allowed");
let outside = base.join("outside");
std::fs::create_dir_all(&allowed).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let status = unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork failed");
if pid == 0 {
let code = match apply_fs_confinement(std::slice::from_ref(&allowed)) {
Err(_) => 77,
Ok(false) => 42,
Ok(true) => {
let inside_ok = std::fs::write(allowed.join("in.txt"), b"x").is_ok();
let outside_ok = std::fs::write(outside.join("out.txt"), b"x").is_ok();
match (inside_ok, outside_ok) {
(true, false) => 0,
(false, _) => 10,
(true, true) => 11,
}
},
};
libc::_exit(code);
}
let mut status: libc::c_int = 0;
assert_eq!(libc::waitpid(pid, &mut status, 0), pid, "waitpid failed");
status
};
let _ = std::fs::remove_dir_all(&base);
assert!(
libc::WIFEXITED(status),
"child should exit, status={status}"
);
let code = libc::WEXITSTATUS(status);
if code == 42 {
eprintln!("skipping: kernel does not enforce Landlock");
return;
}
assert_eq!(
code, 0,
"confined child: 10 = allowed write failed, 11 = outside write \
succeeded, 77 = apply failed"
);
}
fn read_only_child_status(probe: impl FnOnce() -> i32) -> libc::c_int {
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork failed");
if pid == 0 {
let code = if apply_read_only().is_ok() {
probe()
} else {
77
};
libc::_exit(code);
}
let mut status: libc::c_int = 0;
assert_eq!(
libc::waitpid(pid, &raw mut status, 0),
pid,
"waitpid failed"
);
status
}
}
fn errno() -> Option<i32> {
std::io::Error::last_os_error().raw_os_error()
}
#[test]
fn read_only_containment_allows_reads_and_denies_changes() {
use std::ffi::CString;
if !read_only_supported() {
eprintln!("skipping: kernel cannot enforce read-only containment");
return;
}
let base = std::env::temp_dir().join(format!(
"mermaid-readonly-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&base).unwrap();
let existing = base.join("existing.txt");
std::fs::write(&existing, b"before").unwrap();
let existing_c = CString::new(existing.to_str().unwrap()).unwrap();
let fresh = base.join("fresh.txt");
let status = read_only_child_status(|| {
unsafe {
if std::fs::read(&existing).ok().as_deref() != Some(b"before".as_slice()) {
return 10;
}
if std::fs::OpenOptions::new()
.write(true)
.open("/dev/null")
.is_err()
{
return 11;
}
if std::fs::write(&fresh, b"x").is_ok() {
return 12;
}
if std::fs::write(&existing, b"after").is_ok() {
return 13;
}
if libc::chmod(existing_c.as_ptr(), 0o777) == 0 || errno() != Some(libc::EPERM)
{
return 14;
}
if libc::utimensat(libc::AT_FDCWD, existing_c.as_ptr(), std::ptr::null(), 0)
== 0
{
return 15;
}
let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
if fd >= 0 || errno() != Some(libc::EPERM) {
return 16;
}
if libc::kill(libc::getppid(), 0) == 0 {
return 17;
}
let caps_dropped = rustix::thread::capabilities(None)
.is_ok_and(|c| c.effective.is_empty() && c.permitted.is_empty());
if !caps_dropped {
return 18;
}
0
}
});
let unchanged = std::fs::read(&existing).unwrap();
let _ = std::fs::remove_dir_all(&base);
assert!(
libc::WIFEXITED(status),
"child should exit, status={status}"
);
assert_eq!(
libc::WEXITSTATUS(status),
0,
"10 read failed, 11 /dev/null write failed, 12 new file written, \
13 existing file written, 14 chmod allowed, 15 utimensat allowed, \
16 unix socket allowed, 17 signal left the sandbox, \
18 capabilities kept, 77 apply failed"
);
assert_eq!(unchanged, b"before");
}
#[test]
fn read_only_containment_kills_internet_sockets() {
if !read_only_supported() {
eprintln!("skipping: kernel cannot enforce read-only containment");
return;
}
let status = read_only_child_status(|| {
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0) };
if fd >= 0 { 1 } else { 2 }
});
assert!(
libc::WIFSIGNALED(status) && libc::WTERMSIG(status) == libc::SIGSYS,
"AF_INET socket should die with SIGSYS, status={status}"
);
}
}
}