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 one refusal with its own exit code is a
11//! process count the host will not answer,
12//! [`super::PROCESS_COUNT_EXIT`], reported as
13//! `process-count-unavailable`. The helper never runs adapter code
14//! before the jail is fully engaged.
15//!
16//! Order on Linux: namespaces and ID maps, resource limits,
17//! descriptor hygiene, Landlock, seccomp, exec. Descriptor hygiene
18//! runs before Landlock because it enumerates `/proc/self/fd`, which
19//! the filesystem policy then denies. On macOS the Seatbelt profile
20//! is already engaged by `sandbox-exec` before this mode starts, so
21//! only resource limits and descriptor hygiene remain.
22
23use std::ffi::CString;
24use std::os::unix::ffi::OsStrExt;
25use std::path::PathBuf;
26
27use super::{PROCESS_COUNT_EXIT, SANDBOX_SETUP_EXIT, WORKER_BINARY};
28
29/// A refused setup: one path-free line of stderr detail and the exit
30/// code the parent maps back to a named runner reason. Every refusal
31/// that is not called out by its own code is a jail setup failure.
32#[derive(Debug)]
33struct Refusal {
34    detail: String,
35    exit: i32,
36}
37
38impl From<String> for Refusal {
39    fn from(detail: String) -> Refusal {
40        Refusal {
41            detail,
42            exit: SANDBOX_SETUP_EXIT,
43        }
44    }
45}
46
47/// Parsed helper arguments.
48pub struct HelperConfig {
49    /// The jail directory, already the working directory.
50    pub jail: PathBuf,
51    /// Absolute path of the worker binary to exec.
52    pub worker: PathBuf,
53    /// The adapter mode to exec into.
54    pub adapter: String,
55    /// RLIMIT_AS in bytes, or `None` when the profile leaves the
56    /// limit unset.
57    pub address_space_bytes: Option<u64>,
58    /// RLIMIT_CPU in seconds.
59    pub cpu_seconds: u64,
60    /// RLIMIT_FSIZE in bytes.
61    pub file_size_bytes: u64,
62    /// RLIMIT_NPROC, the process and thread cap.
63    pub max_processes: u64,
64    /// Whether the syscall filter engages. Linux only.
65    pub seccomp: bool,
66    /// Literal files granted read and execute beyond the worker: the
67    /// pinned engine and probe binaries.
68    pub exec_grants: Vec<PathBuf>,
69    /// Literal files granted read only: the pinned weights and other
70    /// read-only runtime files.
71    pub read_grants: Vec<PathBuf>,
72}
73
74/// Parses the `sandbox-helper` argument list.
75pub fn parse_args(args: &[String]) -> Result<HelperConfig, String> {
76    let mut jail = None;
77    let mut worker = None;
78    let mut adapter = None;
79    let mut address_space_bytes: Option<Option<u64>> = None;
80    let mut cpu_seconds = None;
81    let mut file_size_bytes = None;
82    let mut max_processes = None;
83    let mut seccomp = None;
84    let mut exec_grants = Vec::new();
85    let mut read_grants = Vec::new();
86    let mut iter = args.iter();
87    while let Some(flag) = iter.next() {
88        let value = iter
89            .next()
90            .ok_or_else(|| "a helper flag is missing its value".to_string())?;
91        match flag.as_str() {
92            "--jail" => jail = Some(PathBuf::from(value)),
93            "--worker" => worker = Some(PathBuf::from(value)),
94            "--adapter" => adapter = Some(value.clone()),
95            "--rlimit-as" => {
96                address_space_bytes = Some(if value == "none" {
97                    None
98                } else {
99                    Some(parse_number(flag, value)?)
100                })
101            }
102            "--rlimit-cpu" => cpu_seconds = Some(parse_number(flag, value)?),
103            "--rlimit-fsize" => file_size_bytes = Some(parse_number(flag, value)?),
104            "--rlimit-nproc" => max_processes = Some(parse_number(flag, value)?),
105            "--seccomp" => {
106                seccomp = Some(match value.as_str() {
107                    "kill" => true,
108                    "off" => false,
109                    _ => return Err("unknown seccomp mode".to_string()),
110                })
111            }
112            "--grant-exec" => exec_grants.push(PathBuf::from(value)),
113            "--grant-read" => read_grants.push(PathBuf::from(value)),
114            _ => return Err("unknown helper flag".to_string()),
115        }
116    }
117    Ok(HelperConfig {
118        jail: jail.ok_or("missing --jail")?,
119        worker: worker.ok_or("missing --worker")?,
120        adapter: adapter.ok_or("missing --adapter")?,
121        address_space_bytes: address_space_bytes.ok_or("missing --rlimit-as")?,
122        cpu_seconds: cpu_seconds.ok_or("missing --rlimit-cpu")?,
123        file_size_bytes: file_size_bytes.ok_or("missing --rlimit-fsize")?,
124        max_processes: max_processes.ok_or("missing --rlimit-nproc")?,
125        seccomp: seccomp.ok_or("missing --seccomp")?,
126        exec_grants,
127        read_grants,
128    })
129}
130
131fn parse_number(flag: &str, value: &str) -> Result<u64, String> {
132    value
133        .parse()
134        .map_err(|_| format!("flag {flag} does not carry a number"))
135}
136
137/// Runs the helper: engage the jail, then exec the adapter mode.
138/// Never returns. Every failure refuses the run.
139pub fn helper_main(args: &[String]) -> ! {
140    let config = match parse_args(args) {
141        Ok(config) => config,
142        Err(detail) => die(&detail, SANDBOX_SETUP_EXIT),
143    };
144    if let Err(refusal) = engage(&config) {
145        die(&refusal.detail, refusal.exit);
146    }
147    // exec only returns on failure.
148    let error = exec_adapter(&config);
149    die(&error, SANDBOX_SETUP_EXIT)
150}
151
152fn die(detail: &str, exit: i32) -> ! {
153    eprintln!("sandbox_setup_failed: {detail}");
154    std::process::exit(exit)
155}
156
157#[cfg(target_os = "linux")]
158fn engage(config: &HelperConfig) -> Result<(), Refusal> {
159    super::linux::unshare_namespaces()?;
160    apply_rlimits(config)?;
161    close_extra_fds()?;
162    super::linux::apply_landlock(
163        &config.jail,
164        &config.worker,
165        &config.exec_grants,
166        &config.read_grants,
167    )?;
168    if config.seccomp {
169        super::linux::apply_seccomp()?;
170    }
171    Ok(())
172}
173
174#[cfg(target_os = "macos")]
175fn engage(config: &HelperConfig) -> Result<(), Refusal> {
176    // The Seatbelt profile is already applied by sandbox-exec.
177    apply_rlimits(config)?;
178    close_extra_fds()?;
179    Ok(())
180}
181
182#[cfg(not(any(target_os = "linux", target_os = "macos")))]
183fn engage(_config: &HelperConfig) -> Result<(), Refusal> {
184    Err("no jail backend exists for this platform"
185        .to_string()
186        .into())
187}
188
189/// Sets the address-space, CPU, file-size, process-count, and core
190/// limits. Any failed call refuses the run.
191///
192/// `RLIMIT_NPROC` caps the process and thread count of the mapped
193/// real user id, so a compromised adapter cannot fork-bomb the host.
194/// `RLIMIT_AS` is applied on Linux only, and only when the profile
195/// carries a value: a profile whose mapped accelerator runtime
196/// reserves a virtual range above any sane cap leaves it unset and
197/// relies on the parent-side resident-memory guard. On macOS the dyld
198/// shared cache reserves a large and version-dependent virtual range,
199/// so a hard address-space cap turns the next allocation into an
200/// abort. The CPU, file-size, process-count, and core limits hold on
201/// every platform, and the macOS backend is development containment
202/// where the wall-clock and output caps are the bounds that hold.
203fn apply_rlimits(config: &HelperConfig) -> Result<(), Refusal> {
204    use rlimit::{Resource, setrlimit};
205    let apply = |resource: Resource, name: &str, value: u64| {
206        setrlimit(resource, value, value).map_err(|_| format!("setrlimit {name} failed"))
207    };
208    #[cfg(target_os = "linux")]
209    if let Some(bytes) = config.address_space_bytes {
210        apply(Resource::AS, "RLIMIT_AS", bytes)?;
211    }
212    apply(Resource::CPU, "RLIMIT_CPU", config.cpu_seconds)?;
213    apply(Resource::FSIZE, "RLIMIT_FSIZE", config.file_size_bytes)?;
214    apply(
215        Resource::NPROC,
216        "RLIMIT_NPROC",
217        nproc_limit(config.max_processes)?,
218    )?;
219    apply(Resource::CORE, "RLIMIT_CORE", 0)?;
220    Ok(())
221}
222
223/// The applied process-count limit: the configured value as headroom
224/// above the real user id's pre-existing process count, clamped to
225/// the hard limit.
226///
227/// `RLIMIT_NPROC` counts every process of the real user id, not this
228/// invocation's descendants. Measured on a desktop-class host: the
229/// user already ran several hundred processes, so an absolute cap of
230/// 64 made every fork fail with EAGAIN before the adapter could spawn
231/// its engine child. The configured number's intent is the
232/// invocation's spawn budget, so it is applied on top of the load
233/// that already exists, and the bound it enforces is on the real user
234/// id's total: that total can never exceed its spawn-time count plus
235/// the configured budget, whatever the worker tree does. Both drift
236/// directions follow from that. Growth of the user id's count after
237/// the measurement narrows what this invocation may add. A shrink
238/// lets the worker tree grow into the slots other processes freed,
239/// so the tree may hold more than the budget while the user id total
240/// stays at or under the same ceiling. The hard limit clamps either
241/// way. A count the host cannot supply fails the run closed with the
242/// named reason
243/// `process-count-unavailable` and nothing is exec'd, because the
244/// headroom the budget rides on is unknown and a substituted
245/// baseline is a guess at the very number being bounded.
246fn nproc_limit(configured: u64) -> Result<u64, Refusal> {
247    use rlimit::{Resource, getrlimit};
248    let (_, hard) =
249        getrlimit(Resource::NPROC).map_err(|_| "getrlimit RLIMIT_NPROC failed".to_string())?;
250    headroom_limit(configured, hard, current_uid_process_count)
251}
252
253/// The headroom arithmetic over one count query, separated so the
254/// unmeasurable case is exercised without a host that can fail.
255fn headroom_limit(
256    configured: u64,
257    hard: u64,
258    count: impl Fn() -> std::io::Result<u64>,
259) -> Result<u64, Refusal> {
260    let base = count().map_err(|e| Refusal {
261        detail: format!(
262            "the real user id's process count could not be measured, so the headroom this limit rides on is unknown: {:?}",
263            e.kind()
264        ),
265        exit: PROCESS_COUNT_EXIT,
266    })?;
267    Ok(base.saturating_add(configured).min(hard))
268}
269
270/// The real user id's current process count, from the platform's
271/// process listing. A listing the host refuses is an error, never a
272/// count of zero: the caller must be able to tell an idle user from
273/// an unanswerable question.
274#[cfg(target_os = "macos")]
275fn current_uid_process_count() -> std::io::Result<u64> {
276    use libproc::processes::{ProcFilter, pids_by_type};
277    let uid = nix::unistd::getuid().as_raw();
278    let pids = pids_by_type(ProcFilter::ByUID { uid }).map_err(std::io::Error::other)?;
279    Ok(pids.len() as u64)
280}
281
282/// The real user id's current process count, from the `/proc` view.
283/// A record that vanished between the listing and the read is an
284/// ordinary exit and is skipped; every other read failure fails the
285/// count, because a view the host will not answer is not a count.
286#[cfg(target_os = "linux")]
287fn current_uid_process_count() -> std::io::Result<u64> {
288    use std::os::unix::fs::MetadataExt;
289    let uid = u32::from(nix::unistd::getuid().as_raw());
290    let mut count: u64 = 0;
291    for entry in std::fs::read_dir("/proc")? {
292        let entry = entry?;
293        let name = entry.file_name();
294        if name.to_str().is_none_or(|n| n.parse::<u32>().is_err()) {
295            continue;
296        }
297        match entry.metadata() {
298            Ok(metadata) => {
299                if metadata.uid() == uid {
300                    count += 1;
301                }
302            }
303            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
304            Err(e) => return Err(e),
305        }
306    }
307    Ok(count)
308}
309
310#[cfg(not(any(target_os = "macos", target_os = "linux")))]
311fn current_uid_process_count() -> std::io::Result<u64> {
312    Err(std::io::Error::new(
313        std::io::ErrorKind::Unsupported,
314        "no process listing exists for this platform",
315    ))
316}
317
318/// Closes every descriptor above stderr, with no fixed ceiling. The
319/// open descriptors are enumerated through the kernel's descriptor
320/// directory, `/proc/self/fd` on Linux and `/dev/fd` on macOS, so a
321/// descriptor above any fixed range is still found and closed. The
322/// parent opens everything close-on-exec, so this is belt and
323/// suspenders against descriptors injected by wrappers or held by a
324/// host embedder.
325fn close_extra_fds() -> Result<(), String> {
326    #[cfg(target_os = "linux")]
327    const FD_DIR: &str = "/proc/self/fd";
328    #[cfg(not(target_os = "linux"))]
329    const FD_DIR: &str = "/dev/fd";
330    let entries = std::fs::read_dir(FD_DIR)
331        .map_err(|e| format!("cannot enumerate descriptors: {:?}", e.kind()))?;
332    let mut fds = Vec::new();
333    for entry in entries {
334        let entry = entry.map_err(|e| format!("cannot enumerate descriptors: {:?}", e.kind()))?;
335        if let Some(fd) = entry
336            .file_name()
337            .to_str()
338            .and_then(|name| name.parse::<i32>().ok())
339            && fd > 2
340        {
341            fds.push(fd);
342        }
343    }
344    for fd in fds {
345        // EBADF covers the enumeration descriptor itself.
346        let _ = nix::unistd::close(fd);
347    }
348    Ok(())
349}
350
351/// Execs the adapter mode of the worker binary. Returns only the
352/// failure detail.
353fn exec_adapter(config: &HelperConfig) -> String {
354    let path = match CString::new(config.worker.as_os_str().as_bytes()) {
355        Ok(path) => path,
356        Err(_) => return "worker path contains a NUL byte".to_string(),
357    };
358    let argv0 = CString::new(WORKER_BINARY).expect("static name has no NUL");
359    let mode = match CString::new(config.adapter.as_str()) {
360        Ok(mode) => mode,
361        Err(_) => return "adapter mode contains a NUL byte".to_string(),
362    };
363    match nix::unistd::execv(&path, &[argv0.as_c_str(), mode.as_c_str()]) {
364        Ok(infallible) => match infallible {},
365        Err(e) => format!("exec of the adapter mode failed: {e:?}"),
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn the_process_limit_rides_on_the_measured_count_and_clamps() {
375        // The budget is added to the count the host reported.
376        let limit = headroom_limit(64, 4096, || Ok(609)).expect("a measured count computes");
377        assert_eq!(limit, 673);
378        // The hard limit is still the ceiling.
379        let clamped = headroom_limit(64, 650, || Ok(609)).expect("a measured count computes");
380        assert_eq!(clamped, 650);
381        // An idle user is a real answer, not a missing one.
382        let idle = headroom_limit(64, 4096, || Ok(0)).expect("a measured count computes");
383        assert_eq!(idle, 64);
384    }
385
386    #[test]
387    fn a_count_the_host_cannot_supply_refuses_the_run() {
388        // The refusal carries the exit code the parent reports as
389        // `process-count-unavailable`, so nothing is exec'd and no
390        // substituted baseline stands in for the real count.
391        let refusal = headroom_limit(64, 4096, || {
392            Err(std::io::Error::other("the listing is unavailable"))
393        })
394        .expect_err("an unmeasurable count must refuse the run");
395        assert_eq!(refusal.exit, PROCESS_COUNT_EXIT);
396        assert_ne!(refusal.exit, SANDBOX_SETUP_EXIT);
397        assert!(refusal.detail.contains("Other"), "{}", refusal.detail);
398    }
399
400    #[cfg(any(target_os = "macos", target_os = "linux"))]
401    #[test]
402    fn this_host_can_be_counted() {
403        // This test process belongs to the real user id, so a working
404        // listing counts at least one.
405        let count = current_uid_process_count().expect("the host answers its own process listing");
406        assert!(count > 0, "the count was {count}");
407    }
408}