Skip to main content

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        eprintln!("[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            eprintln!(
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(&current) {
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(&current, &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(&current, &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        eprintln!(
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}