mod canon;
mod child;
mod lifetime;
mod probe;
mod spawn;
mod sys;
mod violations;
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::os::fd::{AsRawFd, BorrowedFd};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex, OnceLock};
use procage_policy::lower::GrantSlot;
use procage_policy::{
LowerError, LowerOptions, Plan, Policy, Relax, Relaxed, SeatbeltPlan, Target, Tty, lift, lower,
};
pub use crate::unix::TMPDIR_GRANT;
pub use crate::violation::{Coverage, DeniedBy, Violation, Violations};
pub use child::Child;
pub use probe::{ApplyMethod, probe};
use crate::unix::TmpdirGuard;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Step {
Session,
Rlimit,
Apply,
Verify,
Consume,
Fds,
}
impl Step {
fn from_code(code: u32) -> Option<Step> {
Some(match code {
sys::steps::SESSION => Step::Session,
sys::steps::RLIMIT => Step::Rlimit,
sys::steps::APPLY => Step::Apply,
sys::steps::VERIFY => Step::Verify,
sys::steps::CONSUME => Step::Consume,
sys::steps::FDS => Step::Fds,
_ => return None,
})
}
}
impl fmt::Display for Step {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Step::Session => "new session",
Step::Rlimit => "resource limits",
Step::Apply => "apply profile",
Step::Verify => "verify sandbox",
Step::Consume => "consume grant token",
Step::Fds => "close inherited descriptors",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unsupported {
Symbol {
symbol: &'static str,
os_build: String,
},
ApplyMethod {
os_build: String,
detail: String,
},
Setting {
setting: &'static str,
},
RunMarker {
os_build: String,
detail: String,
},
EnvClear,
}
impl fmt::Display for Unsupported {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Unsupported::Symbol { symbol, os_build } => {
write!(
f,
"libsandbox symbol `{symbol}` is missing on macOS build {os_build}"
)
}
Unsupported::ApplyMethod { os_build, detail } => {
write!(
f,
"the in-child apply method failed its probe on macOS build {os_build}: {detail}"
)
}
Unsupported::Setting { setting } => {
write!(f, "{setting} is not supported in this release")
}
Unsupported::EnvClear => write!(
f,
"EnvPolicy::Passthrough needs to see whether Command::env_clear was called, \
which this Rust standard library does not show; use EnvPolicy::Allowlist"
),
Unsupported::RunMarker { os_build, detail } => {
write!(
f,
"the run-marker query failed its probe on macOS build {os_build}: {detail}"
)
}
}
}
}
#[derive(Debug)]
pub enum GrantError {
Undeclared {
name: String,
},
Missing {
name: String,
path: PathBuf,
error: io::Error,
},
Reserved {
name: String,
},
TmpdirWithExec,
Issue {
name: String,
path: String,
},
}
impl fmt::Display for GrantError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GrantError::Undeclared { name } => {
write!(f, "the policy declares no grant named {name:?}")
}
GrantError::Missing { name, path, error } => {
write!(
f,
"grant {name}: cannot resolve {}: {error}",
path.display()
)
}
GrantError::TmpdirWithExec => write!(
f,
"the tmpdir grant needs a handle that removes the directory, and Cage::exec \
returns none: use Cage::spawn, or bind a directory of your own to another grant"
),
GrantError::Reserved { name } => {
write!(
f,
"grant {name} is bound by procage itself (a private temporary directory)"
)
}
GrantError::Issue { name, path } => {
write!(f, "grant {name}: no extension token issued for {path}")
}
}
}
}
#[derive(Debug)]
pub enum SpawnError {
Unsupported(Unsupported),
AlreadySandboxed,
Lower(LowerError),
Path {
path: String,
error: io::Error,
},
Compile {
message: String,
},
LimitExceeded {
limit: usize,
rules: usize,
},
Grant(GrantError),
Fd {
fd: i32,
},
Tmpdir(io::Error),
Apply {
step: Step,
errno: i32,
},
Io(io::Error),
}
impl fmt::Display for SpawnError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SpawnError::Unsupported(u) => write!(f, "unsupported: {u}"),
SpawnError::AlreadySandboxed => f.write_str("the calling process is already sandboxed"),
SpawnError::Lower(e) => write!(f, "{e}"),
SpawnError::Path { path, error } => write!(f, "cannot resolve {path}: {error}"),
SpawnError::Compile { message } => {
write!(f, "libsandbox refused the profile: {message}")
}
SpawnError::LimitExceeded { limit, rules } => {
write!(
f,
"the profile has {rules} rules, more than the in-process compile limit of {limit}"
)
}
SpawnError::Grant(e) => write!(f, "{e}"),
SpawnError::Fd { fd } => write!(f, "descriptor {fd} handed to the spawn is not open"),
SpawnError::Tmpdir(e) => {
write!(f, "cannot create the private temporary directory: {e}")
}
SpawnError::Apply { step, errno } => write!(
f,
"the child failed at step `{step}`: {}; the target did not run",
io::Error::from_raw_os_error(*errno)
),
SpawnError::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for SpawnError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SpawnError::Lower(e) => Some(e),
SpawnError::Path { error, .. } | SpawnError::Io(error) | SpawnError::Tmpdir(error) => {
Some(error)
}
SpawnError::Grant(GrantError::Missing { error, .. }) => Some(error),
_ => None,
}
}
}
impl From<LowerError> for SpawnError {
fn from(e: LowerError) -> Self {
SpawnError::Lower(e)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PathRewrite {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CageOptions {
pub lower: LowerOptions,
pub compile_limit: usize,
pub violations: bool,
}
impl Default for CageOptions {
fn default() -> Self {
Self {
lower: LowerOptions::default(),
compile_limit: 2000,
violations: false,
}
}
}
impl CageOptions {
pub fn with_lower(mut self, lower: LowerOptions) -> Self {
self.lower = lower;
self
}
pub fn with_compile_limit(mut self, limit: usize) -> Self {
self.compile_limit = limit;
self
}
pub fn with_violations(mut self) -> Self {
self.violations = true;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct Grants {
bindings: Vec<(String, PathBuf)>,
}
impl Grants {
pub fn new() -> Self {
Self::default()
}
pub fn bind(mut self, name: &str, path: impl AsRef<Path>) -> Self {
self.bindings
.push((name.to_string(), path.as_ref().to_path_buf()));
self
}
}
type CacheKey = (String, Vec<(String, String)>);
fn cache() -> &'static Mutex<HashMap<CacheKey, Arc<[u8]>>> {
static CACHE: OnceLock<Mutex<HashMap<CacheKey, Arc<[u8]>>>> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
fn compiled(
lib: &sys::Libsandbox,
plan: &SeatbeltPlan,
marker: &[(String, String)],
) -> Result<(Arc<[u8]>, bool), SpawnError> {
let mut params = plan.params.clone();
params.extend_from_slice(marker);
let key = (plan.profile.clone(), params);
let cached = cache()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
.cloned();
if let Some(bytecode) = cached {
return Ok((bytecode, false));
}
let bytecode: Arc<[u8]> = lib
.compile(&plan.profile, &key.1)
.map_err(|message| SpawnError::Compile {
message: message.unwrap_or_else(|| "<no error text>".into()),
})?
.into();
cache()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key, bytecode.clone());
Ok((bytecode, true))
}
fn libsandbox() -> Result<&'static sys::Libsandbox, SpawnError> {
sys::Libsandbox::get().map_err(|symbol| {
SpawnError::Unsupported(Unsupported::Symbol {
symbol,
os_build: sys::os_build(),
})
})
}
pub fn is_sandboxed() -> Result<bool, SpawnError> {
Ok(libsandbox()?.is_sandboxed())
}
#[derive(Debug, Clone)]
pub struct Cage {
plan: SeatbeltPlan,
bytecode: Arc<[u8]>,
rewrites: Vec<PathRewrite>,
compiled: bool,
violations: bool,
}
impl Cage {
pub fn new(policy: &Policy) -> Result<Cage, SpawnError> {
Cage::with_options(policy, &CageOptions::default())
}
pub fn fit(
policy: &Policy,
options: &CageOptions,
relax: &Relax,
) -> Result<(Cage, Vec<Relaxed>), SpawnError> {
let mut fitted = policy.clone();
let mut relaxed = Vec::new();
for _ in 0..4 {
let gaps = match Cage::with_options(&fitted, options) {
Ok(cage) => return Ok((cage, relaxed)),
Err(SpawnError::Lower(LowerError::Gaps(gaps))) => gaps,
Err(e) => return Err(e),
};
let done = relax
.apply(&mut fitted, &gaps)
.map_err(|rest| SpawnError::Lower(LowerError::Gaps(rest)))?;
relaxed.extend(done.into_iter().map(|(gap, relaxation)| Relaxed {
lift: lift(policy, &gap, &options.lower, 0),
gap,
relaxation,
}));
}
Cage::with_options(&fitted, options).map(|cage| (cage, relaxed))
}
pub fn with_options(policy: &Policy, options: &CageOptions) -> Result<Cage, SpawnError> {
if policy.tty == Tty::NewPty {
return Err(SpawnError::Unsupported(Unsupported::Setting {
setting: "Tty::NewPty",
}));
}
let lib = libsandbox()?;
if lib.is_sandboxed() {
return Err(SpawnError::AlreadySandboxed);
}
let (resolved, rewrites) = canon::resolve_policy(policy)?;
let violations = options.violations || options.lower.violations;
let mut lower_options = options.lower.clone();
if violations {
lower_options = lower_options.with_violations();
}
let Plan::Seatbelt(plan) = lower(&resolved, Target::MacOs, &lower_options)? else {
unreachable!("lowering for macOS yields a Seatbelt plan");
};
if plan.rule_count > options.compile_limit {
return Err(SpawnError::LimitExceeded {
limit: options.compile_limit,
rules: plan.rule_count,
});
}
let marker = if plan.run_marker || violations {
lifetime::first_params().to_vec()
} else {
Vec::new()
};
let (bytecode, compiled) = compiled(lib, &plan, &marker)?;
Ok(Cage {
plan,
bytecode,
rewrites,
compiled,
violations,
})
}
pub fn plan(&self) -> &SeatbeltPlan {
&self.plan
}
pub fn rewrites(&self) -> &[PathRewrite] {
&self.rewrites
}
pub fn compiled(&self) -> bool {
self.compiled
}
fn grant_slot(&self, name: &str) -> Option<&GrantSlot> {
self.plan.grants.iter().find(|g| g.name.as_str() == name)
}
pub fn spawn(&self, command: &mut Command, grants: &Grants) -> Result<Child, SpawnError> {
self.spawn_with_fds(command, grants, &[])
}
pub fn spawn_with_fds(
&self,
command: &mut Command,
grants: &Grants,
fds: &[BorrowedFd<'_>],
) -> Result<Child, SpawnError> {
let session = if self.plan.hygiene.tty == Tty::Inherit {
sys::Session::Group
} else {
sys::Session::New
};
let prepared = self.prepare(command, grants, fds, session)?;
let report = match &prepared.run {
Some(run) if self.violations => Some(violations::register(run.marker())),
_ => None,
};
let inner = match command.spawn() {
Ok(inner) => inner,
Err(e) => {
if let Some(run) = &prepared.run {
violations::unregister(run.marker());
}
return Err(spawn::decode(e));
}
};
Ok(Child::new(
inner,
prepared.dropped,
prepared.tmpdir.disarm(),
prepared.run,
child::Reporting {
forking: self.plan.run_marker,
record: report,
},
))
}
pub fn exec(&self, command: &mut Command, grants: &Grants) -> SpawnError {
self.exec_with_fds(command, grants, &[])
}
pub fn exec_with_fds(
&self,
command: &mut Command,
grants: &Grants,
fds: &[BorrowedFd<'_>],
) -> SpawnError {
use std::os::unix::process::CommandExt;
if self.grant_slot(TMPDIR_GRANT).is_some() {
return SpawnError::Grant(GrantError::TmpdirWithExec);
}
match self.prepare(command, grants, fds, sys::Session::Keep) {
Ok(_prepared) => spawn::decode(command.exec()),
Err(e) => e,
}
}
fn prepare(
&self,
command: &mut Command,
grants: &Grants,
fds: &[BorrowedFd<'_>],
session: sys::Session,
) -> Result<Prepared, SpawnError> {
let lib = libsandbox()?;
if lib.is_sandboxed() {
return Err(SpawnError::AlreadySandboxed);
}
probe()?;
let keep = kept_fds(fds.iter().map(AsRawFd::as_raw_fd))?;
if grants.bindings.iter().any(|(name, _)| name == TMPDIR_GRANT) {
return Err(SpawnError::Grant(GrantError::Reserved {
name: TMPDIR_GRANT.into(),
}));
}
let tmpdir = match self.grant_slot(TMPDIR_GRANT) {
Some(_) => Some(crate::unix::private_tmpdir().map_err(SpawnError::Tmpdir)?),
None => None,
};
let removal = TmpdirGuard(tmpdir.clone());
let mut grants = grants.clone();
if let Some(dir) = &tmpdir {
grants = grants.bind(TMPDIR_GRANT, dir);
if !command.get_envs().any(|(k, _)| k == "TMPDIR") {
command.env("TMPDIR", dir);
}
}
let tokens = spawn::tokens(lib, self, &grants)?;
let dropped = crate::unix::environment(command, &self.plan.hygiene.env)
.map_err(|_| SpawnError::Unsupported(Unsupported::EnvClear))?;
if session == sys::Session::Keep {
crate::unix::find_program(command).map_err(SpawnError::Io)?;
}
let (run, bytecode) = self.run_and_bytecode(lib)?;
sys::ChildPlan {
ops: lib.child_ops(),
bytecode,
tokens,
limits: crate::unix::limits(&self.plan.hygiene.limits),
session,
keep,
fd_buffer: sys::fd_buffer(),
}
.install(command);
Ok(Prepared {
dropped,
tmpdir: removal,
run,
})
}
fn run_and_bytecode(
&self,
lib: &sys::Libsandbox,
) -> Result<(Option<lifetime::Run>, Arc<[u8]>), SpawnError> {
if !self.plan.run_marker && !self.violations {
return Ok((None, self.bytecode.clone()));
}
if self.plan.run_marker {
lifetime::probe(lib)?;
}
let run = lifetime::Run::take();
if run.is_first() {
return Ok((Some(run), self.bytecode.clone()));
}
match compiled(lib, &self.plan, &run.params()) {
Ok((bytecode, _)) => Ok((Some(run), bytecode)),
Err(e) => {
run.free();
Err(e)
}
}
}
}
struct Prepared {
dropped: Vec<String>,
tmpdir: TmpdirGuard,
run: Option<lifetime::Run>,
}
fn kept_fds(fds: impl IntoIterator<Item = i32>) -> Result<Vec<i32>, SpawnError> {
let keep: Vec<i32> = fds.into_iter().filter(|&fd| fd > 2).collect();
match keep.iter().find(|&&fd| !sys::fd_is_open(fd)) {
Some(&fd) => Err(SpawnError::Fd { fd }),
None => Ok(keep),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_descriptor_that_is_not_open_is_refused() {
let closed = 1_000_000;
assert!(matches!(kept_fds([closed]), Err(SpawnError::Fd { fd }) if fd == closed));
assert_eq!(kept_fds([0, 1, 2]).unwrap(), Vec::<i32>::new());
let file = std::fs::File::open("/").unwrap();
let fd = file.as_raw_fd();
assert_eq!(kept_fds([fd]).unwrap(), vec![fd]);
}
}
#[doc(hidden)]
pub mod testing {
use super::{SpawnError, Unsupported, sys};
pub fn resolve_symbol(symbol: &'static str) -> Result<(), SpawnError> {
if sys::has_symbol(symbol) {
Ok(())
} else {
Err(SpawnError::Unsupported(Unsupported::Symbol {
symbol,
os_build: sys::os_build(),
}))
}
}
pub fn stop_violation_monitor() {
super::violations::stop();
}
pub fn run_members(child: &super::Child) -> std::io::Result<Vec<u32>> {
let Some(run) = child.run() else {
return Ok(Vec::new());
};
let lib = super::libsandbox().map_err(std::io::Error::other)?;
let pids = super::lifetime::members(lib, run)?;
Ok(pids.into_iter().map(i32::cast_unsigned).collect())
}
pub fn run_slot(child: &super::Child) -> Option<u32> {
child.run().map(super::lifetime::Run::slot)
}
pub fn is_session_leader() -> bool {
sys::is_session_leader()
}
pub fn is_group_leader() -> bool {
sys::is_group_leader()
}
pub fn dyld_churn(stop: &std::sync::atomic::AtomicBool) {
sys::dyld_churn(stop);
}
pub fn clear_cloexec(fd: i32) -> bool {
sys::clear_cloexec(fd).is_ok()
}
pub fn fd_is_open(fd: i32) -> bool {
sys::fd_is_open(fd)
}
pub fn alive(pid: u32) -> bool {
libc::pid_t::try_from(pid).is_ok_and(sys::alive)
}
pub fn spawn_sleeper(setsid: bool) -> std::io::Result<u32> {
sys::spawn_sleeper(setsid)
}
pub fn nofile_and_core() -> Option<(u64, u64)> {
Some((
sys::getrlimit(libc::RLIMIT_NOFILE)?.0,
sys::getrlimit(libc::RLIMIT_CORE)?.0,
))
}
pub fn mach_lookup(name: &str) -> i32 {
std::ffi::CString::new(name).map_or(libc::EINVAL, |c| sys::mach_lookup(&c))
}
pub fn fcntl_errno(fd: i32, cmd: i32) -> i32 {
sys::fcntl_errno(fd, cmd)
}
pub fn procargs_errno(pid: u32) -> i32 {
libc::pid_t::try_from(pid).map_or(libc::EINVAL, sys::procargs_errno)
}
pub fn kill_errno(pid: u32, sig: i32) -> i32 {
libc::pid_t::try_from(pid).map_or(libc::EINVAL, |p| sys::kill_errno(p, sig))
}
pub fn open_pty() -> std::io::Result<(std::os::fd::OwnedFd, std::path::PathBuf)> {
sys::open_pty()
}
pub fn make_controlling(fd: i32) -> i32 {
sys::ioctl_errno(fd, libc::TIOCSCTTY.into())
}
pub fn tiocsti_errno(fd: i32, byte: u8) -> i32 {
sys::tiocsti_errno(fd, byte)
}
}