Skip to main content

capsule_runtime/
resources.rs

1//! Host-process resource bounds for every native subprocess this runtime spawns.
2//!
3//! Sibling of [`crate::limits`], and deliberately not a replacement for it: that module bounds
4//! a WASM *guest* inside its wasmtime store (linear memory, tables, epoch deadline), this one
5//! bounds the *operating-system processes* the runtime forks — `shell::execute_shell` and
6//! `runtime::dispatch_native_tool`. A capsule that cannot escape its containment can still
7//! wedge the host it runs on: fork bombs, unbounded open files, unbounded writes, unbounded
8//! per-process CPU and address space, and unbounded workdir growth are all *denial of service*,
9//! not boundary defeats — nothing outside the granted scope is read, written, or reached — but
10//! a wedged host is still a broken host.
11//!
12//! Three mechanisms, in descending order of portability:
13//!
14//! * **`setrlimit(2)` ceilings** ([`apply_hard_rlimits`]), applied inside the forked child's
15//!   `pre_exec` window on every Unix platform. Per-process, POSIX-portable, no configuration
16//!   required of the host.
17//! * **a cgroup v2 scope** ([`crate::cgroup`]) around the whole subprocess *tree*, Linux only.
18//!   This is what rlimits structurally cannot do: `RLIMIT_NPROC` is a **per-uid** ceiling,
19//!   checked against every thread the uid owns on the host, so it bounds neither this tree nor
20//!   anything else in particular. `pids.max` on a cgroup counts only the scope's own tasks, and
21//!   a spawn that joins a scope gets no `RLIMIT_NPROC` from this module at all ([`NprocBound`]).
22//! * **a periodic workdir-size check** ([`WorkdirGuard`]), on every platform. A plain filesystem
23//!   walk on a fixed cadence, so a breach is caught within one poll interval — not instantly.
24//!
25//! ## A silent manifest means defaults, never "unlimited"
26//!
27//! [`HostResourceLimits::resolve`] follows [`crate::limits::ExecutionLimits::resolve`] exactly:
28//! the manifest's `capabilities.resources` block with every omitted field replaced by its
29//! `DEFAULT_*` constant, and the whole block's absence treated as "every field omitted". There
30//! is no "unset" state downstream — a capsule that declares nothing is bounded identically to
31//! one that declares every default by hand.
32//!
33//! ## Hard limits, not soft ones
34//!
35//! `setrlimit(2)` takes both a soft (`rlim_cur`) and a hard (`rlim_max`) value, and an
36//! unprivileged process may raise its own soft limit up to the hard one at any time. Against a
37//! hostile agent a soft-only cap is therefore advisory: one `setrlimit` call from inside the
38//! capsule undoes it. Every limit this module sets writes `rlim_cur == rlim_max`, leaving no
39//! ceiling to raise into, and lowering `rlim_max` is irreversible for an unprivileged process.
40
41use std::{
42    path::{Path, PathBuf},
43    sync::{
44        atomic::{AtomicBool, Ordering},
45        Arc, Mutex,
46    },
47    thread,
48    time::Duration,
49};
50
51/// Default `RLIMIT_NPROC` headroom for a spawn with no cgroup scope — how much past the uid's
52/// count at that spawn a subprocess tree may add, in whichever unit this platform's
53/// `RLIMIT_NPROC` is checked against. Unused for a spawn that joins a scope. See [`NprocBound`]
54/// for which spawns get it and [`uid_task_count`] for the per-platform unit.
55pub const DEFAULT_MAX_PROCESSES: u64 = 128;
56
57/// Default `RLIMIT_NOFILE` hard ceiling — open descriptors per spawned subprocess.
58pub const DEFAULT_MAX_OPEN_FILES: u64 = 1024;
59
60/// Default `RLIMIT_FSIZE` hard ceiling — 4 GiB, the largest single file a subprocess may write.
61pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = 4 * 1024 * 1024 * 1024;
62
63/// Default `RLIMIT_CPU` hard ceiling — one hour of CPU time per spawned subprocess.
64pub const DEFAULT_CPU_SECONDS: u64 = 3600;
65
66/// Default `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling — 2 GiB per subprocess.
67pub const DEFAULT_MEMORY_BYTES: u64 = 2 * 1024 * 1024 * 1024;
68
69/// Default cgroup `memory.max` — 4 GiB aggregate across the whole subprocess tree. Above the
70/// per-process ceiling on purpose: the tree bound exists to catch what *many* processes do
71/// together, so setting it at or below the single-process bound would make it redundant.
72pub const DEFAULT_CGROUP_MEMORY_BYTES: u64 = 4 * 1024 * 1024 * 1024;
73
74/// Default cgroup `pids.max` — 256 tasks aggregate across the whole subprocess tree.
75pub const DEFAULT_CGROUP_PIDS_MAX: u64 = 256;
76
77/// Default cgroup `cpu.max` quota as a percentage of one core: 200 = two cores' worth.
78pub const DEFAULT_CGROUP_CPU_PERCENT: u32 = 200;
79
80/// Default cgroup `io.max` read+write throughput on the workdir's backing device — 100 MiB/s.
81pub const DEFAULT_CGROUP_IO_BYTES_PER_SEC: u64 = 100 * 1024 * 1024;
82
83/// Default ceiling on total workdir size — 10 GiB, checked periodically.
84pub const DEFAULT_WORKDIR_MAX_BYTES: u64 = 10 * 1024 * 1024 * 1024;
85
86/// How often [`WorkdirGuard`] walks the workdir tree.
87///
88/// An internal constant rather than a manifest key, matching [`crate::limits::EPOCH_TICK_INTERVAL`]:
89/// the cadence changes *when* a breach is noticed, never *whether* it is, so exposing it would
90/// add a knob with no security meaning. The consequence: a disk filler is caught within one
91/// interval of crossing the ceiling, not at the instant it crosses.
92pub(crate) const WORKDIR_CHECK_INTERVAL: Duration = Duration::from_secs(10);
93
94/// Fully-resolved host-process limits for one session: the manifest's `capabilities.resources`
95/// block with every omitted field replaced by its `DEFAULT_*`.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct HostResourceLimits {
98    /// `RLIMIT_NPROC` headroom above the uid's count at each spawn, applied only to a spawn
99    /// with no cgroup scope ([`NprocBound::Headroom`]) and counted in the unit that uid's kernel
100    /// enforces against ([`uid_task_count`]). A spawn that joins a scope is bounded by
101    /// `cgroup_pids_max` instead and ignores this field.
102    pub max_processes: u64,
103    /// `RLIMIT_NOFILE` hard ceiling.
104    pub max_open_files: u64,
105    /// `RLIMIT_FSIZE` hard ceiling, in bytes.
106    pub max_file_size_bytes: u64,
107    /// `RLIMIT_CPU` hard ceiling, in CPU-seconds.
108    pub cpu_seconds: u64,
109    /// `RLIMIT_AS` on Linux, `RLIMIT_DATA` on macOS (which has no `RLIMIT_AS`), in bytes.
110    pub memory_bytes: u64,
111    /// cgroup v2 `memory.max`, in bytes. Linux only.
112    pub cgroup_memory_bytes: u64,
113    /// cgroup v2 `pids.max`. Linux only.
114    pub cgroup_pids_max: u64,
115    /// cgroup v2 `cpu.max` quota as a percentage of one core. Linux only.
116    pub cgroup_cpu_percent: u32,
117    /// cgroup v2 `io.max` rbps+wbps on the workdir's backing device. Linux only, best-effort.
118    pub cgroup_io_bytes_per_sec: u64,
119    /// Ceiling on total workdir size, in bytes. Every platform.
120    pub workdir_max_bytes: u64,
121}
122
123impl Default for HostResourceLimits {
124    fn default() -> Self {
125        Self {
126            max_processes: DEFAULT_MAX_PROCESSES,
127            max_open_files: DEFAULT_MAX_OPEN_FILES,
128            max_file_size_bytes: DEFAULT_MAX_FILE_SIZE_BYTES,
129            cpu_seconds: DEFAULT_CPU_SECONDS,
130            memory_bytes: DEFAULT_MEMORY_BYTES,
131            cgroup_memory_bytes: DEFAULT_CGROUP_MEMORY_BYTES,
132            cgroup_pids_max: DEFAULT_CGROUP_PIDS_MAX,
133            cgroup_cpu_percent: DEFAULT_CGROUP_CPU_PERCENT,
134            cgroup_io_bytes_per_sec: DEFAULT_CGROUP_IO_BYTES_PER_SEC,
135            workdir_max_bytes: DEFAULT_WORKDIR_MAX_BYTES,
136        }
137    }
138}
139
140impl HostResourceLimits {
141    /// Resolve a manifest `capabilities.resources` block, substituting the default for each
142    /// field the manifest left out (and for the whole block when it is absent).
143    #[must_use]
144    pub fn resolve(declared: Option<&murmur_artifact::ResourceCapabilities>) -> Self {
145        let defaults = Self::default();
146        let Some(declared) = declared else {
147            return defaults;
148        };
149        Self {
150            max_processes: declared.max_processes.unwrap_or(defaults.max_processes),
151            max_open_files: declared.max_open_files.unwrap_or(defaults.max_open_files),
152            max_file_size_bytes: declared
153                .max_file_size_bytes
154                .unwrap_or(defaults.max_file_size_bytes),
155            cpu_seconds: declared.cpu_seconds.unwrap_or(defaults.cpu_seconds),
156            memory_bytes: declared.memory_bytes.unwrap_or(defaults.memory_bytes),
157            cgroup_memory_bytes: declared
158                .cgroup_memory_bytes
159                .unwrap_or(defaults.cgroup_memory_bytes),
160            cgroup_pids_max: declared.cgroup_pids_max.unwrap_or(defaults.cgroup_pids_max),
161            cgroup_cpu_percent: declared
162                .cgroup_cpu_percent
163                .unwrap_or(defaults.cgroup_cpu_percent),
164            cgroup_io_bytes_per_sec: declared
165                .cgroup_io_bytes_per_sec
166                .unwrap_or(defaults.cgroup_io_bytes_per_sec),
167            workdir_max_bytes: declared
168                .workdir_max_bytes
169                .unwrap_or(defaults.workdir_max_bytes),
170        }
171    }
172}
173
174/// Resolve a manifest `capabilities.resources` block into [`HostResourceLimits`].
175///
176/// Free function mirroring `ExecutionLimits::resolve`'s call shape at the one call site that
177/// matters (`types::capability_policy_from_runtime_manifest`), so the two sibling limit blocks
178/// read identically there.
179#[must_use]
180pub(crate) fn resolve(
181    declared: Option<&murmur_artifact::ResourceCapabilities>,
182) -> HostResourceLimits {
183    HostResourceLimits::resolve(declared)
184}
185
186/// The named limit a kill signal unambiguously attributes to, or `None`.
187///
188/// Only two signals qualify, and the list is deliberately not longer:
189///
190/// * `SIGXCPU` — the kernel raises exactly this for an `RLIMIT_CPU` overrun. Unambiguous.
191/// * `SIGXFSZ` — likewise for `RLIMIT_FSIZE`. Unambiguous.
192///
193/// Everything else stays unattributed **on purpose**. `RLIMIT_AS`/`RLIMIT_DATA` overrun surfaces
194/// as `ENOMEM` inside the child's own allocator, not as a parent-visible signal with a unique
195/// identity, so mapping `SIGSEGV`/`SIGABRT` to `memory_bytes` would be a guess presented as a
196/// fact. `RLIMIT_NPROC` and `RLIMIT_NOFILE` do not kill anything at all: they fail a
197/// `fork()`/`open()` inside the child with `EAGAIN`/`EMFILE`, visible in that child's own stderr
198/// and exit code. A bare `SIGKILL` with no cgroup evidence has too many causes (host OOM killer,
199/// an operator, an unrelated crash) to name one.
200#[cfg_attr(not(unix), allow(dead_code))]
201pub(crate) fn limit_from_signal(signal: i32) -> Option<&'static str> {
202    match signal {
203        libc::SIGXCPU => Some("cpu_seconds"),
204        libc::SIGXFSZ => Some("max_file_size_bytes"),
205        _ => None,
206    }
207}
208
209/// The line that tells the model which `capabilities.resources` limit stopped a shell command,
210/// and what to do about it: `resource_limit: <name> — <what happened>. <what to do>.`
211///
212/// One line, no trailing newline. `limit` is a name a call was attributed to — one of
213/// [`limit_from_signal`]'s or [`crate::cgroup::CgroupEventCounters::attribution_since`]'s. A name
214/// with no line of its own gets a generic one that still names its `capabilities.resources` field.
215///
216/// The `cgroup_*` lines say "the capsule … while this command ran" because the cgroup scope is
217/// shared by everything the session runs: a counter delta proves the capsule hit the ceiling
218/// during the call, not which process did. The signal lines say "this command" because the kernel
219/// delivered the signal to that process.
220///
221/// Every place the model reads an attributed limit renders it through this function.
222pub(crate) fn resource_limit_line(limit: &str) -> String {
223    let explanation = match limit {
224        "cgroup_pids_max" => "the capsule reached its process limit while this command ran, so \
225             starting a new process failed (EAGAIN). Retry with fewer parallel processes"
226            .to_string(),
227        "cgroup_memory_bytes" => "the capsule reached its memory limit while this command ran, \
228             and the kernel killed a process in it to free memory. Retry using less memory at \
229             once (smaller inputs, fewer parallel jobs)"
230            .to_string(),
231        "cpu_seconds" => "this command used up its CPU-time limit and was stopped. Retry with \
232             less work per command"
233            .to_string(),
234        "max_file_size_bytes" => "this command tried to write a file larger than the capsule's \
235             file-size limit and was stopped. Retry writing smaller files"
236            .to_string(),
237        other => format!(
238            "this command was stopped by the capsule's capabilities.resources.{other} limit. \
239             Retry with less work"
240        ),
241    };
242    format!(
243        "resource_limit: {limit} — {explanation}, or ask the operator to raise \
244         capabilities.resources.{limit}."
245    )
246}
247
248/// What one spawn's `RLIMIT_NPROC` is, decided in the parent before its `pre_exec` closure is
249/// installed — the closure only receives this `Copy` value.
250///
251/// `RLIMIT_NPROC` is checked against **everything the uid owns on the host**, not against the
252/// tasks in this tree, and on Linux the unit it counts is *threads*: `setrlimit(2)` calls it "the
253/// maximum number of processes (or, more precisely on Linux, threads) that can be created for the
254/// real user ID". On macOS, whose BSD-derived limit is per-process, it counts processes. Either
255/// way an interactive account is already deep into that number before the capsule starts, and the
256/// number moves with every thread the desktop, the harness or anything else on the host starts or
257/// stops. A limit set from a count taken at launch therefore loses headroom to the rest of the
258/// host for the whole session, and a `fork()` in the subprocess fails with `EAGAIN` once enough
259/// unrelated threads have started.
260///
261/// Produced by `sandbox::ShellEnforcement::nproc_bound` once per spawn.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub(crate) enum NprocBound {
264    /// Leave `RLIMIT_NPROC` at the value the runtime inherited. The child joins a cgroup scope in
265    /// the same `pre_exec` closure, fatally on failure, and the scope's `pids.max`
266    /// ([`HostResourceLimits::cgroup_pids_max`]) bounds its tree: it counts only the scope's own
267    /// tasks, so nothing else on the host can spend it.
268    CgroupPidsMax,
269    /// `baseline + max_processes`, where `baseline` is [`uid_task_count`] measured in the parent
270    /// for this spawn, so `max_processes` means "how much past the host's usage at this spawn this
271    /// tree may add". `0` when the count cannot be taken, which applies the declared value
272    /// literally and errs toward the tighter bound.
273    Headroom { baseline: u64 },
274}
275
276/// Sets every [`HostResourceLimits`] rlimit field as a **hard** limit (`rlim_cur == rlim_max`),
277/// plus `RLIMIT_CORE = 0`. `RLIMIT_NPROC` is set only for [`NprocBound::Headroom`]; for
278/// [`NprocBound::CgroupPidsMax`] no `RLIMIT_NPROC` call is made at all.
279///
280/// Called from inside a forked child's `pre_exec` closure, so everything here is restricted to
281/// async-signal-safe operations: two syscalls per limit and no allocation, no locking, and no
282/// error formatting (errors are returned as bare `io::Error::from_raw_os_error`).
283///
284/// `RLIMIT_CORE` is fixed at zero with no manifest surface at all — a core dump of a capsule
285/// subprocess writes a potentially multi-gigabyte file, holding whatever was in that process's
286/// memory, into the workdir or the host's core pattern. It belongs to the same category as the
287/// fixed capsule device set in `sandbox.rs`: an invariant, not a setting.
288///
289/// A requested value above the inherited hard limit is **clamped down to it** rather than
290/// treated as an error: an unprivileged process cannot raise `rlim_max`, so the inherited value
291/// is the real ceiling either way, and failing the spawn would replace an unwidenable bound with
292/// no subprocess at all.
293///
294/// ## `RLIMIT_NPROC`: headroom without a scope, untouched with one
295///
296/// A per-uid count cannot be turned into a bound on one tree, only into headroom above whatever
297/// the uid owns. Without a cgroup scope that headroom is the only process bound there is, so it
298/// is set from a baseline measured in the unit the kernel checks: a *process* count fed to a
299/// Linux kernel counting threads yields a ceiling below the uid's live usage, which is the same
300/// `EAGAIN` the headroom exists to prevent.
301///
302/// With a scope, `pids.max` bounds the tree exactly and headroom adds nothing but a way for an
303/// unrelated thread elsewhere on the host to fail a `fork()` here, so the inherited value is left
304/// in place.
305#[cfg(unix)]
306#[allow(unsafe_code)]
307// The `as u32` on each `RLIMIT_*` below is redundant on Linux, where the constants are already
308// `__rlimit_resource_t` (`c_uint`), and load-bearing on macOS, where they are `c_int`. Clippy only
309// ever sees one of the two platforms at a time, so on Linux it reads the portability cast as a
310// no-op.
311#[allow(clippy::unnecessary_cast)]
312pub(crate) fn apply_hard_rlimits(
313    limits: &HostResourceLimits,
314    nproc: NprocBound,
315) -> std::io::Result<()> {
316    if let NprocBound::Headroom { baseline } = nproc {
317        set_hard_rlimit(
318            libc::RLIMIT_NPROC as u32,
319            baseline.saturating_add(limits.max_processes),
320        )?;
321    }
322    set_hard_rlimit(libc::RLIMIT_NOFILE as u32, limits.max_open_files)?;
323    set_hard_rlimit(libc::RLIMIT_FSIZE as u32, limits.max_file_size_bytes)?;
324    set_hard_rlimit(libc::RLIMIT_CPU as u32, limits.cpu_seconds)?;
325    set_hard_rlimit(libc::RLIMIT_CORE as u32, 0)?;
326
327    // `memory_bytes` maps to `RLIMIT_AS` (total address space) on Linux, where it is enforced
328    // and a failure to set it is fatal.
329    #[cfg(target_os = "linux")]
330    set_hard_rlimit(libc::RLIMIT_AS as u32, limits.memory_bytes)?;
331
332    // macOS has no `RLIMIT_AS`, and its `RLIMIT_DATA` is present in the headers but not
333    // enforceable: the kernel rejects any finite value with `EINVAL`. The call is still made (a
334    // BSD that does honour it gets the bound) but its failure is tolerated rather than turned
335    // into a failed spawn — refusing to run any subprocess at all on the primary development
336    // platform, over a knob that platform's kernel will never implement, would trade a missing
337    // bound for a broken runtime. The residual gap is exactly what `W-SEC-010` documents: on a
338    // host with no cgroups, memory is bounded by neither an aggregate nor a per-process ceiling.
339    #[cfg(not(target_os = "linux"))]
340    let _ = set_hard_rlimit(libc::RLIMIT_DATA as u32, limits.memory_bytes);
341
342    Ok(())
343}
344
345/// One `getrlimit`/`setrlimit` pair: read the inherited hard limit, clamp `value` to it, then
346/// write the result to **both** the soft and hard slot.
347#[cfg(unix)]
348#[allow(unsafe_code)]
349fn set_hard_rlimit(resource: u32, value: u64) -> std::io::Result<()> {
350    let mut current = libc::rlimit {
351        rlim_cur: 0,
352        rlim_max: 0,
353    };
354    // SAFETY: `resource` is one of the `libc::RLIMIT_*` constants (cast to the width this
355    // platform's `setrlimit` takes) and `current` is a live, correctly-sized stack `rlimit`.
356    if unsafe { libc::getrlimit(resource as _, &mut current) } != 0 {
357        return Err(std::io::Error::last_os_error());
358    }
359
360    // `rlim_t` is 64-bit on every platform this runtime targets, but saturate rather than cast
361    // so a 32-bit `rlim_t` would clamp to its own maximum instead of silently wrapping.
362    let requested = libc::rlim_t::try_from(value).unwrap_or(libc::rlim_t::MAX);
363    let target = if current.rlim_max == libc::RLIM_INFINITY {
364        requested
365    } else {
366        requested.min(current.rlim_max)
367    };
368
369    let limit = libc::rlimit {
370        rlim_cur: target,
371        rlim_max: target,
372    };
373    // SAFETY: same as above; `limit` is a live stack `rlimit` and is only read by the call.
374    if unsafe { libc::setrlimit(resource as _, &limit) } != 0 {
375        return Err(std::io::Error::last_os_error());
376    }
377    Ok(())
378}
379
380/// How many **tasks** — in the unit this platform's `RLIMIT_NPROC` is actually checked against —
381/// the runtime's own uid currently owns, or `None` if the host cannot be asked.
382///
383/// The unit differs per platform, and matching it is the entire point of this function:
384///
385///   - **Linux**: threads. `setrlimit(2)` states the limit is "the maximum number of processes
386///     (or, more precisely on Linux, threads) that can be created for the real user ID", and the
387///     kernel's `fork()` path compares against the uid's live task count. So this sums each owned
388///     process's `/proc/<pid>/task/` entries (one per thread, the leader included) rather than
389///     counting `/proc/<pid>` directories, which would count one per *process* and land an order
390///     of magnitude low on any account running threaded software.
391///   - **macOS**: processes. The BSD-derived `RLIMIT_NPROC` there genuinely counts one entry per
392///     process, so `proc_listpids` is already the matching unit.
393///
394/// Measured **in the parent** once per spawn that has no cgroup scope — it walks `/proc` (Linux)
395/// or calls into libproc (macOS), neither of which is async-signal-safe, so it must never be
396/// called from `pre_exec`. Its one consumer is [`NprocBound::Headroom`]; see there for why a
397/// per-uid limit is meaningless without it.
398///
399/// The Linux walk is inherently racy — a process can exit between the `read_dir("/proc")` and the
400/// read of its `task/` directory — and every such race is absorbed by skipping that process. A
401/// baseline slightly stale in either direction is harmless: it is added to `max_processes`
402/// headroom, not compared for equality against anything.
403#[cfg_attr(not(unix), allow(dead_code))]
404// Each `cfg` arm returns explicitly so that exactly one of them is live per target without the
405// arms having to be arranged so the live one happens to be last — on the target where an arm is
406// the final live statement, clippy sees its `return` as redundant, but removing it breaks every
407// other target.
408#[allow(clippy::needless_return)]
409pub(crate) fn uid_task_count() -> Option<u64> {
410    #[cfg(target_os = "linux")]
411    {
412        // SAFETY: `geteuid` takes no arguments and cannot fail.
413        let uid = {
414            #[allow(unsafe_code)]
415            unsafe {
416                libc::geteuid()
417            }
418        };
419        return uid_task_count_in(Path::new("/proc"), uid);
420    }
421
422    #[cfg(target_os = "macos")]
423    {
424        /// `PROC_UID_ONLY` from `<libproc.h>`.
425        const PROC_UID_ONLY: u32 = 4;
426
427        // Declared here rather than taken from the `libc` crate, which does not expose libproc on
428        // this target. The symbol lives in libSystem, which every macOS binary already links, so
429        // this needs no build-script or link attribute. Called with a null buffer, it returns the
430        // *byte size* a full listing would need — the count without the listing.
431        extern "C" {
432            fn proc_listpids(
433                r#type: u32,
434                typeinfo: u32,
435                buffer: *mut libc::c_void,
436                buffersize: libc::c_int,
437            ) -> libc::c_int;
438        }
439
440        // SAFETY: the null-buffer/zero-size form is the documented size query; it writes nothing.
441        #[allow(unsafe_code)]
442        let bytes = unsafe {
443            let uid = libc::geteuid();
444            proc_listpids(PROC_UID_ONLY, uid, std::ptr::null_mut(), 0)
445        };
446        if bytes <= 0 {
447            return None;
448        }
449        return Some(bytes as u64 / std::mem::size_of::<u32>() as u64);
450    }
451
452    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
453    None
454}
455
456/// The Linux walk behind [`uid_task_count`], over a `/proc`-shaped tree at `proc_root`: the
457/// `task/` entries of every all-digit directory owned by `uid`, summed.
458///
459/// `None` only when `proc_root` itself cannot be read. A process directory whose `task/` cannot be
460/// read — one that exited mid-walk — contributes 0.
461#[cfg(target_os = "linux")]
462pub(crate) fn uid_task_count_in(proc_root: &Path, uid: u32) -> Option<u64> {
463    use std::os::unix::fs::MetadataExt;
464
465    // A `/proc/<pid>` directory is owned by the uid the process runs as, so a `stat` per entry
466    // finds this uid's processes without opening or parsing a single `status` file.
467    let entries = std::fs::read_dir(proc_root).ok()?;
468    let count = entries
469        .flatten()
470        .filter(|entry| {
471            entry
472                .file_name()
473                .to_str()
474                .is_some_and(|name| name.bytes().all(|b| b.is_ascii_digit()))
475                && entry.metadata().is_ok_and(|metadata| metadata.uid() == uid)
476        })
477        .map(|entry| {
478            // One `task/` entry per thread of that process, the group leader included.
479            std::fs::read_dir(entry.path().join("task"))
480                .map(|threads| threads.flatten().count() as u64)
481                .unwrap_or(0)
482        })
483        .sum();
484    Some(count)
485}
486
487/// A workdir-size ceiling that was crossed: what was allowed, and what was actually observed.
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489pub(crate) struct WorkdirBreach {
490    pub(crate) max_bytes: u64,
491    pub(crate) observed_bytes: u64,
492}
493
494/// Periodic workdir-size check for one session.
495///
496/// Walks the workdir tree every [`WORKDIR_CHECK_INTERVAL`] on a plain OS thread (not a tokio
497/// task — the session runs guests on several runtimes and the guard must outlive all of them),
498/// and latches the first breach it sees. Three consumers read that latch:
499///
500///   * `shell::execute_shell` and `runtime::dispatch_native_tool` refuse to spawn once it is
501///     set, which stops a disk filler from writing another byte;
502///   * the agent turn loop turns it into [`crate::errors::RuntimeError::WorkdirSizeExceeded`],
503///     which terminates the session.
504///
505/// **Why a periodic check and not a tmpfs-backed workdir with a size mount option.** The
506/// structural version needs a mount namespace the runtime does not have: nothing in this
507/// codebase's process model ever runs as root, and the `sealed-containment-runtime` work that
508/// would create one is separate and unbuilt. A poll is what is available without it, at the cost
509/// of a one-interval detection lag.
510#[derive(Debug)]
511pub(crate) struct WorkdirGuard {
512    max_bytes: u64,
513    breach: Mutex<Option<WorkdirBreach>>,
514    stop: AtomicBool,
515}
516
517impl WorkdirGuard {
518    /// Spawn the checker thread for `workdir`. The returned handle owns the latch; dropping the
519    /// last clone stops the thread within one interval.
520    ///
521    /// `workdir` is the directory measured; `records_root` is the session's records directory,
522    /// where a breach is logged, or `None` outside a session, where it is reported on stderr
523    /// alone.
524    pub(crate) fn spawn(
525        workdir: &crate::workdir_fs::WorkdirRoot,
526        records_root: Option<&crate::workdir_fs::WorkdirRoot>,
527        max_bytes: u64,
528    ) -> Arc<Self> {
529        let guard = Arc::new(Self {
530            max_bytes,
531            breach: Mutex::new(None),
532            stop: AtomicBool::new(false),
533        });
534
535        // The thread holds a Weak, so it can never keep the session's guard alive; the stop flag
536        // covers the reverse (guard dropped while the thread is mid-sleep). Same both-ends shape
537        // as `limits::EpochTicker`.
538        let weak = Arc::downgrade(&guard);
539        let workdir = workdir.clone();
540        let records_root = records_root.cloned();
541        thread::spawn(move || loop {
542            thread::sleep(WORKDIR_CHECK_INTERVAL);
543            let Some(guard) = weak.upgrade() else {
544                break;
545            };
546            if guard.stop.load(Ordering::Relaxed) || guard.breach().is_some() {
547                break;
548            }
549            let observed = directory_size_bytes(workdir.path());
550            if observed > guard.max_bytes {
551                guard.record_breach(records_root.as_ref(), observed);
552                break;
553            }
554        });
555
556        guard
557    }
558
559    /// The latched breach, if the workdir has crossed its ceiling.
560    pub(crate) fn breach(&self) -> Option<WorkdirBreach> {
561        self.breach.lock().ok().and_then(|breach| *breach)
562    }
563
564    fn record_breach(
565        &self,
566        records_root: Option<&crate::workdir_fs::WorkdirRoot>,
567        observed_bytes: u64,
568    ) {
569        let breach = WorkdirBreach {
570            max_bytes: self.max_bytes,
571            observed_bytes,
572        };
573        if let Ok(mut slot) = self.breach.lock() {
574            if slot.is_none() {
575                *slot = Some(breach);
576            }
577        }
578        let message = workdir_breach_message(breach);
579        crate::runtime_err!("[capsule-runtime] {message}");
580        if let Some(records_root) = records_root {
581            crate::agent::append_bootstrap_log(records_root, &format!("[resources] {message}"));
582        }
583    }
584}
585
586impl Drop for WorkdirGuard {
587    fn drop(&mut self) {
588        self.stop.store(true, Ordering::Relaxed);
589    }
590}
591
592/// The one wording for a workdir breach, shared by the guard's log line and by the error the
593/// subprocess spawn paths return, so the two cannot drift.
594pub(crate) fn workdir_breach_message(breach: WorkdirBreach) -> String {
595    format!(
596        "workdir grew to {} bytes, past the {} byte ceiling \
597         (capabilities.resources.workdir_max_bytes)",
598        breach.observed_bytes, breach.max_bytes
599    )
600}
601
602/// Total size of every regular file beneath `root`, in bytes.
603///
604/// Iterative rather than recursive, and reads `symlink_metadata` so a symlink counts as its own
605/// (tiny) entry and is never followed — a symlinked directory would otherwise let a capsule both
606/// hide growth outside the workdir and spin this walk on a cycle. Unreadable entries are skipped:
607/// this is a best-effort accounting of growth, and a partial read that under-counts is strictly
608/// better than a walk that gives up entirely.
609pub(crate) fn directory_size_bytes(root: &Path) -> u64 {
610    let mut total: u64 = 0;
611    let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
612
613    while let Some(dir) = stack.pop() {
614        let Ok(entries) = std::fs::read_dir(&dir) else {
615            continue;
616        };
617        for entry in entries.flatten() {
618            let Ok(metadata) = entry.path().symlink_metadata() else {
619                continue;
620            };
621            if metadata.is_dir() {
622                stack.push(entry.path());
623            } else {
624                total = total.saturating_add(metadata.len());
625            }
626        }
627    }
628
629    total
630}
631
632#[cfg(all(test, target_os = "linux"))]
633pub(crate) mod test_support {
634    use std::fs::{File, OpenOptions};
635    use std::sync::{Arc, Barrier};
636
637    /// Serializes the tests that start [`HeldThreads`] or assert on how the uid's task count
638    /// moves, so one test's holders never land inside another's before/after window. Held until
639    /// the returned file is dropped. Other processes the uid owns still move the count; the
640    /// margins in those tests absorb that.
641    ///
642    /// An exclusive lock on a file in the temp directory, not a static mutex: cargo-nextest runs
643    /// every test in its own process, where a static serializes nothing. Each call opens its own
644    /// file description, so the lock also excludes other threads of the same `cargo test` binary.
645    pub(crate) fn host_threads_lock() -> File {
646        let path = std::env::temp_dir().join("murmur-capsule-runtime-host-threads.lock");
647        let file = OpenOptions::new()
648            .create(true)
649            .truncate(false)
650            .write(true)
651            .open(&path)
652            .unwrap_or_else(|error| panic!("opening {}: {error}", path.display()));
653        file.lock()
654            .unwrap_or_else(|error| panic!("locking {}: {error}", path.display()));
655        file
656    }
657
658    /// `count` threads in the test process, each parked on a barrier, standing in for threads the
659    /// rest of the host starts while a session runs. [`Self::start`] returns only once every
660    /// one of them is running, so the uid's task count already includes them; dropping the value
661    /// releases and joins them.
662    pub(crate) struct HeldThreads {
663        release: Arc<Barrier>,
664        handles: Vec<std::thread::JoinHandle<()>>,
665    }
666
667    impl HeldThreads {
668        pub(crate) fn start(count: usize) -> Self {
669            let started = Arc::new(Barrier::new(count + 1));
670            let release = Arc::new(Barrier::new(count + 1));
671            let handles = (0..count)
672                .map(|_| {
673                    let started = Arc::clone(&started);
674                    let release = Arc::clone(&release);
675                    std::thread::Builder::new()
676                        .stack_size(64 * 1024)
677                        .spawn(move || {
678                            started.wait();
679                            release.wait();
680                        })
681                        .expect("a holder thread must start")
682                })
683                .collect();
684            started.wait();
685            Self { release, handles }
686        }
687    }
688
689    impl Drop for HeldThreads {
690        fn drop(&mut self) {
691            self.release.wait();
692            for handle in self.handles.drain(..) {
693                let _ = handle.join();
694            }
695        }
696    }
697
698    /// This process's own `(soft, hard)` `RLIMIT_NPROC`, the values a child inherits when
699    /// `apply_hard_rlimits` leaves the limit alone.
700    #[allow(unsafe_code)]
701    pub(crate) fn own_rlimit_nproc() -> (libc::rlim_t, libc::rlim_t) {
702        let mut current = libc::rlimit {
703            rlim_cur: 0,
704            rlim_max: 0,
705        };
706        // SAFETY: a plain `getrlimit` read into a live stack `rlimit`.
707        assert_eq!(
708            unsafe { libc::getrlimit(libc::RLIMIT_NPROC, &mut current) },
709            0
710        );
711        (current.rlim_cur, current.rlim_max)
712    }
713
714    /// An rlimit as `ulimit` prints it: `unlimited` for `RLIM_INFINITY`, the number otherwise.
715    pub(crate) fn rendered_rlimit(value: libc::rlim_t) -> String {
716        if value == libc::RLIM_INFINITY {
717            "unlimited".to_string()
718        } else {
719            value.to_string()
720        }
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn resolve_uses_defaults_when_manifest_is_silent() {
730        assert_eq!(
731            HostResourceLimits::resolve(None),
732            HostResourceLimits::default()
733        );
734    }
735
736    /// The rule inherited from `capabilities.limits`: a manifest that declares the section but
737    /// omits a field gets that field's default, never "unlimited".
738    #[test]
739    fn resolve_fills_each_omitted_field_independently() {
740        let declared = murmur_artifact::ResourceCapabilities {
741            max_open_files: Some(16),
742            cgroup_pids_max: Some(32),
743            ..murmur_artifact::ResourceCapabilities::default()
744        };
745        let limits = HostResourceLimits::resolve(Some(&declared));
746
747        assert_eq!(limits.max_open_files, 16);
748        assert_eq!(limits.cgroup_pids_max, 32);
749        assert_eq!(limits.max_processes, DEFAULT_MAX_PROCESSES);
750        assert_eq!(limits.max_file_size_bytes, DEFAULT_MAX_FILE_SIZE_BYTES);
751        assert_eq!(limits.cpu_seconds, DEFAULT_CPU_SECONDS);
752        assert_eq!(limits.memory_bytes, DEFAULT_MEMORY_BYTES);
753        assert_eq!(limits.cgroup_memory_bytes, DEFAULT_CGROUP_MEMORY_BYTES);
754        assert_eq!(limits.cgroup_cpu_percent, DEFAULT_CGROUP_CPU_PERCENT);
755        assert_eq!(
756            limits.cgroup_io_bytes_per_sec,
757            DEFAULT_CGROUP_IO_BYTES_PER_SEC
758        );
759        assert_eq!(limits.workdir_max_bytes, DEFAULT_WORKDIR_MAX_BYTES);
760    }
761
762    #[test]
763    fn free_resolve_matches_the_inherent_one() {
764        let declared = murmur_artifact::ResourceCapabilities {
765            cpu_seconds: Some(5),
766            ..murmur_artifact::ResourceCapabilities::default()
767        };
768        assert_eq!(
769            resolve(Some(&declared)),
770            HostResourceLimits::resolve(Some(&declared))
771        );
772    }
773
774    /// Attribution must name a limit only where the kernel's own signal identifies exactly one.
775    /// The negative half matters too: over-attributing a `SIGKILL` to `memory_bytes` would put a
776    /// guess into the trace as if it were evidence.
777    #[test]
778    fn only_the_two_unambiguous_signals_name_a_limit() {
779        assert_eq!(limit_from_signal(libc::SIGXCPU), Some("cpu_seconds"));
780        assert_eq!(
781            limit_from_signal(libc::SIGXFSZ),
782            Some("max_file_size_bytes")
783        );
784        for ambiguous in [libc::SIGKILL, libc::SIGSEGV, libc::SIGABRT, libc::SIGTERM] {
785            assert_eq!(
786                limit_from_signal(ambiguous),
787                None,
788                "signal {ambiguous} has more than one possible cause and must stay unattributed"
789            );
790        }
791    }
792
793    /// Each name is taken from the producer that attributes it, so renaming an attribution
794    /// without giving the new name its line fails here rather than falling through to the generic
795    /// wording.
796    #[test]
797    fn every_attributable_limit_has_its_own_line() {
798        use crate::cgroup::CgroupEventCounters;
799
800        let cpu = limit_from_signal(libc::SIGXCPU).unwrap();
801        let fsize = limit_from_signal(libc::SIGXFSZ).unwrap();
802        let memory = CgroupEventCounters {
803            oom_kill: 1,
804            pids_max: 0,
805        }
806        .attribution_since(CgroupEventCounters::default())
807        .unwrap();
808        let pids = CgroupEventCounters {
809            oom_kill: 0,
810            pids_max: 1,
811        }
812        .attribution_since(CgroupEventCounters::default())
813        .unwrap();
814
815        let expected = [
816            (
817                pids,
818                "resource_limit: cgroup_pids_max — the capsule reached its process limit while \
819                 this command ran, so starting a new process failed (EAGAIN). Retry with fewer \
820                 parallel processes, or ask the operator to raise \
821                 capabilities.resources.cgroup_pids_max.",
822            ),
823            (
824                memory,
825                "resource_limit: cgroup_memory_bytes — the capsule reached its memory limit while \
826                 this command ran, and the kernel killed a process in it to free memory. Retry \
827                 using less memory at once (smaller inputs, fewer parallel jobs), or ask the \
828                 operator to raise capabilities.resources.cgroup_memory_bytes.",
829            ),
830            (
831                cpu,
832                "resource_limit: cpu_seconds — this command used up its CPU-time limit and was \
833                 stopped. Retry with less work per command, or ask the operator to raise \
834                 capabilities.resources.cpu_seconds.",
835            ),
836            (
837                fsize,
838                "resource_limit: max_file_size_bytes — this command tried to write a file larger \
839                 than the capsule's file-size limit and was stopped. Retry writing smaller files, \
840                 or ask the operator to raise capabilities.resources.max_file_size_bytes.",
841            ),
842        ];
843        for (name, text) in expected {
844            let line = resource_limit_line(name);
845            assert_eq!(line, text);
846            assert!(line.starts_with(&format!("resource_limit: {name} — ")));
847            assert!(line.contains(&format!("capabilities.resources.{name}")));
848            assert!(line.contains("ask the operator to raise"));
849            assert!(!line.contains('\n'), "{line:?} must be a single line");
850        }
851
852        for cgroup in [pids, memory] {
853            let line = resource_limit_line(cgroup);
854            assert!(line.contains("the capsule"), "{line}");
855            assert!(line.contains("while this command ran"), "{line}");
856        }
857        for signal in [cpu, fsize] {
858            assert!(resource_limit_line(signal).contains("this command"));
859        }
860
861        assert_eq!(
862            resource_limit_line("some_future_limit"),
863            "resource_limit: some_future_limit — this command was stopped by the capsule's \
864             capabilities.resources.some_future_limit limit. Retry with less work, or ask the \
865             operator to raise capabilities.resources.some_future_limit."
866        );
867    }
868
869    #[test]
870    fn directory_size_sums_nested_files() {
871        let temp = tempfile::tempdir().unwrap();
872        std::fs::write(temp.path().join("a"), vec![0u8; 100]).unwrap();
873        std::fs::create_dir(temp.path().join("nested")).unwrap();
874        std::fs::write(temp.path().join("nested").join("b"), vec![0u8; 250]).unwrap();
875
876        assert_eq!(directory_size_bytes(temp.path()), 350);
877    }
878
879    #[test]
880    fn directory_size_of_a_missing_path_is_zero() {
881        assert_eq!(
882            directory_size_bytes(Path::new("/nonexistent-murmur-workdir-probe")),
883            0
884        );
885    }
886
887    /// The guard starts un-breached, and stays that way for a workdir well under its ceiling —
888    /// the "no false positive" half. The breach half is a real-host manual check (see
889    /// `docs/content/reference/resource-limits-manual-verification.md`), not a timing-dependent
890    /// unit test.
891    #[test]
892    fn guard_reports_no_breach_for_a_workdir_under_the_ceiling() {
893        let temp = tempfile::tempdir().unwrap();
894        std::fs::write(temp.path().join("small"), vec![0u8; 64]).unwrap();
895
896        let guard = WorkdirGuard::spawn(
897            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
898            None,
899            DEFAULT_WORKDIR_MAX_BYTES,
900        );
901        assert_eq!(guard.breach(), None);
902    }
903
904    /// The baseline must be a real, plausible count — a `0` here would silently turn
905    /// `max_processes` back into the absolute per-uid ceiling that makes every `fork()` in a
906    /// subprocess fail on any account already past the limit.
907    #[cfg(any(target_os = "linux", target_os = "macos"))]
908    #[test]
909    fn uid_task_count_reports_a_plausible_baseline() {
910        let count = uid_task_count().expect("this platform can be asked for its task count");
911        assert!(
912            count > 0,
913            "the test process itself is owned by this uid, so the count cannot be zero"
914        );
915    }
916
917    /// On Linux `RLIMIT_NPROC` is enforced against threads, so the baseline sums `task/` entries:
918    /// the three owned processes below hold 7 threads between them, where a count of processes
919    /// would come out as 3. Directories that are not pids (`self`), a process with no readable
920    /// `task/` (`400`), and processes owned by another uid add nothing.
921    ///
922    /// Run against a synthetic tree because the live count covers the whole uid and moves with
923    /// every other thread on the machine, so no assertion that it rises can be deterministic.
924    #[cfg(target_os = "linux")]
925    #[test]
926    fn uid_task_count_sums_threads_not_processes() {
927        let temp = tempfile::tempdir().unwrap();
928        let root = temp.path();
929        for (pid, threads) in [("100", 1), ("200", 4), ("300", 2), ("self", 5)] {
930            let task = root.join(pid).join("task");
931            std::fs::create_dir_all(&task).unwrap();
932            for thread in 0..threads {
933                std::fs::write(task.join(thread.to_string()), b"").unwrap();
934            }
935        }
936        std::fs::create_dir(root.join("400")).unwrap();
937
938        // The owner the walk reads off each pid directory, taken from one of them.
939        let own_uid = {
940            use std::os::unix::fs::MetadataExt;
941            std::fs::metadata(root.join("100")).unwrap().uid()
942        };
943
944        assert_eq!(uid_task_count_in(root, own_uid), Some(7));
945        assert_eq!(uid_task_count_in(root, own_uid.wrapping_add(1)), Some(0));
946        assert_eq!(uid_task_count_in(&root.join("missing"), own_uid), None);
947    }
948
949    /// `rlim_cur == rlim_max` is the whole point of this module's rlimit path: a soft-only cap
950    /// can be raised back by the capsule itself. Asserted against the live process by lowering
951    /// a safely lowerable limit (`RLIMIT_CORE`, which this runtime pins at zero anyway)
952    /// and reading it back.
953    #[cfg(unix)]
954    #[test]
955    #[allow(unsafe_code)]
956    // Same portability cast as `apply_hard_rlimits`: `RLIMIT_CORE` is `c_uint` on Linux and
957    // `c_int` on macOS, so the cast only looks redundant from whichever platform clippy runs on.
958    #[allow(clippy::unnecessary_cast)]
959    fn set_hard_rlimit_writes_both_the_soft_and_hard_slot() {
960        set_hard_rlimit(libc::RLIMIT_CORE as u32, 0).unwrap();
961
962        let mut current = libc::rlimit {
963            rlim_cur: 1,
964            rlim_max: 1,
965        };
966        // SAFETY: a plain `getrlimit` read into a live stack `rlimit`.
967        assert_eq!(
968            unsafe { libc::getrlimit(libc::RLIMIT_CORE as _, &mut current) },
969            0
970        );
971        assert_eq!(current.rlim_cur, 0);
972        assert_eq!(
973            current.rlim_max, 0,
974            "the hard slot must be set, not only the soft one"
975        );
976    }
977}