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