capsule_runtime/cgroup.rs
1//! Linux cgroup v2 scoping for a capsule's whole native-subprocess tree.
2//!
3//! `resources::apply_hard_rlimits` bounds each spawned process individually. That is necessary
4//! and not sufficient: `RLIMIT_NPROC` is a **per-uid** ceiling, so a tree of distinct,
5//! short-lived processes that fork and exit faster than the count is observed slips past it,
6//! and no rlimit bounds *aggregate* memory or CPU across a tree at all. A cgroup v2 scope does
7//! exactly what rlimits structurally cannot: `pids.max`, `memory.max` and `cpu.max` apply to
8//! every task in the scope together, and the kernel enforces them at fork/allocate time rather
9//! than after the fact.
10//!
11//! ## Install requirement: systemd user cgroup delegation
12//!
13//! Creating a cgroup requires write access to a cgroup directory, and this runtime has none by
14//! default. The usual alternative — start as root, create the cgroup, then drop privileges —
15//! is not available here: nothing in this codebase ever runs as root or drops privileges (there
16//! is no setuid or privilege-drop path anywhere in `capsule-runtime` or `murmur-cli`), so there
17//! is no privileged phase to create a cgroup from. The mechanism that fits how this runtime
18//! actually runs is **systemd user delegation**: the user unit or scope `mur` runs under carries
19//! `Delegate=yes` for `memory pids cpu io`, which hands the unprivileged user write access to
20//! its own cgroup subtree. This is the same mechanism rootless Docker and Podman use for the
21//! identical problem. The exact unit configuration is in
22//! `docs/content/reference/resource-limits-manual-verification.md`.
23//!
24//! ## Self-relocation before probing an inherited cgroup
25//!
26//! cgroup v2 refuses to enable controllers for a cgroup's children while that cgroup still holds
27//! tasks, so a bounded child can only be built under a base this process is the *sole* occupant
28//! of. An interactive shell's cgroup holds the shell, its pane scope, and everything else the
29//! user is running; under `cargo test`, `cargo` stays resident beside every test binary. Stepping
30//! aside into [`SUPERVISOR_LEAF`] moves only this process, so co-residency alone makes the base
31//! unusable regardless of delegation.
32//!
33//! [`move_self_into_own_systemd_scope`] asks the systemd *user* manager over the session bus for
34//! a freshly created transient scope carrying `Delegate=yes`, moving this process into a
35//! brand-new cgroup — a sibling of whatever it left, not a child — that it is the sole occupant
36//! of by construction. The step-aside logic below then always succeeds, since nothing else is
37//! ever in that scope to contend with.
38//!
39//! The request is best-effort and silent: with no systemd, no session bus, or a policy denial,
40//! the runtime probes whatever cgroup it inherited, exactly as it does when this step is skipped.
41//!
42//! ## Fail closed, but only where the threat exists
43//!
44//! On Linux, a capsule that can spawn *any* native subprocess and cannot be given a cgroup
45//! refuses to launch (`RuntimeError::CgroupDelegationUnavailable`) rather than running that
46//! subprocess tree unbounded. A capsule that declares no subprocess capability at all needs no
47//! scope and is never blocked — there is no process tree to bound. On macOS, cgroups cannot
48//! exist at all, so this module is inert and the runtime falls back to rlimits-only enforcement
49//! with a `W-SEC-010` warning; refusing every launch on the primary development platform for a
50//! kernel feature that platform will never have would be a policy, not a protection.
51//!
52//! ## Cross-platform shape
53//!
54//! Following `sandbox.rs`: the types here are defined unconditionally and stay type-checked and
55//! unit-testable on macOS, with the *behavior* gated internally by `cfg(target_os = "linux")`
56//! and cross-platform-dead surface marked `#[cfg_attr(not(target_os = "linux"), allow(dead_code))]`.
57//! Nothing is hidden behind a module-level `cfg`.
58
59use std::path::{Path, PathBuf};
60use std::sync::Arc;
61#[cfg(target_os = "linux")]
62use std::time::Duration;
63
64use crate::resources::HostResourceLimits;
65
66/// Where the unified cgroup v2 hierarchy is mounted on a conventional host. Only used as the
67/// fallback when `/proc/mounts` does not name a `cgroup2` mount explicitly.
68#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
69const DEFAULT_CGROUP2_MOUNT: &str = "/sys/fs/cgroup";
70
71/// The `cpu.max` period, in microseconds. The kernel's own default; the quota this module
72/// writes is expressed against it (`cgroup_cpu_percent * 1000` µs of every 100 000 µs).
73#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
74const CPU_MAX_PERIOD_US: u64 = 100_000;
75
76/// Controllers that must be delegated and settable. `io` is deliberately absent: it is
77/// best-effort (see [`CgroupScope::apply_io_max`]).
78#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
79const REQUIRED_CONTROLLERS: &[&str] = &["memory", "pids", "cpu"];
80
81/// Name of the leaf cgroup the runtime moves *itself* into when its delegated base still holds
82/// processes. cgroup v2's "no internal processes" rule forbids enabling controllers in a
83/// cgroup's `subtree_control` while that cgroup directly contains tasks, so the manager has to
84/// step down into its own leaf first — the same move rootless Podman/Docker make.
85#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
86const SUPERVISOR_LEAF: &str = "murmur-supervisor";
87
88/// A cgroup v2 scope created for one capsule session, holding that session's whole native
89/// subprocess tree.
90///
91/// Constructed once per launch (never per subprocess) so that `pids.max` and `memory.max` bound
92/// the tree *in aggregate* — a per-call scope would let N sequential shell calls each spend the
93/// full budget, which is precisely the aggregate case rlimits already fail to cover.
94#[derive(Debug)]
95pub(crate) struct CgroupScope {
96 /// Absolute path of the scope directory, e.g.
97 /// `/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope/murmur-ses_abc123`.
98 path: PathBuf,
99 /// `cgroup.procs`, opened write-only in the **parent** before any fork. The forked child's
100 /// `pre_exec` closure only ever writes its own pid to this already-open descriptor — it
101 /// never performs a path lookup, matching how `sandbox.rs` pre-opens its Landlock fds rather
102 /// than opening paths inside `pre_exec`.
103 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
104 procs_fd: Option<std::os::fd::OwnedFd>,
105}
106
107/// Snapshot of the kernel-maintained kill/denial counters on a scope.
108///
109/// Read before and after a subprocess runs, so attribution rests on the *delta* for that call
110/// rather than on a session-cumulative total that any earlier call could have moved.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub(crate) struct CgroupEventCounters {
113 /// `memory.events`' `oom_kill` — tasks the kernel killed for exceeding `memory.max`.
114 pub(crate) oom_kill: u64,
115 /// `pids.events`' `max` — forks refused for exceeding `pids.max`.
116 pub(crate) pids_max: u64,
117}
118
119impl CgroupEventCounters {
120 /// The named limit this delta unambiguously attributes to, or `None`.
121 ///
122 /// Both counters are maintained by the kernel and incremented only by the condition they
123 /// name, so a nonzero delta is evidence rather than inference — the property that makes
124 /// these two safe to report where a bare `SIGKILL` is not (see
125 /// [`crate::resources::limit_from_signal`]).
126 pub(crate) fn attribution_since(&self, before: Self) -> Option<&'static str> {
127 if self.oom_kill > before.oom_kill {
128 return Some("cgroup_memory_bytes");
129 }
130 if self.pids_max > before.pids_max {
131 return Some("cgroup_pids_max");
132 }
133 None
134 }
135}
136
137/// Whether the declared `capabilities.resources.cgroup_io_bytes_per_sec` ceiling is actually on
138/// this session's cgroup scope.
139///
140/// Four states rather than a `bool` because "the write failed" and "there was never a scope to
141/// write to" are different findings with different remediations, and neither is the same as "this
142/// value was never established". [`Self::NotProbed`] is the `Default` so a report nobody filled in
143/// claims nothing.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
145#[serde(rename_all = "kebab-case")]
146pub enum IoMaxStatus {
147 /// The `io.max` write succeeded against the device backing the workdir. The ceiling is on the
148 /// scope.
149 Enforced,
150 /// A scope exists and the `io.max` write did not succeed. `memory.max`, `pids.max` and
151 /// `cpu.max` are still enforced on it; I/O bandwidth is not bounded. What `W-SEC-021` reports.
152 Unavailable,
153 /// No scope was asked for: either this capsule declares no way to reach a native subprocess,
154 /// or this is not Linux, where cgroups cannot exist. Not a shortfall — there is no process
155 /// tree for a ceiling to apply to.
156 NotRequired,
157 /// Nobody asked. The `Default`, so a [`IoMaxReport`] that was constructed rather than measured
158 /// asserts nothing about the host.
159 #[default]
160 NotProbed,
161}
162
163/// The declared I/O ceiling and what became of it, carried into `--explain-scope`, into
164/// `session_start.effective_grants` and into `W-SEC-021`.
165///
166/// `io.max` is non-fatal, so a ceiling that did not apply refuses nothing. This value is what
167/// carries that outcome alongside the claim, so the manifest's declared ceiling and what the
168/// kernel accepted cannot disagree in silence.
169#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
170pub struct IoMaxReport {
171 /// The effective `capabilities.resources.cgroup_io_bytes_per_sec`, after
172 /// [`HostResourceLimits::resolve`] has applied its default. Reported whatever the status, so a
173 /// reader can see what was asked for as well as what happened to it.
174 pub declared_bytes_per_sec: u64,
175 /// What happened to it.
176 pub status: IoMaxStatus,
177 /// Why, for every status but [`IoMaxStatus::Enforced`]. Absent from the JSON when there is
178 /// nothing to say.
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub reason: Option<String>,
181}
182
183impl IoMaxReport {
184 /// The ceiling applied.
185 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
186 fn enforced(declared_bytes_per_sec: u64) -> Self {
187 Self {
188 declared_bytes_per_sec,
189 status: IoMaxStatus::Enforced,
190 reason: None,
191 }
192 }
193
194 /// A scope exists and the ceiling is not on it.
195 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
196 fn unavailable(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
197 Self {
198 declared_bytes_per_sec,
199 status: IoMaxStatus::Unavailable,
200 reason: Some(reason.into()),
201 }
202 }
203
204 /// No cgroup scope is required because this capsule can reach no native subprocess.
205 ///
206 /// Public, and takes no reason argument: `mur run --explain-scope` has to produce the
207 /// byte-for-byte report a launch produces — `trace.rs`'s `session_start_effective_grants_match_explain_scope_json`
208 /// asserts the two objects are equal — so the wording lives here rather than once per caller.
209 #[must_use]
210 pub fn no_scope_required(declared_bytes_per_sec: u64) -> Self {
211 Self::not_required(declared_bytes_per_sec, NO_SCOPE_REASON)
212 }
213
214 /// No scope was asked for. Always carries its reason: "not required" is the one status whose
215 /// two causes — a capsule that can spawn nothing, and a platform with no cgroups — a reader
216 /// cannot tell apart from the status alone.
217 fn not_required(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
218 Self {
219 declared_bytes_per_sec,
220 status: IoMaxStatus::NotRequired,
221 reason: Some(reason.into()),
222 }
223 }
224}
225
226/// Reason text for the two [`IoMaxStatus::NotRequired`] causes, in one place so `prepare_scope`
227/// and [`probe_io_max`] cannot word the same finding differently.
228const NO_SCOPE_REASON: &str = "this capsule declares no way to run a native subprocess, so it is \
229 given no cgroup scope and there is no process tree for an I/O ceiling to apply to";
230#[cfg_attr(target_os = "linux", allow(dead_code))]
231const NOT_LINUX_REASON: &str =
232 "cgroups are a Linux mechanism and this host has none, so no io.max ceiling can exist here";
233
234/// The message `W-SEC-021` carries, minus the reason the host gave.
235const IO_MAX_UNAVAILABLE_WARNING: &str = "the declared \
236capabilities.resources.cgroup_io_bytes_per_sec ceiling did not apply to this session's cgroup \
237scope, so this capsule's native subprocess tree has no I/O bandwidth bound; memory.max, pids.max \
238and cpu.max are still enforced on the scope";
239
240/// Pure decision for the unenforced-`io.max` warning, split out of
241/// [`warn_for_unenforced_io_max`] the same way `sandbox::aggregate_bounding_warning` is split out
242/// of its emitter, so a test can assert it without capturing stderr.
243///
244/// Fires on [`IoMaxStatus::Unavailable`] alone. `NotRequired` is not a shortfall and `NotProbed`
245/// is not a finding, so neither warns.
246pub(crate) fn io_max_warning(report: &IoMaxReport) -> Option<(&'static str, String)> {
247 if report.status != IoMaxStatus::Unavailable {
248 return None;
249 }
250 let declared = report.declared_bytes_per_sec;
251 let reason = report.reason.as_deref().unwrap_or("no reason reported");
252 Some((
253 murmur_artifact::W_SEC_021,
254 format!("{IO_MAX_UNAVAILABLE_WARNING} (declared {declared} bytes/s): {reason}"),
255 ))
256}
257
258/// Fires at every launch, not just once.
259pub(crate) fn warn_for_unenforced_io_max(workdir: &Path, report: &IoMaxReport) {
260 if let Some((code, message)) = io_max_warning(report) {
261 let link = murmur_artifact::security_warning_link(code);
262 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
263 crate::agent::append_bootstrap_log(
264 workdir,
265 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
266 );
267 }
268}
269
270/// What [`prepare_scope`] establishes: the scope, and what became of its I/O ceiling.
271///
272/// A struct rather than a tuple so the two values cannot be swapped at a call site, and so a
273/// caller cannot take the scope without also being handed the ceiling's outcome.
274#[derive(Debug)]
275pub(crate) struct PreparedScope {
276 /// The scope, or `None` where none was required or none can exist.
277 pub(crate) scope: Option<Arc<CgroupScope>>,
278 /// Whether the declared `io.max` ceiling applied, established by performing the write rather
279 /// than by inferring it.
280 pub(crate) io_max: IoMaxReport,
281}
282
283/// Decide-and-create entry point, called once per launch before any WASM is instantiated.
284///
285/// * `required == false` (the capsule declares no way to spawn a native subprocess) → `Ok(None)`
286/// on every platform. There is no process tree to bound, so demanding host configuration here
287/// would be a regression for WASM-only capsules with nothing to show for it.
288/// * non-Linux → `Ok(None)` always. Never an error: cgroups are structurally impossible there,
289/// not a host misconfiguration.
290/// * Linux and required → probe delegation and create the scope; `Err(reason)` if the host
291/// cannot delegate one, which the caller turns into
292/// [`crate::errors::RuntimeError::CgroupDelegationUnavailable`].
293///
294/// The returned [`PreparedScope`] always carries an [`IoMaxReport`], on every one of those paths:
295/// the declared I/O ceiling is reported as applying, as not applying, or as not required, and
296/// never left silent.
297pub(crate) fn prepare_scope(
298 required: bool,
299 limits: &HostResourceLimits,
300 session_id: &str,
301 workdir: &Path,
302) -> Result<PreparedScope, String> {
303 if !required {
304 return Ok(PreparedScope {
305 scope: None,
306 io_max: IoMaxReport::no_scope_required(limits.cgroup_io_bytes_per_sec),
307 });
308 }
309 #[cfg(target_os = "linux")]
310 {
311 let (scope, io_max) = CgroupScope::create(limits, session_id, workdir)?;
312 Ok(PreparedScope {
313 scope: Some(Arc::new(scope)),
314 io_max,
315 })
316 }
317 #[cfg(not(target_os = "linux"))]
318 {
319 let _ = (session_id, workdir);
320 Ok(PreparedScope {
321 scope: None,
322 io_max: IoMaxReport::not_required(limits.cgroup_io_bytes_per_sec, NOT_LINUX_REASON),
323 })
324 }
325}
326
327/// Answers "would the declared `io.max` ceiling apply on this host?" by performing the write,
328/// against a throwaway cgroup created and removed inside this call.
329///
330/// The diagnostic half of [`prepare_scope`], for `mur run --explain-scope`, which runs before any
331/// workdir exists and before any session is staged. Inferring the answer from "is the `io`
332/// controller delegated" is wrong: the write can fail for a device the block layer will not
333/// accept (tmpfs, overlayfs, a device-mapper stack) on a host whose `io` controller is delegated
334/// perfectly well. Only performing the write answers it.
335///
336/// `workdir` need not exist. The device is resolved from its nearest existing ancestor, so the
337/// probe names the device a launch would use without creating anything.
338///
339/// Off Linux it reports [`IoMaxStatus::NotRequired`]. Whatever the outcome, the throwaway
340/// directory does not survive the call.
341#[must_use]
342pub fn probe_io_max(limits: &HostResourceLimits, workdir: &Path) -> IoMaxReport {
343 let declared = limits.cgroup_io_bytes_per_sec;
344 #[cfg(target_os = "linux")]
345 {
346 let base = match delegated_base() {
347 Ok(base) => base,
348 Err(reason) => {
349 return IoMaxReport::unavailable(
350 declared,
351 format!("no delegated cgroup base to write io.max into: {reason}"),
352 )
353 }
354 };
355 let path = base.join(scope_dir_name("explain-scope"));
356 if let Err(error) = std::fs::create_dir(&path) {
357 return IoMaxReport::unavailable(
358 declared,
359 format!(
360 "could not create a throwaway cgroup at {}: {error}",
361 path.display()
362 ),
363 );
364 }
365 let report = write_io_max(&path, declared, workdir);
366 // Best-effort by necessity and not by preference: the scope holds no tasks, so the only
367 // way this fails is a host that took the directory away underneath us.
368 let _ = std::fs::remove_dir(&path);
369 report
370 }
371 #[cfg(not(target_os = "linux"))]
372 {
373 let _ = workdir;
374 IoMaxReport::not_required(declared, NOT_LINUX_REASON)
375 }
376}
377
378/// Does this policy give the capsule any way to run a native subprocess at all?
379///
380/// The exact condition the Linux fail-closed launch refusal keys on: `capabilities.shell.allow`,
381/// `capabilities.spawn.allow`, or a declared artifact whose implementation is native (passed in
382/// by the caller, which is the only place the installed-artifact list is in hand).
383pub fn requires_process_bounding(
384 policy: &crate::types::CapabilityPolicy,
385 has_native_artifact: bool,
386) -> bool {
387 !policy.shell_allow.is_empty() || !policy.spawn_allow.is_empty() || has_native_artifact
388}
389
390impl CgroupScope {
391 /// Absolute path of this scope's directory.
392 // The scope's own path is read only by this module's tests, which assert the limit files
393 // systemd wrote under it; the launch path carries the descriptors it needs instead.
394 #[allow(dead_code)]
395 pub(crate) fn path(&self) -> &Path {
396 &self.path
397 }
398
399 /// Move the **calling** process into this scope.
400 ///
401 /// Called from inside the forked child's `pre_exec` closure: it writes the child's own pid
402 /// to the parent-opened `cgroup.procs` descriptor with a single `write(2)`, formatting the
403 /// pid into a stack buffer so the whole path stays allocation-free and async-signal-safe.
404 /// Every process the child subsequently forks inherits the scope, which is what makes the
405 /// bound apply to the *tree* rather than to one process.
406 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
407 #[allow(unsafe_code)]
408 pub(crate) fn join_current_process(&self) -> std::io::Result<()> {
409 use std::os::fd::AsRawFd;
410
411 let Some(fd) = self.procs_fd.as_ref() else {
412 return Ok(());
413 };
414
415 // SAFETY: `getpid` takes no arguments and cannot fail.
416 let pid = unsafe { libc::getpid() };
417 let mut buf = [0u8; 24];
418 let written = format_pid(pid, &mut buf);
419
420 let mut offset = 0;
421 while offset < written {
422 // SAFETY: `fd` is an open write-only descriptor owned by `self`, and the slice
423 // passed is a live sub-range of the stack buffer above.
424 let rc = unsafe {
425 libc::write(
426 fd.as_raw_fd(),
427 buf[offset..written].as_ptr().cast::<libc::c_void>(),
428 written - offset,
429 )
430 };
431 if rc < 0 {
432 let error = std::io::Error::last_os_error();
433 if error.kind() == std::io::ErrorKind::Interrupted {
434 continue;
435 }
436 return Err(error);
437 }
438 offset += rc as usize;
439 }
440 Ok(())
441 }
442
443 /// Current `memory.events`/`pids.events` counters.
444 ///
445 /// Must be read while the scope directory still exists — the files vanish with it, which is
446 /// why cleanup happens in `Drop` at session end and never right after a child is reaped.
447 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
448 pub(crate) fn event_counters(&self) -> CgroupEventCounters {
449 CgroupEventCounters {
450 oom_kill: read_event_counter(&self.path.join("memory.events"), "oom_kill"),
451 pids_max: read_event_counter(&self.path.join("pids.events"), "max"),
452 }
453 }
454}
455
456impl Drop for CgroupScope {
457 /// Best-effort `rmdir` of the scope directory.
458 ///
459 /// A cgroup directory can only be removed once empty, so a lingering grandchild the runtime
460 /// never reaped keeps it alive. That is logged and moved past rather than retried in a loop:
461 /// blocking session teardown on a process the runtime does not control would turn a leaked
462 /// directory into a hung exit.
463 fn drop(&mut self) {
464 // Drop the descriptor before the rmdir so nothing holds the directory open.
465 self.procs_fd = None;
466 if self.path.as_os_str().is_empty() {
467 return;
468 }
469 if let Err(error) = std::fs::remove_dir(&self.path) {
470 crate::runtime_err!(
471 "[capsule-runtime] note: could not remove cgroup scope {} ({error}); \
472 it will be reclaimed once its last task exits",
473 self.path.display()
474 );
475 }
476 }
477}
478
479/// Format `pid` into `buf` as decimal ASCII followed by a newline, returning the byte count.
480///
481/// Hand-rolled because [`CgroupScope::join_current_process`] runs between `fork` and `execve`,
482/// where `format!` (and the allocator lock behind it) is not async-signal-safe.
483#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
484fn format_pid(pid: i32, buf: &mut [u8; 24]) -> usize {
485 let mut digits = [0u8; 20];
486 let mut count = 0;
487 let mut value = pid.unsigned_abs() as u64;
488 if value == 0 {
489 digits[0] = b'0';
490 count = 1;
491 }
492 while value > 0 {
493 digits[count] = b'0' + (value % 10) as u8;
494 value /= 10;
495 count += 1;
496 }
497
498 let mut written = 0;
499 for index in (0..count).rev() {
500 buf[written] = digits[index];
501 written += 1;
502 }
503 buf[written] = b'\n';
504 written + 1
505}
506
507/// Read one `<key> <value>` counter out of a cgroup `*.events` file, or `0` if the file or key
508/// is absent. Absence is not an error: `memory.events` only exists once the memory controller is
509/// enabled, and treating a missing counter as zero means attribution simply declines to name a
510/// limit rather than inventing one.
511#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
512fn read_event_counter(path: &Path, key: &str) -> u64 {
513 let Ok(contents) = std::fs::read_to_string(path) else {
514 return 0;
515 };
516 parse_event_counter(&contents, key)
517}
518
519/// Pure parse half of [`read_event_counter`] — no syscalls, so it is unit-testable on macOS.
520#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
521fn parse_event_counter(contents: &str, key: &str) -> u64 {
522 for line in contents.lines() {
523 let mut parts = line.split_whitespace();
524 if parts.next() == Some(key) {
525 return parts
526 .next()
527 .and_then(|value| value.parse().ok())
528 .unwrap_or(0);
529 }
530 }
531 0
532}
533
534/// Turn a session id into a directory name safe to create under the delegated base: everything
535/// outside `[A-Za-z0-9._-]` becomes `_`.
536///
537/// The name is also made unique per scope, with the pid and a per-process serial. The id alone
538/// cannot carry that: `plan::execute` passes the plan's *authored* id, so two plans running
539/// concurrently in one process would otherwise land on the same directory — and because
540/// [`CgroupScope`]'s `Drop` removes the scope by path, the first to finish would delete a cgroup
541/// the other is still bounded by, leaving that subprocess tree unbounded. Sanitizing stays a
542/// belt-and-braces guard against a hostile id rather than a live escape vector.
543///
544/// Operators still find scopes by the documented `murmur-*` glob.
545#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
546fn scope_dir_name(session_id: &str) -> String {
547 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
548 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
549
550 let sanitized: String = session_id
551 .chars()
552 .map(|c| {
553 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
554 c
555 } else {
556 '_'
557 }
558 })
559 .collect();
560 let pid = std::process::id();
561 if sanitized.is_empty() {
562 format!("murmur-{pid}-{serial}")
563 } else {
564 format!("murmur-{sanitized}-{pid}-{serial}")
565 }
566}
567
568/// Extract the unified-hierarchy path from `/proc/self/cgroup` contents.
569///
570/// A cgroup v2 host writes exactly one line whose hierarchy id is `0` and whose controller list
571/// is empty: `0::/user.slice/...`. Returning `None` for anything else is what makes a v1-only
572/// (or hybrid, v2-less) host a clean "cannot delegate" rather than a wrong path.
573#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
574fn parse_unified_cgroup_path(contents: &str) -> Option<String> {
575 for line in contents.lines() {
576 let mut parts = line.splitn(3, ':');
577 let (Some("0"), Some(""), Some(path)) = (parts.next(), parts.next(), parts.next()) else {
578 continue;
579 };
580 if path.starts_with('/') {
581 return Some(path.to_string());
582 }
583 }
584 None
585}
586
587/// Find the `cgroup2` mount point in `/proc/mounts` contents, falling back to the conventional
588/// `/sys/fs/cgroup`.
589#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
590fn parse_cgroup2_mount(contents: &str) -> String {
591 for line in contents.lines() {
592 let mut parts = line.split_whitespace();
593 let (Some(_source), Some(target), Some(fstype)) =
594 (parts.next(), parts.next(), parts.next())
595 else {
596 continue;
597 };
598 if fstype == "cgroup2" {
599 return target.to_string();
600 }
601 }
602 DEFAULT_CGROUP2_MOUNT.to_string()
603}
604
605/// Which of `wanted` are missing from a `cgroup.subtree_control` file's contents.
606#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
607fn missing_controllers(subtree_control: &str, wanted: &[&str]) -> Vec<String> {
608 let enabled: Vec<&str> = subtree_control.split_whitespace().collect();
609 wanted
610 .iter()
611 .filter(|controller| !enabled.contains(*controller))
612 .map(|controller| (*controller).to_string())
613 .collect()
614}
615
616/// Encode a `st_dev` value as the `MAJ:MIN` string `io.max` expects, using glibc's device-number
617/// layout (12+20 bit split, not the legacy 8+8).
618#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
619fn device_major_minor(dev: u64) -> (u64, u64) {
620 let major = ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff);
621 let minor = (dev & 0xff) | ((dev >> 12) & !0xff);
622 (major, minor)
623}
624
625#[cfg(target_os = "linux")]
626impl CgroupScope {
627 /// Probe delegation, enable the controllers, create the scope directory, and write its
628 /// limits. Any failure to establish `memory.max`, `pids.max` or `cpu.max` is fatal — those
629 /// three are settable on any cgroup v2 host once the controllers are delegated, so a failure
630 /// there means the bound genuinely does not exist and the launch must not proceed.
631 fn create(
632 limits: &HostResourceLimits,
633 session_id: &str,
634 workdir: &Path,
635 ) -> Result<(Self, IoMaxReport), String> {
636 let base = delegated_base()?;
637
638 let path = base.join(scope_dir_name(session_id));
639 if let Err(error) = std::fs::create_dir(&path) {
640 if error.kind() != std::io::ErrorKind::AlreadyExists {
641 return Err(format!(
642 "could not create cgroup scope at {}: {error}",
643 path.display()
644 ));
645 }
646 }
647
648 let scope = Self {
649 path,
650 procs_fd: None,
651 };
652
653 scope.write_limit("memory.max", &limits.cgroup_memory_bytes.to_string())?;
654 scope.write_limit("pids.max", &limits.cgroup_pids_max.to_string())?;
655 scope.write_limit(
656 "cpu.max",
657 &format!(
658 "{} {CPU_MAX_PERIOD_US}",
659 u64::from(limits.cgroup_cpu_percent) * (CPU_MAX_PERIOD_US / 100)
660 ),
661 )?;
662 let io_max = scope.apply_io_max(limits.cgroup_io_bytes_per_sec, workdir);
663
664 // Opened last, so a scope that failed any of the fatal writes above is never handed out
665 // with a usable join descriptor.
666 let procs_fd = open_write_only(&scope.path.join("cgroup.procs")).map_err(|error| {
667 format!(
668 "could not open {}/cgroup.procs for subprocess placement: {error}",
669 scope.path.display()
670 )
671 })?;
672
673 Ok((
674 Self {
675 path: scope.take_path(),
676 procs_fd: Some(procs_fd),
677 },
678 io_max,
679 ))
680 }
681
682 /// Move the path out of a scope that must not run its `Drop` (which would `rmdir` the
683 /// directory the returned value is about to take ownership of).
684 fn take_path(mut self) -> PathBuf {
685 let path = std::mem::take(&mut self.path);
686 // `self.path` is now empty, which `Drop` treats as "nothing to remove".
687 path
688 }
689
690 fn write_limit(&self, file: &str, value: &str) -> Result<(), String> {
691 let path = self.path.join(file);
692 std::fs::write(&path, format!("{value}\n"))
693 .map_err(|error| format!("could not write {} to {}: {error}", value, path.display()))
694 }
695
696 /// Best-effort `io.max` on the workdir's backing block device, reported rather than printed.
697 ///
698 /// Non-fatal by design, and the only one of the four controllers treated that way: the
699 /// backing device of a path cannot always be resolved to a real `MAJ:MIN` the block layer
700 /// accepts (overlayfs, tmpfs, btrfs subvolumes and device-mapper stacks all break the
701 /// assumption), and I/O throughput is the least safety-critical of the four — a capsule that
702 /// saturates disk bandwidth is slow, where one that exhausts memory or pids is fatal.
703 ///
704 /// Non-fatal is not the same as unreported: the returned [`IoMaxReport`] travels into
705 /// `--explain-scope`, into `session_start.effective_grants.io_max` and into `W-SEC-021`, so
706 /// the manifest's declared ceiling and what the kernel accepted cannot disagree in silence.
707 fn apply_io_max(&self, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
708 write_io_max(&self.path, bytes_per_sec, workdir)
709 }
710}
711
712/// Writes `io.max` into `scope_path` for the device behind `workdir`, and says what happened.
713///
714/// The one place the `io.max` line is composed, shared by [`CgroupScope::apply_io_max`] at launch
715/// and by [`probe_io_max`] under `--explain-scope`, so the diagnostic exercises the identical
716/// write the launch performs rather than a paraphrase of it.
717///
718/// The device comes from [`io_max_device`], which resolves it from the nearest *existing*
719/// ancestor of `workdir`: `--explain-scope` runs before the workdir is created, and a launch's
720/// workdir sits on the same filesystem as the project directory it is created under.
721#[cfg(target_os = "linux")]
722fn write_io_max(scope_path: &Path, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
723 let (major, minor) = match io_max_device(workdir) {
724 Ok(device) => device,
725 Err(reason) => return IoMaxReport::unavailable(bytes_per_sec, reason),
726 };
727 let value = format!("{major}:{minor} rbps={bytes_per_sec} wbps={bytes_per_sec}");
728 match std::fs::write(scope_path.join("io.max"), format!("{value}\n")) {
729 Ok(()) => IoMaxReport::enforced(bytes_per_sec),
730 // The scope directory is deliberately not named: `--explain-scope` writes into a throwaway
731 // cgroup and a launch writes into the session's, so including it would make the two
732 // reports differ for no reason a reader could act on — and
733 // `session_start.effective_grants` has to be the byte-for-byte object `--explain-scope`
734 // prints. The `MAJ:MIN` and the errno are what identify the failure.
735 Err(error) => IoMaxReport::unavailable(
736 bytes_per_sec,
737 format!("writing `{value}` to the scope's io.max: {error}"),
738 ),
739 }
740}
741
742/// The `MAJ:MIN` an `io.max` line has to name for the filesystem behind `path`, or why that
743/// filesystem has no device a ceiling can name.
744///
745/// `st_dev` is not the answer on its own. Two things stand between a path and a device the block
746/// layer accepts:
747///
748/// * A filesystem the kernel gives an anonymous `dev_t` — btrfs, overlayfs, tmpfs, every FUSE
749/// mount — has major `0`, which names no device. `/proc/self/mountinfo` records what the
750/// covering mount was mounted from, and where that source is a block device node its `st_rdev`
751/// is the device the filesystem's I/O actually reaches.
752/// * `blkcg` binds a limit to a request queue and only a whole disk carries one, so the block
753/// layer answers `ENODEV` for a partition — which is what `st_dev` reports for the common case
754/// of a filesystem on `/dev/sda1` or `/dev/nvme0n1p7`. [`whole_disk_device`] takes the last hop.
755///
756/// A filesystem with no block device behind it at all carries no I/O ceiling, and the `Err` says
757/// which mount and what it is backed by. That is a measurement of the resolution, not a guess
758/// about the write: there is no device for a write to name.
759#[cfg(target_os = "linux")]
760fn io_max_device(path: &Path) -> Result<(u64, u64), String> {
761 use std::os::linux::fs::MetadataExt;
762
763 let Some(existing) = nearest_existing_ancestor(path) else {
764 return Err(format!(
765 "could not stat {} or any ancestor of it to find its backing device",
766 path.display()
767 ));
768 };
769 let metadata = existing.metadata().map_err(|error| {
770 format!(
771 "could not stat {} to find its backing device: {error}",
772 existing.display()
773 )
774 })?;
775 let (major, minor) = device_major_minor(metadata.st_dev());
776 let (major, minor) = if major == 0 {
777 mount_source_device(existing)?
778 } else {
779 (major, minor)
780 };
781 Ok(whole_disk_device(major, minor))
782}
783
784/// The block device the mount covering `path` was mounted from.
785///
786/// Reached only for a filesystem whose `st_dev` is anonymous. `path` must exist, so that
787/// canonicalizing it yields the path the kernel's own mount lookup would take — a mount point
788/// reached through a symlink matches nothing in `mountinfo`.
789#[cfg(target_os = "linux")]
790fn mount_source_device(path: &Path) -> Result<(u64, u64), String> {
791 use std::os::unix::fs::{FileTypeExt, MetadataExt};
792
793 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
794 let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(|error| {
795 format!(
796 "reading /proc/self/mountinfo to find the device behind {}: {error}",
797 resolved.display()
798 )
799 })?;
800 let Some(mount) = mount_covering(&mountinfo, &resolved) else {
801 return Err(format!(
802 "no mount in /proc/self/mountinfo covers {}, so its backing device is unknown",
803 resolved.display()
804 ));
805 };
806 match std::fs::metadata(&mount.source) {
807 Ok(metadata) if metadata.file_type().is_block_device() => {
808 Ok(device_major_minor(metadata.rdev()))
809 }
810 _ => Err(format!(
811 "the filesystem mounted at {} is backed by `{}`, which is not a block device, so no \
812 io.max ceiling can name one",
813 mount.mount_point.display(),
814 mount.source.display()
815 )),
816 }
817}
818
819/// `major:minor` of the whole disk carrying `major:minor`, which is itself where it is not a
820/// partition.
821///
822/// A partition has a `partition` attribute in sysfs and sits inside its disk's directory, so the
823/// parent's `dev` is the disk. Every failure to read sysfs answers with the device as given: the
824/// write that follows is what decides, and reporting a device the block layer then refuses is a
825/// better outcome than reporting a device nothing was tried against.
826#[cfg(target_os = "linux")]
827fn whole_disk_device(major: u64, minor: u64) -> (u64, u64) {
828 let device = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
829 if !device.join("partition").exists() {
830 return (major, minor);
831 }
832 std::fs::read_to_string(device.join("../dev"))
833 .ok()
834 .and_then(|contents| parse_dev_line(&contents))
835 .unwrap_or((major, minor))
836}
837
838/// The `MAJ:MIN` on a sysfs `dev` attribute.
839#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
840fn parse_dev_line(contents: &str) -> Option<(u64, u64)> {
841 let (major, minor) = contents.trim().split_once(':')?;
842 Some((major.parse().ok()?, minor.parse().ok()?))
843}
844
845/// One `/proc/self/mountinfo` line, reduced to the two fields that identify a filesystem's
846/// backing device.
847#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
848#[derive(Debug, PartialEq, Eq)]
849struct MountEntry {
850 /// Where the filesystem is mounted, as the kernel spells it.
851 mount_point: PathBuf,
852 /// What it was mounted from: a block device node for an ordinary disk filesystem, and a bare
853 /// name such as `tmpfs` for one with nothing behind it.
854 source: PathBuf,
855}
856
857/// The mount `path` lands in: the entry whose mount point is the longest prefix of it.
858///
859/// Later lines win a tie. `mountinfo` is in mount order, so a filesystem mounted over an earlier
860/// one at the same point is the one a lookup reaches.
861#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
862fn mount_covering(mountinfo: &str, path: &Path) -> Option<MountEntry> {
863 let mut best: Option<(usize, MountEntry)> = None;
864 for line in mountinfo.lines() {
865 let Some(entry) = parse_mount_entry(line) else {
866 continue;
867 };
868 if !path.starts_with(&entry.mount_point) {
869 continue;
870 }
871 let depth = entry.mount_point.components().count();
872 if best.as_ref().is_none_or(|(best, _)| depth >= *best) {
873 best = Some((depth, entry));
874 }
875 }
876 best.map(|(_, entry)| entry)
877}
878
879/// Mount point and source out of one `mountinfo` line.
880///
881/// The optional-fields run is terminated by a lone `-`, which no field can contain: `mountinfo`
882/// escapes space, tab, newline and backslash as octal, so splitting on `" - "` is unambiguous.
883#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
884fn parse_mount_entry(line: &str) -> Option<MountEntry> {
885 let (mounted, backing) = line.split_once(" - ")?;
886 let mount_point = mounted.split_whitespace().nth(4)?;
887 let source = backing.split_whitespace().nth(1)?;
888 Some(MountEntry {
889 mount_point: unescape_octal(mount_point),
890 source: unescape_octal(source),
891 })
892}
893
894/// Decode `mountinfo`'s `\NNN` octal escapes, byte-wise, so a mount point containing a space or a
895/// non-ASCII byte compares equal to the path the kernel reports for it.
896#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
897fn unescape_octal(field: &str) -> PathBuf {
898 use std::os::unix::ffi::OsStringExt;
899
900 let bytes = field.as_bytes();
901 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
902 let mut index = 0;
903 while index < bytes.len() {
904 let escape = bytes[index] == b'\\'
905 && index + 3 < bytes.len()
906 && bytes[index + 1..index + 4]
907 .iter()
908 .all(|byte| (b'0'..=b'7').contains(byte));
909 if escape {
910 let digits = &field[index + 1..index + 4];
911 out.push(u8::from_str_radix(digits, 8).expect("three octal digits"));
912 index += 4;
913 } else {
914 out.push(bytes[index]);
915 index += 1;
916 }
917 }
918 PathBuf::from(std::ffi::OsString::from_vec(out))
919}
920
921/// The closest ancestor of `path` that exists, which is `path` itself where it does.
922///
923/// The backing device is a property of the filesystem, not of the leaf, so an ancestor's answer
924/// is the answer for a path that has not been created yet — the state `--explain-scope` finds the
925/// would-be workdir in.
926#[cfg(target_os = "linux")]
927fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
928 let mut candidate = Some(path);
929 while let Some(current) = candidate {
930 if current.exists() {
931 return Some(current);
932 }
933 candidate = current.parent();
934 }
935 None
936}
937
938/// The delegated cgroup root this process creates its scopes under, resolved once.
939///
940/// [`probe_delegation`] derives the root from `/proc/self/cgroup`, but establishing the first
941/// scope may move this process into `<root>/murmur-supervisor` to vacate the root — cgroup v2
942/// forbids a cgroup from both holding processes and enabling controllers for its children. Once
943/// that move has happened the process's own cgroup is no longer the delegated root, so
944/// re-deriving it per call mistakes the leaf for the root and nests one level deeper every time,
945/// until the nested leaf has no delegated controllers at all and every further session is
946/// refused. Any process that opens a second session — concurrently or in sequence — hits this.
947///
948/// Resolving once, under a lock, keeps every scope a sibling directly under the real root, and
949/// makes concurrent first-callers agree on that root instead of racing to relocate each other.
950/// Only success is cached: a host that is not delegated must keep reporting so on every attempt.
951#[cfg(target_os = "linux")]
952fn delegated_base() -> Result<PathBuf, String> {
953 static BASE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
954
955 // A poisoned lock guards no invariant worth honouring here: the cache is either populated or
956 // it is not, and a caller that panicked cannot have left it half-written.
957 let mut cached = BASE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
958 if let Some(base) = cached.as_ref() {
959 return Ok(base.clone());
960 }
961
962 // Best-effort: relocate into our own scope before probing. On failure, the probe below runs
963 // against the inherited cgroup — the correct fallback on a host with no systemd to ask.
964 move_self_into_own_systemd_scope();
965
966 let base = probe_delegation()?;
967 enable_controllers(&base)?;
968 *cached = Some(base.clone());
969 Ok(base)
970}
971
972/// Ask the systemd user manager for a freshly created, delegated transient scope holding this
973/// process, so the cgroup this runtime builds under is one it is the only occupant of.
974///
975/// Best-effort and non-fatal: every failure here (no systemd, no session bus, policy denial, a
976/// migration that never lands) leaves the process exactly where it was — the state
977/// [`probe_delegation`] handles. Never let a failed optimisation become a launch refusal.
978///
979/// Runs at most once per process — a scope is a property of the process, not the session, and
980/// [`delegated_base`] retries on failure but caches only success, so the guard has to live here.
981#[cfg(target_os = "linux")]
982fn move_self_into_own_systemd_scope() {
983 static ATTEMPTED: std::sync::Once = std::sync::Once::new();
984 ATTEMPTED.call_once(|| {
985 let _ = request_transient_scope(&transient_scope_unit_name());
986 });
987}
988
989/// Name of the transient scope unit to ask systemd for.
990///
991/// `murmur-` keeps the operator-facing convention the scope *directories* already use
992/// ([`scope_dir_name`]), so `systemctl --user list-units 'murmur-*.scope'` finds the runtime's
993/// units the same way a `murmur-*` glob finds its cgroup directories. The pid and a per-process
994/// serial keep it unique against a recycled pid whose earlier unit systemd has not yet collected.
995#[cfg(target_os = "linux")]
996fn transient_scope_unit_name() -> String {
997 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
998 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
999 format!("murmur-{}-{serial}.scope", std::process::id())
1000}
1001
1002/// The D-Bus half of [`move_self_into_own_systemd_scope`], plus confirmation that the kernel
1003/// actually moved us.
1004#[cfg(target_os = "linux")]
1005fn request_transient_scope(unit: &str) -> Result<(), String> {
1006 // zbus's blocking API drives its own executor; run it off-thread so it never blocks a
1007 // caller already inside another async runtime (e.g. tokio) on a nested reactor.
1008 let owned_unit = unit.to_string();
1009 std::thread::Builder::new()
1010 .name("murmur-cgroup-scope".to_string())
1011 .spawn(move || start_transient_scope(&owned_unit))
1012 .map_err(|error| format!("could not spawn the D-Bus thread: {error}"))?
1013 .join()
1014 .map_err(|_| "the D-Bus thread panicked".to_string())??;
1015
1016 await_scope_membership(unit)
1017}
1018
1019/// `StartTransientUnit` on the systemd user manager: create `unit` as a scope containing this
1020/// process, with its cgroup subtree delegated to us.
1021///
1022/// The call is
1023/// `org.freedesktop.systemd1.Manager.StartTransientUnit(in s name, in s mode, in a(sv) properties,
1024/// in a(sa(sv)) aux)` on `/org/freedesktop/systemd1` at the well-known name
1025/// `org.freedesktop.systemd1`, over the **session** bus (`$DBUS_SESSION_BUS_ADDRESS`, falling back
1026/// to `$XDG_RUNTIME_DIR/bus`) so the unit lands in this user's manager and needs no privilege.
1027/// The properties are the same set `systemd-run --user --scope -p Delegate=yes` sends:
1028///
1029/// * `PIDs` (`au`) — this process, which is what makes systemd *move* it rather than fork a new one.
1030/// * `Delegate` (`b`) — hand the scope's subtree to us, controllers and all, and stop managing it.
1031/// * `CollectMode` (`s`) — `inactive-or-failed`, so the unit is garbage-collected when this
1032/// process exits rather than lingering as a failed unit an operator has to reset.
1033/// * `Description` (`s`) — what `systemctl --user list-units` shows beside the unit.
1034#[cfg(target_os = "linux")]
1035fn start_transient_scope(unit: &str) -> Result<(), String> {
1036 use zbus::zvariant::Value;
1037
1038 let connection = zbus::blocking::Connection::session()
1039 .map_err(|error| format!("no systemd session bus: {error}"))?;
1040
1041 let properties: Vec<(&str, Value<'_>)> = vec![
1042 (
1043 "Description",
1044 Value::from("Murmur capsule subprocess scope"),
1045 ),
1046 ("PIDs", Value::from(vec![std::process::id()])),
1047 ("Delegate", Value::from(true)),
1048 ("CollectMode", Value::from("inactive-or-failed")),
1049 ];
1050 // No auxiliary units: a scope has none.
1051 let aux: Vec<(&str, Vec<(&str, Value<'_>)>)> = Vec::new();
1052
1053 connection
1054 .call_method(
1055 Some("org.freedesktop.systemd1"),
1056 "/org/freedesktop/systemd1",
1057 Some("org.freedesktop.systemd1.Manager"),
1058 "StartTransientUnit",
1059 // "fail" rather than "replace": a name collision means something is wrong with our
1060 // assumption of uniqueness, and displacing an unrelated unit is never the fix.
1061 &(unit, "fail", properties, aux),
1062 )
1063 .map(|_| ())
1064 .map_err(|error| format!("systemd refused to start {unit}: {error}"))
1065}
1066
1067/// Wait until `/proc/self/cgroup` actually reports us inside `unit`'s cgroup.
1068///
1069/// `StartTransientUnit` returns once the job is *enqueued*, not once the move has happened, and
1070/// every later step (the delegation probe, the controller enable, the scope directory) depends on
1071/// where this process actually lives — so migration is confirmed from `/proc/self/cgroup`, the
1072/// same source the rest of this module reads, rather than inferred from a job object path.
1073///
1074/// The 2s deadline in 20ms steps mirrors [`enable_controllers`]: both wait on systemd/kernel
1075/// bookkeeping that normally completes in a millisecond or two, and a wait that long means
1076/// something structural is wrong. A timeout here is cheap — the caller ignores it and falls back
1077/// to probing the inherited cgroup.
1078#[cfg(target_os = "linux")]
1079fn await_scope_membership(unit: &str) -> Result<(), String> {
1080 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1081 loop {
1082 let current = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
1083 if let Some(path) = parse_unified_cgroup_path(¤t) {
1084 if path.rsplit('/').next() == Some(unit) {
1085 return Ok(());
1086 }
1087 }
1088 if std::time::Instant::now() >= deadline {
1089 return Err(format!(
1090 "systemd accepted the request for {unit} but this process is not in it"
1091 ));
1092 }
1093 std::thread::sleep(Duration::from_millis(20));
1094 }
1095}
1096
1097/// Locate the delegated cgroup v2 directory this process may create children under, confirming
1098/// the controllers this runtime needs are actually available there.
1099///
1100/// Returns the base directory on success, or a message naming what is missing — that message is
1101/// what reaches the operator inside
1102/// [`crate::errors::RuntimeError::CgroupDelegationUnavailable`], so it names the delegation
1103/// requirement rather than only the failed syscall.
1104#[cfg(target_os = "linux")]
1105fn probe_delegation() -> Result<PathBuf, String> {
1106 let self_cgroup = std::fs::read_to_string("/proc/self/cgroup")
1107 .map_err(|error| format!("could not read /proc/self/cgroup: {error}"))?;
1108 let relative = parse_unified_cgroup_path(&self_cgroup).ok_or_else(|| {
1109 "this host has no cgroup v2 unified hierarchy (no `0::` line in /proc/self/cgroup); \
1110 cgroup v1-only and hybrid hosts cannot be delegated a v2 scope"
1111 .to_string()
1112 })?;
1113
1114 let mount = std::fs::read_to_string("/proc/mounts")
1115 .map(|contents| parse_cgroup2_mount(&contents))
1116 .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
1117
1118 let base = ascend_out_of_supervisor_leaves(
1119 PathBuf::from(mount).join(relative.trim_start_matches('/')),
1120 );
1121 if !base.is_dir() {
1122 return Err(format!(
1123 "delegated cgroup directory {} does not exist",
1124 base.display()
1125 ));
1126 }
1127
1128 let controllers =
1129 std::fs::read_to_string(base.join("cgroup.controllers")).map_err(|error| {
1130 format!(
1131 "could not read {}/cgroup.controllers: {error}",
1132 base.display()
1133 )
1134 })?;
1135 let unavailable = missing_controllers(&controllers, REQUIRED_CONTROLLERS);
1136 if !unavailable.is_empty() {
1137 return Err(format!(
1138 "cgroup controllers [{}] are not delegated to {}; the unit `mur` runs under needs \
1139 `Delegate=yes` (or `Delegate=memory pids cpu io`)",
1140 unavailable.join(", "),
1141 base.display()
1142 ));
1143 }
1144
1145 // The probe must confirm we can actually *create* a child here, not just read the directory:
1146 // a readable cgroup with no write permission is the exact shape of an undelegated host.
1147 // Named per-pid so two `mur` processes probing concurrently never race on the same path.
1148 let probe_dir = base.join(format!("murmur-delegation-probe-{}", std::process::id()));
1149 let _ = std::fs::remove_dir(&probe_dir);
1150 std::fs::create_dir(&probe_dir).map_err(|error| {
1151 format!(
1152 "cannot create a child cgroup under {} ({error}); the unit `mur` runs under needs \
1153 `Delegate=yes` for memory, pids, cpu and io",
1154 base.display()
1155 )
1156 })?;
1157 let _ = std::fs::remove_dir(&probe_dir);
1158
1159 Ok(base)
1160}
1161
1162/// Enable `memory`, `pids`, `cpu` (and `io`, best-effort) in the delegated base's
1163/// `cgroup.subtree_control` so a child cgroup can carry their limit files.
1164///
1165/// If the base still holds this process directly, the write is refused (`EBUSY`) by cgroup v2's
1166/// "no internal processes" rule; the recovery is to move this process into its own leaf and try
1167/// once more. That relocation stays entirely inside the delegated subtree.
1168#[cfg(target_os = "linux")]
1169fn enable_controllers(base: &Path) -> Result<(), String> {
1170 let subtree_control = base.join("cgroup.subtree_control");
1171 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
1172
1173 // `io` is requested opportunistically: a host that delegates only the three required
1174 // controllers still gets a fully-enforced scope, just without `io.max`.
1175 let available = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
1176 let mut wanted: Vec<&str> = REQUIRED_CONTROLLERS.to_vec();
1177 if available.split_whitespace().any(|c| c == "io") {
1178 wanted.push("io");
1179 }
1180
1181 let missing = missing_controllers(¤t, &wanted);
1182 if missing.is_empty() {
1183 return Ok(());
1184 }
1185 let directive = missing
1186 .iter()
1187 .map(|controller| format!("+{controller}"))
1188 .collect::<Vec<_>>()
1189 .join(" ");
1190
1191 if std::fs::write(&subtree_control, format!("{directive}\n")).is_ok() {
1192 return Ok(());
1193 }
1194
1195 move_self_to_supervisor_leaf(base)?;
1196
1197 // Stepping aside vacates *this* process, and cgroup v2 refuses the write while the base holds
1198 // any task at all. When several murmur processes share a base — a test binary and the `mur`
1199 // children it spawns, or two capsules launched together — each is independently discovering
1200 // it must step aside, so the first write after our own move can still hit `EBUSY` on peers
1201 // that have not moved yet. Retrying briefly lets the group finish draining; a peer that wins
1202 // the race and enables the controllers first is just as good, which is why the loop re-reads
1203 // `subtree_control` rather than only retrying the write.
1204 //
1205 // Bounded, because a task that never leaves is a genuine misconfiguration and has to surface
1206 // as one rather than hang the launch.
1207 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1208 // Left uninitialised: every path that reaches the deadline check has just gone through the
1209 // write's `Err` arm, so the fallback below only ever describes a real failure.
1210 let mut last_error;
1211 loop {
1212 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
1213 if missing_controllers(¤t, &wanted).is_empty() {
1214 return Ok(());
1215 }
1216 match std::fs::write(&subtree_control, format!("{directive}\n")) {
1217 Ok(()) => return Ok(()),
1218 Err(error) => last_error = Some(error),
1219 }
1220 if std::time::Instant::now() >= deadline {
1221 break;
1222 }
1223 std::thread::sleep(Duration::from_millis(20));
1224 }
1225
1226 Err(format!(
1227 "could not enable controllers [{}] in {} ({}); the unit `mur` runs under needs \
1228 `Delegate=yes` for memory, pids, cpu and io",
1229 missing.join(", "),
1230 subtree_control.display(),
1231 last_error.map_or_else(|| "still occupied".to_string(), |error| error.to_string()),
1232 ))
1233}
1234
1235/// Walk up out of any `murmur-supervisor` leaves the path ends in.
1236///
1237/// The leaf is where a murmur process steps aside so controllers can be enabled on the delegated
1238/// base. Every process it then spawns *inherits that leaf as its own cgroup* — so a `mur`
1239/// subprocess reading `/proc/self/cgroup` would take the leaf for its delegated base and step
1240/// aside again inside it, one level deeper per generation, until the innermost leaf has no
1241/// controllers delegated to it at all and every launch below that point is refused.
1242///
1243/// Ascending here makes every generation agree on the same real base, which is also what lets
1244/// them share one scope parent rather than fragmenting the delegation.
1245#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1246fn ascend_out_of_supervisor_leaves(mut base: PathBuf) -> PathBuf {
1247 while base.file_name().and_then(|name| name.to_str()) == Some(SUPERVISOR_LEAF) {
1248 base.pop();
1249 }
1250 base
1251}
1252
1253/// Move this process into `<base>/murmur-supervisor`, creating it if needed.
1254#[cfg(target_os = "linux")]
1255fn move_self_to_supervisor_leaf(base: &Path) -> Result<(), String> {
1256 let leaf = base.join(SUPERVISOR_LEAF);
1257 if let Err(error) = std::fs::create_dir(&leaf) {
1258 if error.kind() != std::io::ErrorKind::AlreadyExists {
1259 return Err(format!(
1260 "could not create {} to vacate the delegated cgroup: {error}",
1261 leaf.display()
1262 ));
1263 }
1264 }
1265 std::fs::write(
1266 leaf.join("cgroup.procs"),
1267 format!("{}\n", std::process::id()),
1268 )
1269 .map_err(|error| {
1270 format!(
1271 "could not move this process into {} to vacate the delegated cgroup: {error}",
1272 leaf.display()
1273 )
1274 })
1275}
1276
1277/// `open(path, O_WRONLY | O_CLOEXEC)`.
1278///
1279/// `CLOEXEC` matters twice over: the descriptor is written from `pre_exec` (still open at that
1280/// point) and must not survive into the exec'd binary, where it would hand a capsule the ability
1281/// to move arbitrary pids into its own cgroup.
1282#[cfg(target_os = "linux")]
1283#[allow(unsafe_code)]
1284fn open_write_only(path: &Path) -> std::io::Result<std::os::fd::OwnedFd> {
1285 use std::os::unix::fs::OpenOptionsExt;
1286
1287 std::fs::OpenOptions::new()
1288 .write(true)
1289 .custom_flags(libc::O_CLOEXEC)
1290 .open(path)
1291 .map(std::os::fd::OwnedFd::from)
1292}
1293
1294/// Whether this host can hand the runtime a delegated cgroup v2 scope — the precondition
1295/// [`prepare_scope`] fails closed on.
1296///
1297/// Test support, not a runtime code path: `mur` never asks this question, it asks systemd for a
1298/// scope and reports [`crate::errors::RuntimeError::CgroupDelegationUnavailable`] when it cannot
1299/// get one. Tests that launch a capsule tripping [`requires_process_bounding`] call this to skip
1300/// visibly instead, because a containerised runner has no delegable subtree to give and the
1301/// refusal there says nothing about the code under test. What CI therefore cannot cover is
1302/// covered by hand: `docs/content/reference/resource-limits-manual-verification.md`.
1303///
1304/// Probed by actually creating a transient `--user` scope with `Delegate=yes`, since a
1305/// `systemd-run` binary on `PATH` says nothing about a reachable user manager — and the user
1306/// manager is the half a runner is missing. Off Linux there are no cgroups, this module is inert
1307/// and nothing is ever refused, so the answer is `true`.
1308pub fn cgroup_delegation_available() -> bool {
1309 #[cfg(not(target_os = "linux"))]
1310 {
1311 true
1312 }
1313
1314 #[cfg(target_os = "linux")]
1315 {
1316 // Answered through `delegated_base`, the same call `prepare_scope` makes, so the answer
1317 // cannot disagree with what a launch does on this host. A separate `systemd-run` probe
1318 // answers a different question and is wrong in both directions: a user manager hands out
1319 // a scope on a host whose controllers `enable_controllers` cannot delegate, and the
1320 // inherited cgroup carries delegated controllers on a host with no reachable session bus.
1321 delegated_base().is_ok()
1322 }
1323}
1324
1325/// Whether the filesystem behind `path` has a block device an `io.max` ceiling can name.
1326///
1327/// Test support beside [`cgroup_delegation_available`], for the half of the `io.max` scenario
1328/// that needs a ceiling to actually apply: a checkout on tmpfs, on overlayfs or on a FUSE mount
1329/// has no device for the block layer to bind a limit to, and `enforced` is unreachable there
1330/// however well the host delegates cgroups. Answered through [`io_max_device`], the same
1331/// resolution a launch performs, so a test cannot stand down on a host the launch would have
1332/// satisfied. Off Linux there is no `io.max` at all.
1333#[must_use]
1334pub fn io_max_device_available(path: &Path) -> bool {
1335 #[cfg(not(target_os = "linux"))]
1336 {
1337 let _ = path;
1338 false
1339 }
1340
1341 #[cfg(target_os = "linux")]
1342 {
1343 io_max_device(path).is_ok()
1344 }
1345}
1346
1347/// Whether a test that launches a subprocess-capable capsule must stand down on this host,
1348/// printing the blocker when it must.
1349///
1350/// Both blockers are asked through the same calls a launch makes -- `delegated_base` for the
1351/// cgroup scope, `detect_egress_namespace_blocker` for the network namespace -- so the gate and
1352/// the launch cannot reach different conclusions about the host. A containerised CI runner
1353/// typically clears the first and fails the second.
1354pub fn skip_without_host_support(test_name: &str) -> bool {
1355 if crate::network_namespace::skip_without_egress_namespace(test_name) {
1356 return true;
1357 }
1358 if !cgroup_delegation_available() {
1359 crate::runtime_err!(
1360 "[SKIP-HOST] {test_name}: this host cannot delegate a cgroup v2 scope, so a capsule \
1361 that can spawn native subprocesses refuses to launch with E-RUN-012 before anything \
1362 this test observes happens -- see \
1363 docs/content/reference/resource-limits-manual-verification.md"
1364 );
1365 return true;
1366 }
1367 false
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::*;
1373 use crate::types::CapabilityPolicy;
1374
1375 /// The wire spelling every consumer reads: `--explain-scope --json`, `mur doctor` and
1376 /// `session_start.effective_grants.io_max` in `trace.jsonl`. Kebab-case, not the `Debug`
1377 /// rendering, which is free to change.
1378 #[test]
1379 fn io_max_status_wire_names_are_kebab_case() {
1380 let spellings: Vec<String> = [
1381 IoMaxStatus::Enforced,
1382 IoMaxStatus::Unavailable,
1383 IoMaxStatus::NotRequired,
1384 IoMaxStatus::NotProbed,
1385 ]
1386 .into_iter()
1387 .map(|status| {
1388 serde_json::to_value(status)
1389 .unwrap()
1390 .as_str()
1391 .unwrap()
1392 .to_string()
1393 })
1394 .collect();
1395 assert_eq!(
1396 spellings,
1397 vec!["enforced", "unavailable", "not-required", "not-probed"]
1398 );
1399 }
1400
1401 /// A report nobody filled in must claim nothing, which is what makes it safe to construct one
1402 /// at stage time and overwrite it once the scope exists.
1403 #[test]
1404 fn an_unfilled_report_claims_nothing() {
1405 let report = IoMaxReport::default();
1406 assert_eq!(report.status, IoMaxStatus::NotProbed);
1407 assert_eq!(report.declared_bytes_per_sec, 0);
1408 assert_eq!(report.reason, None);
1409 assert_eq!(io_max_warning(&report), None);
1410 }
1411
1412 /// `reason` is the only optional key, and it is present for every status but `Enforced`.
1413 #[test]
1414 fn only_a_failed_write_omits_nothing_and_a_successful_one_explains_nothing() {
1415 let enforced = serde_json::to_value(IoMaxReport::enforced(1_000)).unwrap();
1416 assert_eq!(enforced["status"], "enforced");
1417 assert_eq!(enforced["declared_bytes_per_sec"], 1_000);
1418 assert!(
1419 enforced.get("reason").is_none(),
1420 "an applied ceiling has nothing to explain: {enforced}"
1421 );
1422
1423 let unavailable =
1424 serde_json::to_value(IoMaxReport::unavailable(1_000, "no io controller")).unwrap();
1425 assert_eq!(unavailable["status"], "unavailable");
1426 assert_eq!(unavailable["reason"], "no io controller");
1427 }
1428
1429 /// `W-SEC-021` fires on the one status that is a shortfall, and its message names the
1430 /// declared ceiling, the host's reason, and the three limits that *are* still enforced —
1431 /// without which "io.max did not apply" reads as "this session is unbounded".
1432 #[test]
1433 fn the_warning_fires_only_where_a_declared_ceiling_did_not_apply() {
1434 assert_eq!(io_max_warning(&IoMaxReport::enforced(4_096)), None);
1435 assert_eq!(
1436 io_max_warning(&IoMaxReport::not_required(4_096, NO_SCOPE_REASON)),
1437 None
1438 );
1439
1440 let (code, message) = io_max_warning(&IoMaxReport::unavailable(
1441 4_096,
1442 "device 0:24 is not a block device",
1443 ))
1444 .expect("an unapplied ceiling must be reported");
1445 assert_eq!(code, murmur_artifact::W_SEC_021);
1446 assert!(message.contains("4096 bytes/s"), "message was: {message}");
1447 assert!(
1448 message.contains("device 0:24 is not a block device"),
1449 "message was: {message}"
1450 );
1451 assert!(message.contains("memory.max"), "message was: {message}");
1452 }
1453
1454 /// `--explain-scope` runs before the workdir exists, so the probe has to name the device a
1455 /// launch would use without creating anything.
1456 #[cfg(target_os = "linux")]
1457 #[test]
1458 fn the_device_is_resolved_from_the_nearest_existing_ancestor() {
1459 let temp = tempfile::tempdir().unwrap();
1460 let missing = temp
1461 .path()
1462 .join("workdir")
1463 .join("does")
1464 .join("not")
1465 .join("exist");
1466 assert_eq!(
1467 nearest_existing_ancestor(&missing),
1468 Some(temp.path()),
1469 "an ancestor of a tempdir always exists"
1470 );
1471 assert_eq!(
1472 io_max_device(&missing).ok(),
1473 io_max_device(temp.path()).ok(),
1474 "a path that does not exist yet must resolve to its filesystem's device"
1475 );
1476 assert!(!missing.exists(), "resolving a device must create nothing");
1477 }
1478
1479 /// The mount a path lands in is the deepest one covering it, not the first line that matches
1480 /// — `/` covers every path, and a nested mount is what a lookup actually reaches.
1481 #[test]
1482 fn the_covering_mount_is_the_deepest_one() {
1483 let mountinfo = "\
148425 30 0:29 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p5 rw,subvol=/
148526 25 0:31 / /dev/shm rw,nosuid shared:2 - tmpfs tmpfs rw,inode64
148627 25 0:41 / /space rw,relatime shared:3 - btrfs /dev/nvme0n1p7 rw,subvol=/
1487";
1488 let covering = |path: &str| mount_covering(mountinfo, Path::new(path)).unwrap();
1489
1490 assert_eq!(
1491 covering("/space/project/workdir").source,
1492 PathBuf::from("/dev/nvme0n1p7")
1493 );
1494 assert_eq!(covering("/dev/shm/project").source, PathBuf::from("tmpfs"));
1495 assert_eq!(
1496 covering("/home/someone").source,
1497 PathBuf::from("/dev/nvme0n1p5"),
1498 "a path under no nested mount belongs to the root filesystem"
1499 );
1500 assert_eq!(
1501 covering("/spaceship").source,
1502 PathBuf::from("/dev/nvme0n1p5"),
1503 "mount points match whole path components, not string prefixes"
1504 );
1505 }
1506
1507 /// A filesystem mounted over another at the same point is the one a lookup reaches, so the
1508 /// later line wins.
1509 #[test]
1510 fn a_mount_over_another_at_the_same_point_wins() {
1511 let mountinfo = "\
151225 30 0:29 / / rw shared:1 - ext4 /dev/sda1 rw
151326 25 0:31 / /mnt rw shared:2 - ext4 /dev/sdb1 rw
151427 25 0:32 / /mnt rw shared:3 - ext4 /dev/sdc1 rw
1515";
1516 assert_eq!(
1517 mount_covering(mountinfo, Path::new("/mnt/data"))
1518 .unwrap()
1519 .source,
1520 PathBuf::from("/dev/sdc1")
1521 );
1522 }
1523
1524 /// `mountinfo` escapes the four characters that would break its own field separation, and a
1525 /// mount point that does not decode compares equal to nothing.
1526 #[test]
1527 fn mount_point_escapes_are_decoded() {
1528 let mountinfo = "\
152925 30 0:29 / /media/a\\040disk rw shared:1 - ext4 /dev/sdb1 rw
1530";
1531 let entry = mount_covering(mountinfo, Path::new("/media/a disk/project")).unwrap();
1532 assert_eq!(entry.mount_point, PathBuf::from("/media/a disk"));
1533 assert_eq!(entry.source, PathBuf::from("/dev/sdb1"));
1534 assert_eq!(unescape_octal("no\\134escape"), PathBuf::from("no\\escape"));
1535 assert_eq!(
1536 unescape_octal("\\04"),
1537 PathBuf::from("\\04"),
1538 "a truncated escape is not an escape"
1539 );
1540 }
1541
1542 /// A sysfs `dev` attribute is the only place the parent disk's number comes from.
1543 #[test]
1544 fn sysfs_dev_attributes_are_read_as_major_minor() {
1545 assert_eq!(parse_dev_line("259:0\n"), Some((259, 0)));
1546 assert_eq!(parse_dev_line(""), None);
1547 assert_eq!(parse_dev_line("259"), None);
1548 }
1549
1550 /// The block layer binds an `io.max` entry to a request queue, and only a whole disk carries
1551 /// one — so whatever device this checkout sits on, the resolved one must not be a partition.
1552 /// Naming a partition is the failure that makes `enforced` unreachable on an ordinary host.
1553 #[cfg(target_os = "linux")]
1554 #[test]
1555 fn the_resolved_device_is_never_a_partition() {
1556 let Ok((major, minor)) = io_max_device(Path::new(".")) else {
1557 // A checkout on a filesystem with no block device behind it has no device to check,
1558 // and `write_io_max` reports that rather than naming one.
1559 return;
1560 };
1561 let sysfs = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
1562 assert!(
1563 !sysfs.join("partition").exists(),
1564 "io.max was given the partition {major}:{minor}, which the block layer refuses"
1565 );
1566 }
1567
1568 /// The diagnostic answers the question by exercising it, and leaves nothing behind whichever
1569 /// way it answers.
1570 #[cfg(target_os = "linux")]
1571 #[test]
1572 fn probing_io_max_creates_nothing_that_survives_the_call() {
1573 if skip_without_host_support("probing_io_max_creates_nothing_that_survives_the_call") {
1574 return;
1575 }
1576 let temp = tempfile::tempdir().unwrap();
1577 let report = probe_io_max(&HostResourceLimits::default(), &temp.path().join("workdir"));
1578 assert!(
1579 matches!(
1580 report.status,
1581 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
1582 ),
1583 "a performed probe must report what it performed, got {report:?}"
1584 );
1585
1586 // Counted by name rather than by directory count: this binary's other tests create and
1587 // remove scopes under the same base concurrently, and only `probe_io_max` names one
1588 // `explain-scope`.
1589 let leftovers: Vec<String> = std::fs::read_dir(delegated_base().expect("a delegated base"))
1590 .unwrap()
1591 .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
1592 .filter(|name| name.contains("explain-scope"))
1593 .collect();
1594 assert!(
1595 leftovers.is_empty(),
1596 "the throwaway cgroup outlived the probe: {leftovers:?}"
1597 );
1598 }
1599
1600 #[test]
1601 fn scope_is_required_by_any_route_to_a_native_subprocess() {
1602 let shell = CapabilityPolicy {
1603 shell_allow: vec!["bash".to_string()],
1604 ..CapabilityPolicy::default()
1605 };
1606 let spawn = CapabilityPolicy {
1607 spawn_allow: vec!["child".to_string()],
1608 ..CapabilityPolicy::default()
1609 };
1610 let bare = CapabilityPolicy::default();
1611
1612 assert!(requires_process_bounding(&shell, false));
1613 assert!(requires_process_bounding(&spawn, false));
1614 assert!(
1615 requires_process_bounding(&bare, true),
1616 "a native artifact alone is enough"
1617 );
1618 assert!(
1619 !requires_process_bounding(&bare, false),
1620 "a WASM-only capsule has no process tree to bound and must not be blocked"
1621 );
1622 }
1623
1624 /// A capsule with no subprocess capability never needs delegation — on any platform, and
1625 /// notably including Linux, where a blanket requirement would be a disproportionate
1626 /// regression for WASM-only capsules.
1627 #[test]
1628 fn prepare_scope_returns_none_when_bounding_is_not_required() {
1629 let temp = tempfile::tempdir().unwrap();
1630 let scope = prepare_scope(
1631 false,
1632 &HostResourceLimits::default(),
1633 "ses_test",
1634 temp.path(),
1635 )
1636 .expect("a capsule with no subprocess capability must never be refused");
1637 assert!(scope.scope.is_none());
1638 // Not a shortfall and never a warning: there is no process tree for a ceiling to apply to.
1639 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
1640 assert!(
1641 scope.io_max.reason.is_some(),
1642 "not-required must say which cause"
1643 );
1644 assert_eq!(io_max_warning(&scope.io_max), None);
1645 }
1646
1647 /// macOS can never have a cgroup, so a required scope there must be a silent `None` rather
1648 /// than a launch-blocking error.
1649 #[cfg(not(target_os = "linux"))]
1650 #[test]
1651 fn prepare_scope_is_never_an_error_off_linux() {
1652 let temp = tempfile::tempdir().unwrap();
1653 let scope = prepare_scope(
1654 true,
1655 &HostResourceLimits::default(),
1656 "ses_test",
1657 temp.path(),
1658 )
1659 .expect("a non-Linux host must not refuse to launch for a missing cgroup");
1660 assert!(scope.scope.is_none());
1661 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
1662 assert!(scope
1663 .io_max
1664 .reason
1665 .as_deref()
1666 .expect("not-required off Linux must say cgroups cannot exist here")
1667 .contains("Linux"));
1668 }
1669
1670 /// The point of asking systemd for a scope: a Linux host with a working user session hands
1671 /// out a bounded scope no matter what this process's *inherited* cgroup looked like — an
1672 /// interactive shell's cgroup full of co-resident processes, or the one `cargo test` keeps
1673 /// itself resident in while running this very binary.
1674 #[cfg(target_os = "linux")]
1675 #[test]
1676 fn a_required_scope_is_created_whatever_cgroup_this_process_inherited() {
1677 if skip_without_host_support(
1678 "a_required_scope_is_created_whatever_cgroup_this_process_inherited",
1679 ) {
1680 return;
1681 }
1682 let temp = tempfile::tempdir().unwrap();
1683 let prepared = prepare_scope(
1684 true,
1685 &HostResourceLimits::default(),
1686 "ses_selftest",
1687 temp.path(),
1688 )
1689 .expect("a Linux host with a systemd user session must be able to bound a subprocess tree");
1690 let io_max = prepared.io_max.clone();
1691 let scope = prepared
1692 .scope
1693 .expect("a required scope on Linux is never `None`");
1694
1695 for file in ["memory.max", "pids.max", "cpu.max"] {
1696 let contents = std::fs::read_to_string(scope.path().join(file))
1697 .unwrap_or_else(|error| panic!("{file} unreadable in the scope: {error}"));
1698 assert!(!contents.trim().is_empty(), "{file} was left unset");
1699 assert_ne!(contents.trim(), "max", "{file} carries no ceiling");
1700 }
1701
1702 // Whatever the host did with `io.max`, the scope says which of the two it was, and never
1703 // `NotProbed`: a scope that exists has had the write attempted against it.
1704 assert!(
1705 matches!(
1706 io_max.status,
1707 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
1708 ),
1709 "a created scope must report a performed io.max write, got {io_max:?}"
1710 );
1711 assert_eq!(
1712 io_max.declared_bytes_per_sec,
1713 HostResourceLimits::default().cgroup_io_bytes_per_sec
1714 );
1715 if io_max.status == IoMaxStatus::Unavailable {
1716 assert!(io_max.reason.is_some(), "unavailable must carry a reason");
1717 assert!(io_max_warning(&io_max).is_some());
1718 } else {
1719 assert_eq!(io_max.reason, None);
1720 assert_eq!(io_max_warning(&io_max), None);
1721 }
1722 }
1723
1724 /// The unit name systemd is asked for stays inside the `murmur-*` convention operators
1725 /// already use to find this runtime's cgroup directories, and stays unique per request.
1726 #[cfg(target_os = "linux")]
1727 #[test]
1728 fn transient_scope_unit_names_are_murmur_prefixed_and_unique() {
1729 let first = transient_scope_unit_name();
1730 let second = transient_scope_unit_name();
1731 assert!(first.starts_with(&format!("murmur-{}-", std::process::id())));
1732 assert!(first.ends_with(".scope"));
1733 assert_ne!(first, second);
1734 }
1735
1736 #[test]
1737 fn unified_cgroup_path_is_read_from_the_zero_hierarchy_line() {
1738 let v2_only = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope\n";
1739 assert_eq!(
1740 parse_unified_cgroup_path(v2_only).as_deref(),
1741 Some("/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope")
1742 );
1743
1744 let hybrid = "12:pids:/user.slice\n11:memory:/user.slice\n0::/user.slice/session.scope\n";
1745 assert_eq!(
1746 parse_unified_cgroup_path(hybrid).as_deref(),
1747 Some("/user.slice/session.scope")
1748 );
1749
1750 let v1_only = "12:pids:/user.slice\n11:memory:/user.slice\n";
1751 assert_eq!(parse_unified_cgroup_path(v1_only), None);
1752 }
1753
1754 #[test]
1755 fn cgroup2_mount_falls_back_to_the_conventional_path() {
1756 let mounts = "cgroup2 /custom/cgroup cgroup2 rw,nsdelegate 0 0\n";
1757 assert_eq!(parse_cgroup2_mount(mounts), "/custom/cgroup");
1758 assert_eq!(
1759 parse_cgroup2_mount("proc /proc proc rw 0 0\n"),
1760 "/sys/fs/cgroup"
1761 );
1762 }
1763
1764 #[test]
1765 fn missing_controllers_reports_only_what_is_absent() {
1766 assert_eq!(
1767 missing_controllers("cpu memory", REQUIRED_CONTROLLERS),
1768 vec!["pids".to_string()]
1769 );
1770 assert!(missing_controllers("cpu io memory pids", REQUIRED_CONTROLLERS).is_empty());
1771 assert_eq!(
1772 missing_controllers("", REQUIRED_CONTROLLERS),
1773 vec!["memory".to_string(), "pids".to_string(), "cpu".to_string()]
1774 );
1775 }
1776
1777 #[test]
1778 fn event_counters_parse_the_named_key_only() {
1779 let memory_events = "low 0\nhigh 0\nmax 12\noom 3\noom_kill 2\n";
1780 assert_eq!(parse_event_counter(memory_events, "oom_kill"), 2);
1781 assert_eq!(parse_event_counter(memory_events, "max"), 12);
1782 assert_eq!(parse_event_counter("max 7\n", "max"), 7);
1783 assert_eq!(parse_event_counter("", "oom_kill"), 0);
1784 assert_eq!(parse_event_counter("oom_kill\n", "oom_kill"), 0);
1785 }
1786
1787 /// Attribution keys on the delta, not the absolute counter: a session-cumulative total would
1788 /// mis-attribute every later call once any earlier one had been OOM-killed.
1789 #[test]
1790 fn attribution_uses_the_delta_across_one_call() {
1791 let before = CgroupEventCounters {
1792 oom_kill: 1,
1793 pids_max: 4,
1794 };
1795 assert_eq!(before.attribution_since(before), None);
1796 assert_eq!(
1797 CgroupEventCounters {
1798 oom_kill: 2,
1799 pids_max: 4
1800 }
1801 .attribution_since(before),
1802 Some("cgroup_memory_bytes")
1803 );
1804 assert_eq!(
1805 CgroupEventCounters {
1806 oom_kill: 1,
1807 pids_max: 5
1808 }
1809 .attribution_since(before),
1810 Some("cgroup_pids_max")
1811 );
1812 }
1813
1814 #[test]
1815 fn scope_dir_name_is_sanitized_and_never_empty() {
1816 assert!(scope_dir_name("ses_abc123").starts_with("murmur-ses_abc123-"));
1817 assert!(scope_dir_name("../escape").starts_with("murmur-.._escape-"));
1818 assert!(!scope_dir_name("../escape").contains('/'));
1819 assert!(scope_dir_name("").starts_with("murmur-"));
1820 }
1821
1822 /// A subprocess inherits its parent's cgroup, so a `mur` spawned by a runtime that already
1823 /// stepped aside starts life *inside* the supervisor leaf. Reading that as its delegated base
1824 /// would nest another leaf inside it, one per generation, until nothing is delegated.
1825 #[test]
1826 fn delegated_base_ascends_out_of_inherited_supervisor_leaves() {
1827 let base = PathBuf::from("/sys/fs/cgroup/user.slice/app.slice/mur.scope");
1828
1829 assert_eq!(ascend_out_of_supervisor_leaves(base.clone()), base);
1830 assert_eq!(
1831 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF)),
1832 base
1833 );
1834 assert_eq!(
1835 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF).join(SUPERVISOR_LEAF)),
1836 base,
1837 "a base already nested by an earlier build must still resolve to the real root"
1838 );
1839 }
1840
1841 /// Two scopes must never share a directory even when handed the same id: `Drop` removes the
1842 /// scope by path, so a collision would have one session delete the cgroup still bounding
1843 /// another's subprocess tree.
1844 #[test]
1845 fn scope_dir_name_is_unique_per_call_for_one_id() {
1846 let first = scope_dir_name("p");
1847 let second = scope_dir_name("p");
1848 assert_ne!(
1849 first, second,
1850 "same plan id must not reuse a scope directory"
1851 );
1852 }
1853
1854 #[test]
1855 fn pid_is_formatted_without_allocating() {
1856 let mut buf = [0u8; 24];
1857 let len = format_pid(4321, &mut buf);
1858 assert_eq!(&buf[..len], b"4321\n");
1859
1860 let len = format_pid(7, &mut buf);
1861 assert_eq!(&buf[..len], b"7\n");
1862 }
1863
1864 #[test]
1865 fn device_numbers_use_the_wide_glibc_encoding() {
1866 // 8:0 (`/dev/sda`) encodes as 0x800 under the 12+20 bit layout.
1867 assert_eq!(device_major_minor(0x800), (8, 0));
1868 // 259:3 (`nvme0n1p3`) needs the wide major field, which the legacy 8+8 layout truncates.
1869 assert_eq!(device_major_minor((259 << 8) | 3), (259, 3));
1870 }
1871}