Skip to main content

text_mirror/runner/
helper.rs

1//! The sandbox helper: the single-threaded first stage of every
2//! jailed spawn.
3//!
4//! The parent never does sandbox work in a `pre_exec` closure. It
5//! spawns the worker binary in this mode, a fresh single-threaded
6//! process, which applies the platform jail and resource limits and
7//! then execs the requested adapter mode of the same binary. Any
8//! setup failure writes one diagnostic line to stderr and exits with
9//! [`super::SANDBOX_SETUP_EXIT`], which the parent reports as
10//! `sandbox_unavailable`. The helper never runs adapter code before
11//! the jail is fully engaged.
12//!
13//! Order on Linux: namespaces and ID maps, resource limits,
14//! descriptor hygiene, Landlock, seccomp, exec. Descriptor hygiene
15//! runs before Landlock because it enumerates `/proc/self/fd`, which
16//! the filesystem policy then denies. On macOS the Seatbelt profile
17//! is already engaged by `sandbox-exec` before this mode starts, so
18//! only resource limits and descriptor hygiene remain.
19
20use std::ffi::CString;
21use std::os::unix::ffi::OsStrExt;
22use std::path::PathBuf;
23
24use super::{SANDBOX_SETUP_EXIT, WORKER_BINARY};
25
26/// Parsed helper arguments.
27pub struct HelperConfig {
28    /// The jail directory, already the working directory.
29    pub jail: PathBuf,
30    /// Absolute path of the worker binary to exec.
31    pub worker: PathBuf,
32    /// The adapter mode to exec into.
33    pub adapter: String,
34    /// RLIMIT_AS in bytes.
35    pub address_space_bytes: u64,
36    /// RLIMIT_CPU in seconds.
37    pub cpu_seconds: u64,
38    /// RLIMIT_FSIZE in bytes.
39    pub file_size_bytes: u64,
40    /// RLIMIT_NPROC, the process and thread cap.
41    pub max_processes: u64,
42    /// Whether the syscall filter engages. Linux only.
43    pub seccomp: bool,
44}
45
46/// Parses the `sandbox-helper` argument list.
47pub fn parse_args(args: &[String]) -> Result<HelperConfig, String> {
48    let mut jail = None;
49    let mut worker = None;
50    let mut adapter = None;
51    let mut address_space_bytes = None;
52    let mut cpu_seconds = None;
53    let mut file_size_bytes = None;
54    let mut max_processes = None;
55    let mut seccomp = None;
56    let mut iter = args.iter();
57    while let Some(flag) = iter.next() {
58        let value = iter
59            .next()
60            .ok_or_else(|| format!("flag {flag} is missing its value"))?;
61        match flag.as_str() {
62            "--jail" => jail = Some(PathBuf::from(value)),
63            "--worker" => worker = Some(PathBuf::from(value)),
64            "--adapter" => adapter = Some(value.clone()),
65            "--rlimit-as" => address_space_bytes = Some(parse_number(flag, value)?),
66            "--rlimit-cpu" => cpu_seconds = Some(parse_number(flag, value)?),
67            "--rlimit-fsize" => file_size_bytes = Some(parse_number(flag, value)?),
68            "--rlimit-nproc" => max_processes = Some(parse_number(flag, value)?),
69            "--seccomp" => {
70                seccomp = Some(match value.as_str() {
71                    "kill" => true,
72                    "off" => false,
73                    other => return Err(format!("unknown seccomp mode {other:?}")),
74                })
75            }
76            other => return Err(format!("unknown flag {other:?}")),
77        }
78    }
79    Ok(HelperConfig {
80        jail: jail.ok_or("missing --jail")?,
81        worker: worker.ok_or("missing --worker")?,
82        adapter: adapter.ok_or("missing --adapter")?,
83        address_space_bytes: address_space_bytes.ok_or("missing --rlimit-as")?,
84        cpu_seconds: cpu_seconds.ok_or("missing --rlimit-cpu")?,
85        file_size_bytes: file_size_bytes.ok_or("missing --rlimit-fsize")?,
86        max_processes: max_processes.ok_or("missing --rlimit-nproc")?,
87        seccomp: seccomp.ok_or("missing --seccomp")?,
88    })
89}
90
91fn parse_number(flag: &str, value: &str) -> Result<u64, String> {
92    value
93        .parse()
94        .map_err(|e| format!("flag {flag} value {value:?} is not a number: {e}"))
95}
96
97/// Runs the helper: engage the jail, then exec the adapter mode.
98/// Never returns. Every failure refuses the run.
99pub fn helper_main(args: &[String]) -> ! {
100    let config = match parse_args(args) {
101        Ok(config) => config,
102        Err(detail) => die(&detail),
103    };
104    if let Err(detail) = engage(&config) {
105        die(&detail);
106    }
107    // exec only returns on failure.
108    let error = exec_adapter(&config);
109    die(&error)
110}
111
112fn die(detail: &str) -> ! {
113    eprintln!("sandbox_setup_failed: {detail}");
114    std::process::exit(SANDBOX_SETUP_EXIT)
115}
116
117#[cfg(target_os = "linux")]
118fn engage(config: &HelperConfig) -> Result<(), String> {
119    super::linux::unshare_namespaces()?;
120    apply_rlimits(config)?;
121    close_extra_fds()?;
122    super::linux::apply_landlock(&config.jail, &config.worker)?;
123    if config.seccomp {
124        super::linux::apply_seccomp()?;
125    }
126    Ok(())
127}
128
129#[cfg(target_os = "macos")]
130fn engage(config: &HelperConfig) -> Result<(), String> {
131    // The Seatbelt profile is already applied by sandbox-exec.
132    apply_rlimits(config)?;
133    close_extra_fds()?;
134    Ok(())
135}
136
137#[cfg(not(any(target_os = "linux", target_os = "macos")))]
138fn engage(_config: &HelperConfig) -> Result<(), String> {
139    Err("no jail backend exists for this platform".to_string())
140}
141
142/// Sets the address-space, CPU, file-size, process-count, and core
143/// limits. Any failed call refuses the run.
144///
145/// `RLIMIT_NPROC` caps the process and thread count of the mapped
146/// real user id, so a compromised adapter cannot fork-bomb the host.
147/// `RLIMIT_AS` is applied on Linux only. On macOS the dyld shared
148/// cache reserves a large and version-dependent virtual range, so a
149/// hard address-space cap turns the next allocation into an abort. The CPU, file-size, process-count, and
150/// core limits hold on every platform, and the macOS backend is
151/// development containment where the wall-clock and output caps are
152/// the bounds that hold.
153fn apply_rlimits(config: &HelperConfig) -> Result<(), String> {
154    use rlimit::{Resource, setrlimit};
155    let apply = |resource: Resource, name: &str, value: u64| {
156        setrlimit(resource, value, value).map_err(|e| format!("setrlimit {name} failed: {e}"))
157    };
158    #[cfg(target_os = "linux")]
159    apply(Resource::AS, "RLIMIT_AS", config.address_space_bytes)?;
160    apply(Resource::CPU, "RLIMIT_CPU", config.cpu_seconds)?;
161    apply(Resource::FSIZE, "RLIMIT_FSIZE", config.file_size_bytes)?;
162    apply(Resource::NPROC, "RLIMIT_NPROC", config.max_processes)?;
163    apply(Resource::CORE, "RLIMIT_CORE", 0)?;
164    Ok(())
165}
166
167/// Closes every descriptor above stderr, with no fixed ceiling. The
168/// open descriptors are enumerated through the kernel's descriptor
169/// directory, `/proc/self/fd` on Linux and `/dev/fd` on macOS, so a
170/// descriptor above any fixed range is still found and closed. The
171/// parent opens everything close-on-exec, so this is belt and
172/// suspenders against descriptors injected by wrappers or held by a
173/// host embedder.
174fn close_extra_fds() -> Result<(), String> {
175    #[cfg(target_os = "linux")]
176    const FD_DIR: &str = "/proc/self/fd";
177    #[cfg(not(target_os = "linux"))]
178    const FD_DIR: &str = "/dev/fd";
179    let entries =
180        std::fs::read_dir(FD_DIR).map_err(|e| format!("cannot enumerate descriptors: {e}"))?;
181    let mut fds = Vec::new();
182    for entry in entries {
183        let entry = entry.map_err(|e| format!("cannot enumerate descriptors: {e}"))?;
184        if let Some(fd) = entry
185            .file_name()
186            .to_str()
187            .and_then(|name| name.parse::<i32>().ok())
188            && fd > 2
189        {
190            fds.push(fd);
191        }
192    }
193    for fd in fds {
194        // EBADF covers the enumeration descriptor itself.
195        let _ = nix::unistd::close(fd);
196    }
197    Ok(())
198}
199
200/// Execs the adapter mode of the worker binary. Returns only the
201/// failure detail.
202fn exec_adapter(config: &HelperConfig) -> String {
203    let path = match CString::new(config.worker.as_os_str().as_bytes()) {
204        Ok(path) => path,
205        Err(_) => return "worker path contains a NUL byte".to_string(),
206    };
207    let argv0 = CString::new(WORKER_BINARY).expect("static name has no NUL");
208    let mode = match CString::new(config.adapter.as_str()) {
209        Ok(mode) => mode,
210        Err(_) => return "adapter mode contains a NUL byte".to_string(),
211    };
212    match nix::unistd::execv(&path, &[argv0.as_c_str(), mode.as_c_str()]) {
213        Ok(infallible) => match infallible {},
214        Err(e) => format!("exec of the adapter mode failed: {e}"),
215    }
216}