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}