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