Skip to main content

capsule_runtime/
resources.rs

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