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