use crate::policy::{AccessPolicy, NetworkAccess, ReadAccess, UnixSocketAccess};
use crate::trap::{Result, Trap};
use crate::trap_fd::TrapFd;
use std::ffi::{CStr, CString, OsStr, OsString};
use std::fmt::{self, Write};
use std::fs;
use std::os::fd::RawFd;
use std::os::unix::fs::FileTypeExt;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::ptr;
const SBPL_PROFILE_FLAGS: u64 = 0;
const FIRST_INHERITED_FD: RawFd = 3;
const FALLBACK_FD_LIMIT: RawFd = 1_048_576;
pub(crate) fn execute(
policy: &AccessPolicy,
tool: &OsStr,
args: &[OsString],
trap_fd: &TrapFd,
) -> Result<()> {
reject_unsupported_policy(policy)?;
let profile = render_profile(policy).map_err(Trap::policy_stdin_source)?;
apply_profile(&profile)?;
trap_fd.close();
close_inherited_fds();
let error = Command::new(tool).args(args).exec();
Err(Trap::tool_exec(Some(tool.to_os_string()), &error))
}
fn reject_unsupported_policy(policy: &AccessPolicy) -> Result<()> {
reject_unsupported_read_policy(policy)?;
reject_unsupported_unix_socket_policy(policy)?;
reject_unsupported_symlink_write_policy(policy)?;
Ok(())
}
fn reject_unsupported_read_policy(policy: &AccessPolicy) -> Result<()> {
let ReadAccess::AllowRoots(roots) = &policy.read_access else {
return Ok(());
};
if roots.iter().any(|root| root == Path::new("/")) {
return Ok(());
}
Err(Trap::internal().with_detail("feature", "partial read access"))
}
fn reject_unsupported_unix_socket_policy(policy: &AccessPolicy) -> Result<()> {
let UnixSocketAccess::AllowPaths(paths) = &policy.network_access.unix_socket_access else {
return Ok(());
};
for path in paths {
let Ok(metadata) = fs::symlink_metadata(path) else {
return Err(Trap::internal()
.with_detail("feature", "Unix socket path")
.with_detail("path", path.to_string_lossy()));
};
if !metadata.file_type().is_socket() {
return Err(Trap::internal()
.with_detail("feature", "Unix socket path")
.with_detail("path", path.to_string_lossy()));
}
}
Ok(())
}
fn reject_unsupported_symlink_write_policy(policy: &AccessPolicy) -> Result<()> {
let has_writable_symlink_ancestor = policy.write_denied_links.iter().any(|link| {
policy
.write_roots
.iter()
.any(|root| link == root || link.starts_with(root))
});
if has_writable_symlink_ancestor {
return Err(Trap::internal().with_detail("feature", "denyWrite symlink ancestor"));
}
Ok(())
}
fn close_inherited_fds() {
if let Ok(mut entries) = fs::read_dir("/dev/fd") {
let mut fds = Vec::new();
for entry in entries.by_ref().flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Ok(fd) = name.parse::<RawFd>() else {
continue;
};
if fd >= FIRST_INHERITED_FD {
fds.push(fd);
}
}
drop(entries);
fds.sort_unstable();
fds.dedup();
for fd in fds {
close_fd(fd);
}
return;
}
for fd in FIRST_INHERITED_FD..open_fd_limit() {
close_fd(fd);
}
}
fn open_fd_limit() -> RawFd {
let limit = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) };
RawFd::try_from(limit).map_or(FALLBACK_FD_LIMIT, |limit| {
limit.clamp(FIRST_INHERITED_FD, FALLBACK_FD_LIMIT)
})
}
fn close_fd(fd: RawFd) {
if fd >= FIRST_INHERITED_FD {
unsafe { libc::close(fd) };
}
}
fn apply_profile(profile: &str) -> Result<()> {
let profile = CString::new(profile).map_err(|source| {
let nul_position = source.nul_position();
Trap::internal()
.with_detail("offset", nul_position.to_string())
.with_detail("mechanism", "sbpl")
})?;
let mut errorbuf = ptr::null_mut();
let rc = unsafe { ffi::sandbox_init(profile.as_ptr(), SBPL_PROFILE_FLAGS, &raw mut errorbuf) };
if rc == 0 {
Ok(())
} else {
Err(Trap::internal()
.with_detail("source", take_sandbox_error(errorbuf))
.with_detail("mechanism", "sbpl"))
}
}
fn render_profile(policy: &AccessPolicy) -> std::result::Result<String, fmt::Error> {
let mut sb = String::new();
writeln!(sb, "(version 1)")?;
writeln!(sb, "(deny default)")?;
render_process_rules(&mut sb)?;
render_write_rules(&mut sb, &policy.write_roots, &policy.write_denied_roots)?;
render_read_rules(&mut sb, &policy.read_access)?;
render_network_rules(&mut sb, &policy.network_access)?;
Ok(sb)
}
fn render_process_rules(sb: &mut String) -> fmt::Result {
writeln!(sb, "(allow process-exec)")?;
writeln!(sb, "(allow process-fork)")?;
writeln!(sb, "(allow sysctl-read)")
}
fn render_write_rules(
sb: &mut String,
write_roots: &[PathBuf],
write_denied_roots: &[PathBuf],
) -> fmt::Result {
for root in write_roots {
let escaped = escape_sbpl_literal(&root.to_string_lossy());
writeln!(sb, "(allow file-write* (subpath \"{escaped}\"))")?;
}
for root in write_denied_roots {
let escaped = escape_sbpl_literal(&root.to_string_lossy());
writeln!(sb, "(deny file-write* (subpath \"{escaped}\"))")?;
}
Ok(())
}
fn render_read_rules(sb: &mut String, read_access: &ReadAccess) -> fmt::Result {
match read_access {
ReadAccess::Unrestricted => sb.push_str("(allow file-read*)\n"),
ReadAccess::AllowRoots(roots) => {
writeln!(sb, "(deny file-read*)")?;
writeln!(sb, "(allow file-read* (literal \"/\"))")?;
for root in roots {
let escaped = escape_sbpl_literal(&root.to_string_lossy());
writeln!(sb, "(allow file-read* (subpath \"{escaped}\"))")?;
}
render_parent_dir_rules(sb, roots)?;
}
}
Ok(())
}
fn render_parent_dir_rules(sb: &mut String, roots: &[PathBuf]) -> fmt::Result {
let mut ancestors: Vec<PathBuf> = Vec::new();
for root in roots {
let mut current = root.as_path();
while let Some(parent) = current.parent() {
if parent.as_os_str().is_empty() {
break;
}
ancestors.push(parent.to_path_buf());
if let Ok(real) = std::fs::canonicalize(parent) {
ancestors.push(real);
}
current = parent;
}
}
ancestors.sort_unstable();
ancestors.dedup();
for ancestor in &ancestors {
let escaped = escape_sbpl_literal(&ancestor.to_string_lossy());
writeln!(sb, "(allow file-read* (literal \"{escaped}\"))")?;
}
Ok(())
}
fn render_network_rules(sb: &mut String, network: &NetworkAccess) -> fmt::Result {
if network.is_unrestricted() {
sb.push_str("(allow network*)\n");
return Ok(());
}
if network.restrict_connect_tcp {
sb.push_str("(deny network-outbound)\n");
for port in &network.connect_tcp_ports {
writeln!(
sb,
"(allow network-outbound (remote tcp \"localhost:{port}\"))"
)?;
}
}
if network.restrict_bind_tcp {
sb.push_str("(deny network-bind)\n");
sb.push_str("(deny network-inbound)\n");
}
if network.local_tcp_bind {
sb.push_str("(allow network-bind (local tcp \"localhost:*\"))\n");
sb.push_str("(allow network-inbound (local tcp \"localhost:*\"))\n");
}
match &network.unix_socket_access {
UnixSocketAccess::Unrestricted => {
sb.push_str("(allow network-outbound (remote unix-socket))\n");
sb.push_str("(allow network-bind (local unix-socket))\n");
}
UnixSocketAccess::AllowPaths(paths) => {
for path in paths {
let escaped = escape_sbpl_literal(&path.to_string_lossy());
writeln!(
sb,
"(allow network-outbound (remote unix-socket (path-literal \"{escaped}\")))"
)?;
writeln!(
sb,
"(allow network-bind (local unix-socket (path-literal \"{escaped}\")))"
)?;
}
}
}
Ok(())
}
fn escape_sbpl_literal(path: &str) -> String {
let mut escaped = String::with_capacity(path.len());
for ch in path.chars() {
match ch {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
_ => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::escape_sbpl_literal;
#[test]
fn escape_sbpl_literal_preserves_parentheses() {
assert_eq!(escape_sbpl_literal("/tmp/App (Beta)"), "/tmp/App (Beta)");
}
#[test]
fn escape_sbpl_literal_escapes_string_delimiters() {
assert_eq!(escape_sbpl_literal("/tmp/a\"b"), "/tmp/a\\\"b");
assert_eq!(escape_sbpl_literal("/tmp/a\\b"), "/tmp/a\\\\b");
assert_eq!(escape_sbpl_literal("/tmp/a\nb"), "/tmp/a\\nb");
}
}
fn take_sandbox_error(errorbuf: *mut libc::c_char) -> String {
if errorbuf.is_null() {
return "sandbox_init failed without an error message".to_string();
}
let message = unsafe { CStr::from_ptr(errorbuf) }
.to_string_lossy()
.into_owned();
unsafe { ffi::sandbox_free_error(errorbuf) };
message
}
mod ffi {
use libc::{c_char, c_int};
#[link(name = "sandbox")]
unsafe extern "C" {
pub(super) fn sandbox_init(
profile: *const c_char,
flags: u64,
errorbuf: *mut *mut c_char,
) -> c_int;
pub(super) fn sandbox_free_error(errorbuf: *mut c_char);
}
}