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, so a
19//!   tree of distinct, rapidly-forking, short-lived processes evades it in practice even when
20//!   set correctly. `pids.max` on a cgroup is per-cgroup and does not.
21//! * **a periodic workdir-size check** ([`WorkdirGuard`]), on every platform. A plain filesystem
22//!   walk on a fixed cadence, so a breach is caught within one poll interval — not instantly.
23//!
24//! ## A silent manifest means defaults, never "unlimited"
25//!
26//! [`HostResourceLimits::resolve`] follows [`crate::limits::ExecutionLimits::resolve`] exactly:
27//! the manifest's `capabilities.resources` block with every omitted field replaced by its
28//! `DEFAULT_*` constant, and the whole block's absence treated as "every field omitted". There
29//! is no "unset" state downstream — a capsule that declares nothing is bounded identically to
30//! one that declares every default by hand.
31//!
32//! ## Hard limits, not soft ones
33//!
34//! `setrlimit(2)` takes both a soft (`rlim_cur`) and a hard (`rlim_max`) value, and an
35//! unprivileged process may raise its own soft limit up to the hard one at any time. Against a
36//! hostile agent a soft-only cap is therefore advisory: one `setrlimit` call from inside the
37//! capsule undoes it. Every limit this module sets writes `rlim_cur == rlim_max`, leaving no
38//! ceiling to raise into, and lowering `rlim_max` is irreversible for an unprivileged process.
39
40use std::{
41    path::{Path, PathBuf},
42    sync::{
43        atomic::{AtomicBool, Ordering},
44        Arc, Mutex,
45    },
46    thread,
47    time::Duration,
48};
49
50/// Default `RLIMIT_NPROC` headroom — how much past the runtime's own uid baseline a subprocess
51/// tree may add, in whichever unit this platform's `RLIMIT_NPROC` is checked against. See
52/// [`apply_hard_rlimits`] for why this is headroom rather than an absolute ceiling, and
53/// [`uid_task_count`] for the per-platform unit and how the baseline is measured.
54pub const DEFAULT_MAX_PROCESSES: u64 = 128;
55
56/// Default `RLIMIT_NOFILE` hard ceiling — open descriptors per spawned subprocess.
57pub const DEFAULT_MAX_OPEN_FILES: u64 = 1024;
58
59/// Default `RLIMIT_FSIZE` hard ceiling — 4 GiB, the largest single file a subprocess may write.
60pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = 4 * 1024 * 1024 * 1024;
61
62/// Default `RLIMIT_CPU` hard ceiling — one hour of CPU time per spawned subprocess.
63pub const DEFAULT_CPU_SECONDS: u64 = 3600;
64
65/// Default `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling — 2 GiB per subprocess.
66pub const DEFAULT_MEMORY_BYTES: u64 = 2 * 1024 * 1024 * 1024;
67
68/// Default cgroup `memory.max` — 4 GiB aggregate across the whole subprocess tree. Above the
69/// per-process ceiling on purpose: the tree bound exists to catch what *many* processes do
70/// together, so setting it at or below the single-process bound would make it redundant.
71pub const DEFAULT_CGROUP_MEMORY_BYTES: u64 = 4 * 1024 * 1024 * 1024;
72
73/// Default cgroup `pids.max` — 256 tasks aggregate across the whole subprocess tree.
74pub const DEFAULT_CGROUP_PIDS_MAX: u64 = 256;
75
76/// Default cgroup `cpu.max` quota as a percentage of one core: 200 = two cores' worth.
77pub const DEFAULT_CGROUP_CPU_PERCENT: u32 = 200;
78
79/// Default cgroup `io.max` read+write throughput on the workdir's backing device — 100 MiB/s.
80pub const DEFAULT_CGROUP_IO_BYTES_PER_SEC: u64 = 100 * 1024 * 1024;
81
82/// Default ceiling on total workdir size — 10 GiB, checked periodically.
83pub const DEFAULT_WORKDIR_MAX_BYTES: u64 = 10 * 1024 * 1024 * 1024;
84
85/// How often [`WorkdirGuard`] walks the workdir tree.
86///
87/// An internal constant rather than a manifest key, matching [`crate::limits::EPOCH_TICK_INTERVAL`]:
88/// the cadence changes *when* a breach is noticed, never *whether* it is, so exposing it would
89/// add a knob with no security meaning. The consequence: a disk filler is caught within one
90/// interval of crossing the ceiling, not at the instant it crosses.
91pub(crate) const WORKDIR_CHECK_INTERVAL: Duration = Duration::from_secs(10);
92
93/// Fully-resolved host-process limits for one session: the manifest's `capabilities.resources`
94/// block with every omitted field replaced by its `DEFAULT_*`.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct HostResourceLimits {
97    /// `RLIMIT_NPROC` headroom above the runtime's own uid baseline. Per-**uid**, not
98    /// per-process-tree, and counted in the unit that uid's kernel enforces against
99    /// ([`uid_task_count`]). See the module docs for why the cgroup `pids.max` below exists
100    /// alongside it rather than duplicating it, and [`apply_hard_rlimits`] for why it is applied
101    /// as headroom.
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/// Sets every [`HostResourceLimits`] rlimit field as a **hard** limit (`rlim_cur == rlim_max`),
210/// plus `RLIMIT_CORE = 0`.
211///
212/// Called from inside a forked child's `pre_exec` closure, so everything here is restricted to
213/// async-signal-safe operations: two syscalls per limit and no allocation, no locking, and no
214/// error formatting (errors are returned as bare `io::Error::from_raw_os_error`).
215///
216/// `RLIMIT_CORE` is fixed at zero with no manifest surface at all — a core dump of a capsule
217/// subprocess writes a potentially multi-gigabyte file, holding whatever was in that process's
218/// memory, into the workdir or the host's core pattern. It belongs to the same category as the
219/// fixed capsule device set in `sandbox.rs`: an invariant, not a setting.
220///
221/// A requested value above the inherited hard limit is **clamped down to it** rather than
222/// treated as an error: an unprivileged process cannot raise `rlim_max`, so the inherited value
223/// is the real ceiling either way, and failing the spawn would replace an unwidenable bound with
224/// no subprocess at all.
225///
226/// ## Why `max_processes` is headroom, not an absolute ceiling
227///
228/// `RLIMIT_NPROC` is checked against **everything the uid already owns**, not against the ones in
229/// this tree — and on Linux the unit it counts is *threads*, not processes: `setrlimit(2)` calls
230/// it "the maximum number of processes (or, more precisely on Linux, threads) that can be created
231/// for the real user ID". On macOS, whose BSD-derived limit is genuinely per-process, it counts
232/// processes. Either way an interactive account is already deep into that number before the
233/// capsule starts — and far deeper in threads than in processes — so a hard `RLIMIT_NPROC` of 128
234/// does not bound the capsule at 128; it makes the first `fork()` in the subprocess fail with
235/// `EAGAIN`. That is a broken runtime, not a bound.
236///
237/// So `nproc_baseline` — the uid's live count *in that platform's own unit*, measured once in the
238/// parent at launch by [`uid_task_count`] — is added to the declared `max_processes`, making the
239/// manifest field mean "how much past the host's existing usage this capsule's tree may add".
240/// This only holds if the baseline is measured in the unit the kernel checks: a *process* count
241/// fed to a Linux kernel counting threads yields a ceiling below the uid's live usage, i.e. the
242/// same `EAGAIN` the headroom design exists to prevent. When the count cannot be measured the
243/// caller passes `0` and the declared value applies literally, which errs toward the tighter
244/// bound.
245///
246/// This is why the Linux cgroup `pids.max` is not redundant with this: it counts only the tasks
247/// in the capsule's own scope, so it needs no baseline and cannot be evaded by the uid's other
248/// processes.
249#[cfg(unix)]
250#[allow(unsafe_code)]
251// The `as u32` on each `RLIMIT_*` below is redundant on Linux, where the constants are already
252// `__rlimit_resource_t` (`c_uint`), and load-bearing on macOS, where they are `c_int`. Clippy only
253// ever sees one of the two platforms at a time, so on Linux it reads the portability cast as a
254// no-op.
255#[allow(clippy::unnecessary_cast)]
256pub(crate) fn apply_hard_rlimits(
257    limits: &HostResourceLimits,
258    nproc_baseline: u64,
259) -> std::io::Result<()> {
260    set_hard_rlimit(
261        libc::RLIMIT_NPROC as u32,
262        nproc_baseline.saturating_add(limits.max_processes),
263    )?;
264    set_hard_rlimit(libc::RLIMIT_NOFILE as u32, limits.max_open_files)?;
265    set_hard_rlimit(libc::RLIMIT_FSIZE as u32, limits.max_file_size_bytes)?;
266    set_hard_rlimit(libc::RLIMIT_CPU as u32, limits.cpu_seconds)?;
267    set_hard_rlimit(libc::RLIMIT_CORE as u32, 0)?;
268
269    // `memory_bytes` maps to `RLIMIT_AS` (total address space) on Linux, where it is enforced
270    // and a failure to set it is fatal.
271    #[cfg(target_os = "linux")]
272    set_hard_rlimit(libc::RLIMIT_AS as u32, limits.memory_bytes)?;
273
274    // macOS has no `RLIMIT_AS`, and its `RLIMIT_DATA` is present in the headers but not
275    // enforceable: the kernel rejects any finite value with `EINVAL`. The call is still made (a
276    // BSD that does honour it gets the bound) but its failure is tolerated rather than turned
277    // into a failed spawn — refusing to run any subprocess at all on the primary development
278    // platform, over a knob that platform's kernel will never implement, would trade a missing
279    // bound for a broken runtime. The residual gap is exactly what `W-SEC-010` documents: on a
280    // host with no cgroups, memory is bounded by neither an aggregate nor a per-process ceiling.
281    #[cfg(not(target_os = "linux"))]
282    let _ = set_hard_rlimit(libc::RLIMIT_DATA as u32, limits.memory_bytes);
283
284    Ok(())
285}
286
287/// One `getrlimit`/`setrlimit` pair: read the inherited hard limit, clamp `value` to it, then
288/// write the result to **both** the soft and hard slot.
289#[cfg(unix)]
290#[allow(unsafe_code)]
291fn set_hard_rlimit(resource: u32, value: u64) -> std::io::Result<()> {
292    let mut current = libc::rlimit {
293        rlim_cur: 0,
294        rlim_max: 0,
295    };
296    // SAFETY: `resource` is one of the `libc::RLIMIT_*` constants (cast to the width this
297    // platform's `setrlimit` takes) and `current` is a live, correctly-sized stack `rlimit`.
298    if unsafe { libc::getrlimit(resource as _, &mut current) } != 0 {
299        return Err(std::io::Error::last_os_error());
300    }
301
302    // `rlim_t` is 64-bit on every platform this runtime targets, but saturate rather than cast
303    // so a 32-bit `rlim_t` would clamp to its own maximum instead of silently wrapping.
304    let requested = libc::rlim_t::try_from(value).unwrap_or(libc::rlim_t::MAX);
305    let target = if current.rlim_max == libc::RLIM_INFINITY {
306        requested
307    } else {
308        requested.min(current.rlim_max)
309    };
310
311    let limit = libc::rlimit {
312        rlim_cur: target,
313        rlim_max: target,
314    };
315    // SAFETY: same as above; `limit` is a live stack `rlimit` and is only read by the call.
316    if unsafe { libc::setrlimit(resource as _, &limit) } != 0 {
317        return Err(std::io::Error::last_os_error());
318    }
319    Ok(())
320}
321
322/// How many **tasks** — in the unit this platform's `RLIMIT_NPROC` is actually checked against —
323/// the runtime's own uid currently owns, or `None` if the host cannot be asked.
324///
325/// The unit differs per platform, and matching it is the entire point of this function:
326///
327///   - **Linux**: threads. `setrlimit(2)` states the limit is "the maximum number of processes
328///     (or, more precisely on Linux, threads) that can be created for the real user ID", and the
329///     kernel's `fork()` path compares against the uid's live task count. So this sums each owned
330///     process's `/proc/<pid>/task/` entries (one per thread, the leader included) rather than
331///     counting `/proc/<pid>` directories, which would count one per *process* and land an order
332///     of magnitude low on any account running threaded software.
333///   - **macOS**: processes. The BSD-derived `RLIMIT_NPROC` there genuinely counts one entry per
334///     process, so `proc_listpids` is already the matching unit.
335///
336/// Measured once per launch **in the parent** — it walks `/proc` (Linux) or calls into libproc
337/// (macOS), neither of which is async-signal-safe, so it must never be called from `pre_exec`.
338/// Its one consumer is the `RLIMIT_NPROC` baseline in [`apply_hard_rlimits`]; see there for why a
339/// per-uid limit is meaningless without it.
340///
341/// The Linux walk is inherently racy — a process can exit between the `read_dir("/proc")` and the
342/// read of its `task/` directory — and every such race is absorbed by skipping that process. A
343/// baseline slightly stale in either direction is harmless: it is added to `max_processes`
344/// headroom, not compared for equality against anything.
345#[cfg_attr(not(unix), allow(dead_code))]
346// Each `cfg` arm returns explicitly so that exactly one of them is live per target without the
347// arms having to be arranged so the live one happens to be last — on the target where an arm is
348// the final live statement, clippy sees its `return` as redundant, but removing it breaks every
349// other target.
350#[allow(clippy::needless_return)]
351pub(crate) fn uid_task_count() -> Option<u64> {
352    #[cfg(target_os = "linux")]
353    {
354        // SAFETY: `geteuid` takes no arguments and cannot fail.
355        let uid = {
356            #[allow(unsafe_code)]
357            unsafe {
358                libc::geteuid()
359            }
360        };
361        return uid_task_count_in(Path::new("/proc"), uid);
362    }
363
364    #[cfg(target_os = "macos")]
365    {
366        /// `PROC_UID_ONLY` from `<libproc.h>`.
367        const PROC_UID_ONLY: u32 = 4;
368
369        // Declared here rather than taken from the `libc` crate, which does not expose libproc on
370        // this target. The symbol lives in libSystem, which every macOS binary already links, so
371        // this needs no build-script or link attribute. Called with a null buffer, it returns the
372        // *byte size* a full listing would need — the count without the listing.
373        extern "C" {
374            fn proc_listpids(
375                r#type: u32,
376                typeinfo: u32,
377                buffer: *mut libc::c_void,
378                buffersize: libc::c_int,
379            ) -> libc::c_int;
380        }
381
382        // SAFETY: the null-buffer/zero-size form is the documented size query; it writes nothing.
383        #[allow(unsafe_code)]
384        let bytes = unsafe {
385            let uid = libc::geteuid();
386            proc_listpids(PROC_UID_ONLY, uid, std::ptr::null_mut(), 0)
387        };
388        if bytes <= 0 {
389            return None;
390        }
391        return Some(bytes as u64 / std::mem::size_of::<u32>() as u64);
392    }
393
394    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
395    None
396}
397
398/// The Linux walk behind [`uid_task_count`], over a `/proc`-shaped tree at `proc_root`: the
399/// `task/` entries of every all-digit directory owned by `uid`, summed.
400///
401/// `None` only when `proc_root` itself cannot be read. A process directory whose `task/` cannot be
402/// read — one that exited mid-walk — contributes 0.
403#[cfg(target_os = "linux")]
404pub(crate) fn uid_task_count_in(proc_root: &Path, uid: u32) -> Option<u64> {
405    use std::os::unix::fs::MetadataExt;
406
407    // A `/proc/<pid>` directory is owned by the uid the process runs as, so a `stat` per entry
408    // finds this uid's processes without opening or parsing a single `status` file.
409    let entries = std::fs::read_dir(proc_root).ok()?;
410    let count = entries
411        .flatten()
412        .filter(|entry| {
413            entry
414                .file_name()
415                .to_str()
416                .is_some_and(|name| name.bytes().all(|b| b.is_ascii_digit()))
417                && entry.metadata().is_ok_and(|metadata| metadata.uid() == uid)
418        })
419        .map(|entry| {
420            // One `task/` entry per thread of that process, the group leader included.
421            std::fs::read_dir(entry.path().join("task"))
422                .map(|threads| threads.flatten().count() as u64)
423                .unwrap_or(0)
424        })
425        .sum();
426    Some(count)
427}
428
429/// A workdir-size ceiling that was crossed: what was allowed, and what was actually observed.
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431pub(crate) struct WorkdirBreach {
432    pub(crate) max_bytes: u64,
433    pub(crate) observed_bytes: u64,
434}
435
436/// Periodic workdir-size check for one session.
437///
438/// Walks the workdir tree every [`WORKDIR_CHECK_INTERVAL`] on a plain OS thread (not a tokio
439/// task — the session runs guests on several runtimes and the guard must outlive all of them),
440/// and latches the first breach it sees. Three consumers read that latch:
441///
442///   * `shell::execute_shell` and `runtime::dispatch_native_tool` refuse to spawn once it is
443///     set, which stops a disk filler from writing another byte;
444///   * the agent turn loop turns it into [`crate::errors::RuntimeError::WorkdirSizeExceeded`],
445///     which terminates the session.
446///
447/// **Why a periodic check and not a tmpfs-backed workdir with a size mount option.** The
448/// structural version needs a mount namespace the runtime does not have: nothing in this
449/// codebase's process model ever runs as root, and the `sealed-containment-runtime` work that
450/// would create one is separate and unbuilt. A poll is what is available without it, at the cost
451/// of a one-interval detection lag.
452#[derive(Debug)]
453pub(crate) struct WorkdirGuard {
454    max_bytes: u64,
455    breach: Mutex<Option<WorkdirBreach>>,
456    stop: AtomicBool,
457}
458
459impl WorkdirGuard {
460    /// Spawn the checker thread for `workdir`. The returned handle owns the latch; dropping the
461    /// last clone stops the thread within one interval.
462    pub(crate) fn spawn(workdir: &Path, max_bytes: u64) -> Arc<Self> {
463        let guard = Arc::new(Self {
464            max_bytes,
465            breach: Mutex::new(None),
466            stop: AtomicBool::new(false),
467        });
468
469        // The thread holds a Weak, so it can never keep the session's guard alive; the stop flag
470        // covers the reverse (guard dropped while the thread is mid-sleep). Same both-ends shape
471        // as `limits::EpochTicker`.
472        let weak = Arc::downgrade(&guard);
473        let workdir = workdir.to_path_buf();
474        thread::spawn(move || loop {
475            thread::sleep(WORKDIR_CHECK_INTERVAL);
476            let Some(guard) = weak.upgrade() else {
477                break;
478            };
479            if guard.stop.load(Ordering::Relaxed) || guard.breach().is_some() {
480                break;
481            }
482            let observed = directory_size_bytes(&workdir);
483            if observed > guard.max_bytes {
484                guard.record_breach(&workdir, observed);
485                break;
486            }
487        });
488
489        guard
490    }
491
492    /// The latched breach, if the workdir has crossed its ceiling.
493    pub(crate) fn breach(&self) -> Option<WorkdirBreach> {
494        self.breach.lock().ok().and_then(|breach| *breach)
495    }
496
497    fn record_breach(&self, workdir: &Path, observed_bytes: u64) {
498        let breach = WorkdirBreach {
499            max_bytes: self.max_bytes,
500            observed_bytes,
501        };
502        if let Ok(mut slot) = self.breach.lock() {
503            if slot.is_none() {
504                *slot = Some(breach);
505            }
506        }
507        let message = workdir_breach_message(breach);
508        crate::runtime_err!("[capsule-runtime] {message}");
509        crate::agent::append_bootstrap_log(workdir, &format!("[resources] {message}"));
510    }
511}
512
513impl Drop for WorkdirGuard {
514    fn drop(&mut self) {
515        self.stop.store(true, Ordering::Relaxed);
516    }
517}
518
519/// The one wording for a workdir breach, shared by the guard's log line and by the error the
520/// subprocess spawn paths return, so the two cannot drift.
521pub(crate) fn workdir_breach_message(breach: WorkdirBreach) -> String {
522    format!(
523        "workdir grew to {} bytes, past the {} byte ceiling \
524         (capabilities.resources.workdir_max_bytes)",
525        breach.observed_bytes, breach.max_bytes
526    )
527}
528
529/// Total size of every regular file beneath `root`, in bytes.
530///
531/// Iterative rather than recursive, and reads `symlink_metadata` so a symlink counts as its own
532/// (tiny) entry and is never followed — a symlinked directory would otherwise let a capsule both
533/// hide growth outside the workdir and spin this walk on a cycle. Unreadable entries are skipped:
534/// this is a best-effort accounting of growth, and a partial read that under-counts is strictly
535/// better than a walk that gives up entirely.
536pub(crate) fn directory_size_bytes(root: &Path) -> u64 {
537    let mut total: u64 = 0;
538    let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
539
540    while let Some(dir) = stack.pop() {
541        let Ok(entries) = std::fs::read_dir(&dir) else {
542            continue;
543        };
544        for entry in entries.flatten() {
545            let Ok(metadata) = entry.path().symlink_metadata() else {
546                continue;
547            };
548            if metadata.is_dir() {
549                stack.push(entry.path());
550            } else {
551                total = total.saturating_add(metadata.len());
552            }
553        }
554    }
555
556    total
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn resolve_uses_defaults_when_manifest_is_silent() {
565        assert_eq!(
566            HostResourceLimits::resolve(None),
567            HostResourceLimits::default()
568        );
569    }
570
571    /// The rule inherited from `capabilities.limits`: a manifest that declares the section but
572    /// omits a field gets that field's default, never "unlimited".
573    #[test]
574    fn resolve_fills_each_omitted_field_independently() {
575        let declared = murmur_artifact::ResourceCapabilities {
576            max_open_files: Some(16),
577            cgroup_pids_max: Some(32),
578            ..murmur_artifact::ResourceCapabilities::default()
579        };
580        let limits = HostResourceLimits::resolve(Some(&declared));
581
582        assert_eq!(limits.max_open_files, 16);
583        assert_eq!(limits.cgroup_pids_max, 32);
584        assert_eq!(limits.max_processes, DEFAULT_MAX_PROCESSES);
585        assert_eq!(limits.max_file_size_bytes, DEFAULT_MAX_FILE_SIZE_BYTES);
586        assert_eq!(limits.cpu_seconds, DEFAULT_CPU_SECONDS);
587        assert_eq!(limits.memory_bytes, DEFAULT_MEMORY_BYTES);
588        assert_eq!(limits.cgroup_memory_bytes, DEFAULT_CGROUP_MEMORY_BYTES);
589        assert_eq!(limits.cgroup_cpu_percent, DEFAULT_CGROUP_CPU_PERCENT);
590        assert_eq!(
591            limits.cgroup_io_bytes_per_sec,
592            DEFAULT_CGROUP_IO_BYTES_PER_SEC
593        );
594        assert_eq!(limits.workdir_max_bytes, DEFAULT_WORKDIR_MAX_BYTES);
595    }
596
597    #[test]
598    fn free_resolve_matches_the_inherent_one() {
599        let declared = murmur_artifact::ResourceCapabilities {
600            cpu_seconds: Some(5),
601            ..murmur_artifact::ResourceCapabilities::default()
602        };
603        assert_eq!(
604            resolve(Some(&declared)),
605            HostResourceLimits::resolve(Some(&declared))
606        );
607    }
608
609    /// Attribution must name a limit only where the kernel's own signal identifies exactly one.
610    /// The negative half matters too: over-attributing a `SIGKILL` to `memory_bytes` would put a
611    /// guess into the trace as if it were evidence.
612    #[test]
613    fn only_the_two_unambiguous_signals_name_a_limit() {
614        assert_eq!(limit_from_signal(libc::SIGXCPU), Some("cpu_seconds"));
615        assert_eq!(
616            limit_from_signal(libc::SIGXFSZ),
617            Some("max_file_size_bytes")
618        );
619        for ambiguous in [libc::SIGKILL, libc::SIGSEGV, libc::SIGABRT, libc::SIGTERM] {
620            assert_eq!(
621                limit_from_signal(ambiguous),
622                None,
623                "signal {ambiguous} has more than one possible cause and must stay unattributed"
624            );
625        }
626    }
627
628    #[test]
629    fn directory_size_sums_nested_files() {
630        let temp = tempfile::tempdir().unwrap();
631        std::fs::write(temp.path().join("a"), vec![0u8; 100]).unwrap();
632        std::fs::create_dir(temp.path().join("nested")).unwrap();
633        std::fs::write(temp.path().join("nested").join("b"), vec![0u8; 250]).unwrap();
634
635        assert_eq!(directory_size_bytes(temp.path()), 350);
636    }
637
638    #[test]
639    fn directory_size_of_a_missing_path_is_zero() {
640        assert_eq!(
641            directory_size_bytes(Path::new("/nonexistent-murmur-workdir-probe")),
642            0
643        );
644    }
645
646    /// The guard starts un-breached, and stays that way for a workdir well under its ceiling —
647    /// the "no false positive" half. The breach half is a real-host manual check (see
648    /// `docs/content/reference/resource-limits-manual-verification.md`), not a timing-dependent
649    /// unit test.
650    #[test]
651    fn guard_reports_no_breach_for_a_workdir_under_the_ceiling() {
652        let temp = tempfile::tempdir().unwrap();
653        std::fs::write(temp.path().join("small"), vec![0u8; 64]).unwrap();
654
655        let guard = WorkdirGuard::spawn(temp.path(), DEFAULT_WORKDIR_MAX_BYTES);
656        assert_eq!(guard.breach(), None);
657    }
658
659    /// The baseline must be a real, plausible count — a `0` here would silently turn
660    /// `max_processes` back into the absolute per-uid ceiling that makes every `fork()` in a
661    /// subprocess fail on any account already past the limit.
662    #[cfg(any(target_os = "linux", target_os = "macos"))]
663    #[test]
664    fn uid_task_count_reports_a_plausible_baseline() {
665        let count = uid_task_count().expect("this platform can be asked for its task count");
666        assert!(
667            count > 0,
668            "the test process itself is owned by this uid, so the count cannot be zero"
669        );
670    }
671
672    /// On Linux `RLIMIT_NPROC` is enforced against threads, so the baseline sums `task/` entries:
673    /// the three owned processes below hold 7 threads between them, where a count of processes
674    /// would come out as 3. Directories that are not pids (`self`), a process with no readable
675    /// `task/` (`400`), and processes owned by another uid add nothing.
676    ///
677    /// Run against a synthetic tree because the live count covers the whole uid and moves with
678    /// every other thread on the machine, so no assertion that it rises can be deterministic.
679    #[cfg(target_os = "linux")]
680    #[test]
681    fn uid_task_count_sums_threads_not_processes() {
682        let temp = tempfile::tempdir().unwrap();
683        let root = temp.path();
684        for (pid, threads) in [("100", 1), ("200", 4), ("300", 2), ("self", 5)] {
685            let task = root.join(pid).join("task");
686            std::fs::create_dir_all(&task).unwrap();
687            for thread in 0..threads {
688                std::fs::write(task.join(thread.to_string()), b"").unwrap();
689            }
690        }
691        std::fs::create_dir(root.join("400")).unwrap();
692
693        // The owner the walk reads off each pid directory, taken from one of them.
694        let own_uid = {
695            use std::os::unix::fs::MetadataExt;
696            std::fs::metadata(root.join("100")).unwrap().uid()
697        };
698
699        assert_eq!(uid_task_count_in(root, own_uid), Some(7));
700        assert_eq!(uid_task_count_in(root, own_uid.wrapping_add(1)), Some(0));
701        assert_eq!(uid_task_count_in(&root.join("missing"), own_uid), None);
702    }
703
704    /// `rlim_cur == rlim_max` is the whole point of this module's rlimit path: a soft-only cap
705    /// can be raised back by the capsule itself. Asserted against the live process by lowering
706    /// a safely lowerable limit (`RLIMIT_CORE`, which this runtime pins at zero anyway)
707    /// and reading it back.
708    #[cfg(unix)]
709    #[test]
710    #[allow(unsafe_code)]
711    // Same portability cast as `apply_hard_rlimits`: `RLIMIT_CORE` is `c_uint` on Linux and
712    // `c_int` on macOS, so the cast only looks redundant from whichever platform clippy runs on.
713    #[allow(clippy::unnecessary_cast)]
714    fn set_hard_rlimit_writes_both_the_soft_and_hard_slot() {
715        set_hard_rlimit(libc::RLIMIT_CORE as u32, 0).unwrap();
716
717        let mut current = libc::rlimit {
718            rlim_cur: 1,
719            rlim_max: 1,
720        };
721        // SAFETY: a plain `getrlimit` read into a live stack `rlimit`.
722        assert_eq!(
723            unsafe { libc::getrlimit(libc::RLIMIT_CORE as _, &mut current) },
724            0
725        );
726        assert_eq!(current.rlim_cur, 0);
727        assert_eq!(
728            current.rlim_max, 0,
729            "the hard slot must be set, not only the soft one"
730        );
731    }
732}