Skip to main content

capsule_runtime/
cgroup.rs

1//! Linux cgroup v2 scoping for a capsule's whole 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, checked against every thread the
5//! uid owns on the host, so it cannot bound one tree, and no rlimit bounds *aggregate* memory or
6//! CPU across a tree at all. A cgroup v2 scope does exactly what rlimits structurally cannot:
7//! `pids.max`, `memory.max` and `cpu.max` apply to every task in the scope together, and the
8//! kernel enforces them at fork/allocate time rather than after the fact. `memory.swap.max` is
9//! written as [`SCOPE_SWAP_MAX`] beside `memory.max`, so the memory ceiling bounds the tree's
10//! whole footprint rather than only its resident pages. A spawn that joins a
11//! scope therefore gets no `RLIMIT_NPROC` at all, and `pids.max` is its only process bound — see
12//! `resources::NprocBound`.
13//!
14//! ## Install requirement: systemd user cgroup delegation
15//!
16//! Creating a cgroup requires write access to a cgroup directory, and this runtime has none by
17//! default. The usual alternative — start as root, create the cgroup, then drop privileges —
18//! is not available here: nothing in this codebase ever runs as root or drops privileges (there
19//! is no setuid or privilege-drop path anywhere in `capsule-runtime` or `murmur-cli`), so there
20//! is no privileged phase to create a cgroup from. The mechanism that fits how this runtime
21//! actually runs is **systemd user delegation**: the user unit or scope `mur` runs under carries
22//! `Delegate=yes` for `memory pids cpu io`, which hands the unprivileged user write access to
23//! its own cgroup subtree. This is the same mechanism rootless Docker and Podman use for the
24//! identical problem. The exact unit configuration is in
25//! `docs/content/reference/resource-limits-manual-verification.md`.
26//!
27//! ## Self-relocation before probing an inherited cgroup
28//!
29//! cgroup v2 refuses to enable controllers for a cgroup's children while that cgroup still holds
30//! tasks, so a bounded child can only be built under a base this process is the *sole* occupant
31//! of. An interactive shell's cgroup holds the shell, its pane scope, and everything else the
32//! user is running; under `cargo test`, `cargo` stays resident beside every test binary. Stepping
33//! aside into [`SUPERVISOR_LEAF`] moves only this process, so co-residency alone makes the base
34//! unusable regardless of delegation.
35//!
36//! [`move_self_into_own_systemd_scope`] asks the systemd *user* manager over the session bus for
37//! a freshly created transient scope carrying `Delegate=yes`, moving this process into a
38//! brand-new cgroup — a sibling of whatever it left, not a child — that it is the sole occupant
39//! of by construction. The step-aside logic below then always succeeds, since nothing else is
40//! ever in that scope to contend with.
41//!
42//! The request is best-effort and silent: with no systemd, no session bus, or a policy denial,
43//! the runtime probes whatever cgroup it inherited, exactly as it does when this step is skipped.
44//!
45//! ## Fail closed, but only where the threat exists
46//!
47//! On Linux, a capsule that can spawn *any* subprocess and cannot be given a cgroup
48//! refuses to launch (`RuntimeError::CgroupDelegationUnavailable`) rather than running that
49//! subprocess tree unbounded. A capsule that declares no subprocess capability at all needs no
50//! scope and is never blocked — there is no process tree to bound. On macOS, cgroups cannot
51//! exist at all, so this module is inert and the runtime falls back to rlimits-only enforcement
52//! with a `W-SEC-010` warning; refusing every launch on the primary development platform for a
53//! kernel feature that platform will never have would be a policy, not a protection.
54//!
55//! ## A scope outlives no runtime
56//!
57//! Each scope [`CgroupScope::create`] makes on Linux has a watcher: a process forked from the
58//! runtime that blocks reading a pipe whose write end only the runtime holds. When the read
59//! returns end-of-file — the scope's `Drop` closed it, or the kernel closed it because the runtime
60//! died by any means, `SIGKILL` and the OOM killer included — the watcher kills the scope and
61//! exits. A parent-death signal would reach only a direct child, and a backgrounded server is a
62//! grandchild; the pipe reaches every death at once. The watcher stays in the runtime's own
63//! cgroup, never in the scope it kills, and holds none of the runtime's other descriptors, so it
64//! keeps no pipe from reaching end-of-file and no port bound.
65//!
66//! ## Cross-platform shape
67//!
68//! Following `sandbox.rs`: the types here are defined unconditionally and stay type-checked and
69//! unit-testable on macOS, with the *behavior* gated internally by `cfg(target_os = "linux")`
70//! and cross-platform-dead surface marked `#[cfg_attr(not(target_os = "linux"), allow(dead_code))]`.
71//! Nothing is hidden behind a module-level `cfg`.
72
73use std::path::{Path, PathBuf};
74use std::sync::{
75    atomic::{AtomicU64, Ordering},
76    Arc,
77};
78#[cfg(target_os = "linux")]
79use std::time::Duration;
80
81use crate::resources::HostResourceLimits;
82
83/// Where the unified cgroup v2 hierarchy is mounted on a conventional host. Only used as the
84/// fallback when `/proc/mounts` does not name a `cgroup2` mount explicitly.
85#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
86const DEFAULT_CGROUP2_MOUNT: &str = "/sys/fs/cgroup";
87
88/// The `cpu.max` period, in microseconds. The kernel's own default; the quota this module
89/// writes is expressed against it (`cgroup_cpu_percent * 1000` µs of every 100 000 µs).
90#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
91const CPU_MAX_PERIOD_US: u64 = 100_000;
92
93/// `memory.swap.max` for every scope: no swap at all.
94///
95/// `memory.max` alone bounds only resident pages; with `memory.swap.max` at the kernel default
96/// `max`, a tree over `cgroup_memory_bytes` is pushed out to swap instead of being OOM-killed and
97/// keeps growing there. Zero makes `cgroup_memory_bytes` the tree's whole memory footprint, which
98/// is what the manifest field promises.
99pub const SCOPE_SWAP_MAX: &str = "0";
100
101/// One cgroup interface file a scope's limits are written to, and the value written.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub(crate) struct ScopeLimit {
104    pub(crate) file: &'static str,
105    pub(crate) value: String,
106    /// Whether a kernel that exposes no such file still yields a bounded scope. Only
107    /// `memory.swap.max` is: it is absent when swap support is compiled out or swap accounting
108    /// is off at boot, and on such a kernel there is no per-cgroup swap control to write. A file
109    /// that exists and refuses the write is fatal either way.
110    pub(crate) absent_ok: bool,
111}
112
113/// The limit writes a scope is created with, in write order. `io.max` is not among them: it is
114/// best-effort and reported separately (see [`CgroupScope::apply_io_max`]). `cpu.max` is among
115/// them only when `cgroup_cpu_percent` is set; without it the scope keeps the kernel's
116/// `max 100000`.
117#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
118pub(crate) fn scope_limits(limits: &HostResourceLimits) -> Vec<ScopeLimit> {
119    let required = |file, value| ScopeLimit {
120        file,
121        value,
122        absent_ok: false,
123    };
124    let mut writes = vec![
125        required("memory.max", limits.cgroup_memory_bytes.to_string()),
126        ScopeLimit {
127            file: "memory.swap.max",
128            value: SCOPE_SWAP_MAX.to_string(),
129            absent_ok: true,
130        },
131        required("pids.max", limits.cgroup_pids_max.to_string()),
132    ];
133    if let Some(percent) = limits.cgroup_cpu_percent {
134        writes.push(required(
135            "cpu.max",
136            format!(
137                "{} {CPU_MAX_PERIOD_US}",
138                u64::from(percent) * (CPU_MAX_PERIOD_US / 100)
139            ),
140        ));
141    }
142    writes
143}
144
145/// The writes of an unconfined session's scope: one per `cgroup_*` field `declared` names, and
146/// nothing else — no `memory.swap.max` and no default for an undeclared field. `io.max` is
147/// written separately, and only when declared.
148#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
149pub(crate) fn declared_scope_limits(
150    declared: &murmur_artifact::ResourceCapabilities,
151) -> Vec<ScopeLimit> {
152    let required = |file, value| ScopeLimit {
153        file,
154        value,
155        absent_ok: false,
156    };
157    let mut writes = Vec::new();
158    if let Some(bytes) = declared.cgroup_memory_bytes {
159        writes.push(required("memory.max", bytes.to_string()));
160    }
161    if let Some(pids) = declared.cgroup_pids_max {
162        writes.push(required("pids.max", pids.to_string()));
163    }
164    if let Some(percent) = declared.cgroup_cpu_percent {
165        writes.push(required(
166            "cpu.max",
167            format!(
168                "{} {CPU_MAX_PERIOD_US}",
169                u64::from(percent) * (CPU_MAX_PERIOD_US / 100)
170            ),
171        ));
172    }
173    writes
174}
175
176/// Whether this kernel gives a cgroup a `memory.swap.max` to write, as `mur doctor` reports it.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum SwapControl {
179    /// A cgroup on this host carries `memory.swap.max`, so a scope's swap is bounded at
180    /// [`SCOPE_SWAP_MAX`].
181    Exposed,
182    /// A cgroup on this host carries `memory.max` and no `memory.swap.max`: the kernel has no
183    /// per-cgroup swap control, and a scope's swap is unbounded.
184    Absent,
185    /// No cgroup this process can see has the memory controller enabled, so the question cannot
186    /// be answered from here. Also the answer off Linux.
187    Unknown,
188}
189
190/// Probe whether this kernel exposes `memory.swap.max`, without creating or moving anything.
191///
192/// Walks from this process's own cgroup up to the hierarchy root and answers from the first
193/// cgroup that has the memory controller (a `memory.max` file): the file set a cgroup gets is the
194/// kernel's, so one cgroup with `memory.max` and no `memory.swap.max` speaks for every scope this
195/// host would create.
196#[must_use]
197pub fn probe_swap_control() -> SwapControl {
198    #[cfg(not(target_os = "linux"))]
199    {
200        SwapControl::Unknown
201    }
202
203    #[cfg(target_os = "linux")]
204    {
205        let mount = std::fs::read_to_string("/proc/mounts")
206            .map(|contents| parse_cgroup2_mount(&contents))
207            .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
208        let Some(own) = std::fs::read_to_string("/proc/self/cgroup")
209            .ok()
210            .and_then(|contents| parse_unified_cgroup_path(&contents))
211        else {
212            return SwapControl::Unknown;
213        };
214        let root = PathBuf::from(mount);
215        let mut dir = root.join(own.trim_start_matches('/'));
216        loop {
217            if dir.join("memory.max").exists() {
218                return if dir.join("memory.swap.max").exists() {
219                    SwapControl::Exposed
220                } else {
221                    SwapControl::Absent
222                };
223            }
224            if dir == root || !dir.pop() || !dir.starts_with(&root) {
225                return SwapControl::Unknown;
226            }
227        }
228    }
229}
230
231/// How many swap areas `/proc/swaps` lists as active, or `None` where it cannot be read.
232#[must_use]
233pub fn active_swap_areas() -> Option<usize> {
234    let contents = std::fs::read_to_string("/proc/swaps").ok()?;
235    Some(count_swap_areas(&contents))
236}
237
238/// Count the device lines in `/proc/swaps` contents, below its one header line.
239fn count_swap_areas(contents: &str) -> usize {
240    contents
241        .lines()
242        .skip(1)
243        .filter(|line| !line.trim().is_empty())
244        .count()
245}
246
247/// Controllers that must be delegated and settable. `io` is deliberately absent: it is
248/// best-effort (see [`CgroupScope::apply_io_max`]).
249#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
250const REQUIRED_CONTROLLERS: &[&str] = &["memory", "pids", "cpu"];
251
252/// Name of the leaf cgroup the runtime moves *itself* into when its delegated base still holds
253/// processes. cgroup v2's "no internal processes" rule forbids enabling controllers in a
254/// cgroup's `subtree_control` while that cgroup directly contains tasks, so the manager has to
255/// step down into its own leaf first — the same move rootless Podman/Docker make.
256#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
257const SUPERVISOR_LEAF: &str = "murmur-supervisor";
258
259/// A cgroup v2 scope created for one capsule session, holding that session's whole
260/// subprocess tree.
261///
262/// Constructed once per launch (never per subprocess) so that `pids.max` and `memory.max` bound
263/// the tree *in aggregate* — a per-call scope would let N sequential shell calls each spend the
264/// full budget, which is precisely the aggregate case rlimits already fail to cover.
265#[derive(Debug)]
266pub(crate) struct CgroupScope {
267    /// Absolute path of the scope directory, e.g.
268    /// `/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope/murmur-ses_abc123`.
269    path: PathBuf,
270    /// `cgroup.procs`, opened write-only in the **parent** before any fork. The forked child's
271    /// `pre_exec` closure only ever writes its own pid to this already-open descriptor — it
272    /// never performs a path lookup, matching how `sandbox.rs` pre-opens its Landlock fds rather
273    /// than opening paths inside `pre_exec`.
274    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
275    procs_fd: Option<std::os::fd::OwnedFd>,
276    /// Who is inside the scope right now, which decides whether a counter delta can be pinned on
277    /// one call. Behind its own `Arc` so a [`ScopeOccupancy`] can outlive the borrow it was
278    /// taken from and move to a demoted command's thread.
279    occupancy: Arc<Occupancy>,
280    /// The process that kills the scope once the runtime is gone. `None` off Linux and for a
281    /// scope with no directory.
282    #[cfg(target_os = "linux")]
283    watcher: Option<ScopeWatcher>,
284}
285
286/// The scope's occupancy counters.
287///
288/// The scope's kill/denial counters are shared by every process in it, so a delta read across one
289/// call's window is evidence about that call only when nothing else ran in the scope during the
290/// window. These two counters are what answer that.
291#[derive(Debug, Default)]
292struct Occupancy {
293    /// Shell commands and `murmur:process` programs inside the scope now.
294    occupants: AtomicU64,
295    /// Entries since the scope was created. Never decremented: a later entry is visible as a
296    /// higher number even after it has left.
297    entries: AtomicU64,
298}
299
300impl Occupancy {
301    /// Mark one spawn as inside the scope until the returned guard drops.
302    fn enter(self: &Arc<Self>) -> ScopeOccupancy {
303        // Every access is `SeqCst`, and `entries` is read on both sides of the `occupants`
304        // increment. An entry that raced in between the two increments — counted in one, not yet
305        // in the other — moves `entries` past what was read first, so neither call can think it
306        // was alone while the other was already on its way in.
307        let entries_before = self.entries.load(Ordering::SeqCst);
308        let others_inside = self.occupants.fetch_add(1, Ordering::SeqCst) > 0;
309        let sequence = self.entries.fetch_add(1, Ordering::SeqCst);
310        ScopeOccupancy {
311            occupancy: Arc::clone(self),
312            alone_at_entry: !others_inside && sequence == entries_before,
313            sequence,
314        }
315    }
316}
317
318/// One spawn's stay inside a [`CgroupScope`]. Leaves the scope on drop.
319///
320/// Held for as long as the process it was taken for runs — a demoted command carries it to its
321/// own thread — so a sibling's window overlaps it exactly when the two processes did.
322#[derive(Debug)]
323pub(crate) struct ScopeOccupancy {
324    occupancy: Arc<Occupancy>,
325    alone_at_entry: bool,
326    sequence: u64,
327}
328
329impl ScopeOccupancy {
330    /// Whether this spawn had the scope to itself from entry until now: nobody was inside when it
331    /// entered, and nobody has entered since.
332    pub(crate) fn exclusive(&self) -> bool {
333        self.alone_at_entry && self.occupancy.entries.load(Ordering::SeqCst) == self.sequence + 1
334    }
335}
336
337impl Drop for ScopeOccupancy {
338    fn drop(&mut self) {
339        self.occupancy.occupants.fetch_sub(1, Ordering::SeqCst);
340    }
341}
342
343/// Snapshot of the kernel-maintained kill/denial counters on a scope.
344///
345/// Read before and after a subprocess runs, so attribution rests on the *delta* for that call
346/// rather than on a session-cumulative total that any earlier call could have moved.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
348pub(crate) struct CgroupEventCounters {
349    /// `memory.events`' `oom_kill` — tasks the kernel killed for exceeding `memory.max`.
350    pub(crate) oom_kill: u64,
351    /// `pids.events`' `max` — forks refused for exceeding `pids.max`.
352    pub(crate) pids_max: u64,
353}
354
355impl CgroupEventCounters {
356    /// The named limit this delta unambiguously attributes to, or `None`.
357    ///
358    /// Both counters are maintained by the kernel and incremented only by the condition they
359    /// name, so a nonzero delta is evidence rather than inference — the property that makes
360    /// these two safe to report where a bare `SIGKILL` is not (see
361    /// [`crate::resources::limit_from_signal`]).
362    ///
363    /// The evidence is about the scope, not about any one process in it. A delta read across one
364    /// call's window is about that call only when the call was alone in the scope for the whole
365    /// window ([`ScopeOccupancy::exclusive`]); `shell::attribute_resource_limit` is where that
366    /// rule is applied.
367    pub(crate) fn attribution_since(&self, before: Self) -> Option<&'static str> {
368        if self.oom_kill > before.oom_kill {
369            return Some("cgroup_memory_bytes");
370        }
371        if self.pids_max > before.pids_max {
372            return Some("cgroup_pids_max");
373        }
374        None
375    }
376}
377
378/// Whether the declared `capabilities.resources.cgroup_io_bytes_per_sec` ceiling is actually on
379/// this session's cgroup scope.
380///
381/// Four states rather than a `bool` because "the write failed" and "there was never a scope to
382/// write to" are different findings with different remediations, and neither is the same as "this
383/// value was never established". [`Self::NotProbed`] is the `Default` so a report nobody filled in
384/// claims nothing.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
386#[serde(rename_all = "kebab-case")]
387pub enum IoMaxStatus {
388    /// The `io.max` write succeeded against the device backing the workdir. The ceiling is on the
389    /// scope.
390    Enforced,
391    /// A scope exists and the `io.max` write did not succeed. `memory.max` and `pids.max` are
392    /// still enforced on it, and so is `cpu.max` when `cgroup_cpu_percent` sets a cap; I/O
393    /// bandwidth is not bounded. What `W-SEC-021` reports.
394    Unavailable,
395    /// No scope was asked for: either this capsule declares no way to reach a subprocess,
396    /// or this is not Linux, where cgroups cannot exist. Not a shortfall — there is no process
397    /// tree for a ceiling to apply to.
398    NotRequired,
399    /// Nobody asked. The `Default`, so a [`IoMaxReport`] that was constructed rather than measured
400    /// asserts nothing about the host.
401    #[default]
402    NotProbed,
403}
404
405/// The declared I/O ceiling and what became of it, carried into `--explain-scope`, into
406/// `session_start.effective_grants` and into `W-SEC-021`.
407///
408/// `io.max` is non-fatal, so a ceiling that did not apply refuses nothing. This value is what
409/// carries that outcome alongside the claim, so the manifest's declared ceiling and what the
410/// kernel accepted cannot disagree in silence.
411#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
412pub struct IoMaxReport {
413    /// The effective `capabilities.resources.cgroup_io_bytes_per_sec`, after
414    /// [`HostResourceLimits::resolve`] has applied its default. Reported whatever the status, so a
415    /// reader can see what was asked for as well as what happened to it.
416    pub declared_bytes_per_sec: u64,
417    /// What happened to it.
418    pub status: IoMaxStatus,
419    /// Why, for every status but [`IoMaxStatus::Enforced`]. Absent from the JSON when there is
420    /// nothing to say.
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub reason: Option<String>,
423}
424
425impl IoMaxReport {
426    /// The ceiling applied.
427    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
428    fn enforced(declared_bytes_per_sec: u64) -> Self {
429        Self {
430            declared_bytes_per_sec,
431            status: IoMaxStatus::Enforced,
432            reason: None,
433        }
434    }
435
436    /// A scope exists and the ceiling is not on it.
437    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
438    fn unavailable(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
439        Self {
440            declared_bytes_per_sec,
441            status: IoMaxStatus::Unavailable,
442            reason: Some(reason.into()),
443        }
444    }
445
446    /// No cgroup scope is required because this capsule can reach no subprocess.
447    ///
448    /// Public, and takes no reason argument: `mur run --explain-scope` has to produce the
449    /// byte-for-byte report a launch produces — `trace.rs`'s `session_start_effective_grants_match_explain_scope_json`
450    /// asserts the two objects are equal — so the wording lives here rather than once per caller.
451    #[must_use]
452    pub fn no_scope_required(declared_bytes_per_sec: u64) -> Self {
453        Self::not_required(declared_bytes_per_sec, NO_SCOPE_REASON)
454    }
455
456    /// No scope was asked for. Always carries its reason: "not required" is the one status whose
457    /// two causes — a capsule that can spawn nothing, and a platform with no cgroups — a reader
458    /// cannot tell apart from the status alone.
459    fn not_required(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
460        Self {
461            declared_bytes_per_sec,
462            status: IoMaxStatus::NotRequired,
463            reason: Some(reason.into()),
464        }
465    }
466}
467
468/// Whether this session's subprocesses get a delegated cgroup v2 scope, the precondition every
469/// capsule that can run a subprocess is refused without (`E-RUN-012`).
470///
471/// [`Self::NotProbed`] is the `Default` so a report nobody filled in claims nothing, on
472/// [`IoMaxStatus`]'s terms.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
474#[serde(rename_all = "kebab-case")]
475pub enum CgroupScopeStatus {
476    /// A child cgroup was created under the delegated base: at launch the session's scope, under
477    /// `--explain-scope` and `mur doctor` a throwaway one removed again.
478    Delegated,
479    /// No child cgroup could be created, so a launch of a capsule that needs one is refused with
480    /// `E-RUN-012`. Never seen at launch: a refused launch writes no `session_start`.
481    Unavailable,
482    /// No scope was asked for: the capsule declares no way to run a subprocess, or this is
483    /// not Linux.
484    NotRequired,
485    /// Nobody asked. The `Default`.
486    #[default]
487    NotProbed,
488}
489
490/// What became of this session's cgroup scope, carried into `--explain-scope`, into
491/// `session_start.effective_grants.cgroup_scope` and into `mur doctor`'s `cgroup scope:` line.
492///
493/// Filled from a performed operation, never inferred: at launch from [`prepare_scope`]'s outcome,
494/// under `--explain-scope` and `mur doctor` from [`probe_scope`], which creates and removes a
495/// child cgroup through the same calls a launch makes.
496#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
497pub struct CgroupScopeReport {
498    /// What happened.
499    pub status: CgroupScopeStatus,
500    /// The symbolic errno of the call that failed, for [`CgroupScopeStatus::Unavailable`] when
501    /// that call left one (`EROFS`, `EACCES`, …). Absent from the JSON otherwise.
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub errno: Option<String>,
504    /// Why, for [`CgroupScopeStatus::Unavailable`] and [`CgroupScopeStatus::NotRequired`]. For
505    /// `unavailable` it is the reason text `E-RUN-012` prints on the same host. Absent from the
506    /// JSON for `delegated`, which carries no path because the path differs between processes.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub reason: Option<String>,
509}
510
511impl CgroupScopeReport {
512    /// A child cgroup was created.
513    fn delegated() -> Self {
514        Self {
515            status: CgroupScopeStatus::Delegated,
516            errno: None,
517            reason: None,
518        }
519    }
520
521    /// No child cgroup could be created, for the reason `error` gives.
522    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
523    fn unavailable(error: &CgroupError) -> Self {
524        Self {
525            status: CgroupScopeStatus::Unavailable,
526            errno: error.errno.map(crate::network_namespace::errno_label),
527            reason: Some(error.message.clone()),
528        }
529    }
530
531    /// No scope was asked for, for `reason`.
532    fn not_required(reason: &str) -> Self {
533        Self {
534            status: CgroupScopeStatus::NotRequired,
535            errno: None,
536            reason: Some(reason.to_string()),
537        }
538    }
539
540    /// No scope is required because this capsule can reach no subprocess. Public on
541    /// [`IoMaxReport::no_scope_required`]'s terms.
542    #[must_use]
543    pub fn no_scope_required() -> Self {
544        Self::not_required(NO_CGROUP_SCOPE_REASON)
545    }
546
547    /// The report for a scope [`prepare_scope`] returned: delegated when it created one, not
548    /// required otherwise.
549    fn of_prepared(scope: Option<&Arc<CgroupScope>>, required: bool) -> Self {
550        match (scope, required) {
551            (Some(_), _) => Self::delegated(),
552            (None, false) => Self::no_scope_required(),
553            (None, true) => Self::not_required(NOT_LINUX_CGROUP_REASON),
554        }
555    }
556}
557
558/// The `cgroup scope:` line `--explain-scope` and `mur doctor` print, without indentation or a
559/// trailing newline, so the two cannot word one answer differently.
560#[must_use]
561pub fn render_cgroup_scope(report: &CgroupScopeReport) -> String {
562    let status = match report.status {
563        CgroupScopeStatus::Delegated => "delegated",
564        CgroupScopeStatus::Unavailable => "unavailable",
565        CgroupScopeStatus::NotRequired => "not-required",
566        CgroupScopeStatus::NotProbed => "not-probed",
567    };
568    match (&report.errno, &report.reason) {
569        (Some(errno), Some(reason)) => {
570            format!("cgroup scope: {status} ({errno}) \u{2014} {reason}")
571        }
572        (Some(errno), None) => format!("cgroup scope: {status} ({errno})"),
573        (None, Some(reason)) => format!("cgroup scope: {status} \u{2014} {reason}"),
574        (None, None) => format!("cgroup scope: {status}"),
575    }
576}
577
578/// Why a cgroup operation failed: the message an operator reads inside `E-RUN-012`, and the errno
579/// of the call that failed when it left one.
580///
581/// Displays as [`Self::message`] alone, so carrying the errno out changes no refusal's text.
582#[derive(Debug, Clone, PartialEq, Eq)]
583pub(crate) struct CgroupError {
584    /// The reason, verbatim as `E-RUN-012` prints it.
585    pub(crate) message: String,
586    /// `raw_os_error()` of the failing call, `None` where the failure was no system call.
587    pub(crate) errno: Option<i32>,
588}
589
590impl CgroupError {
591    /// `message`, caused by `error`.
592    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
593    fn io(message: String, error: &std::io::Error) -> Self {
594        Self {
595            message,
596            errno: error.raw_os_error(),
597        }
598    }
599}
600
601impl std::fmt::Display for CgroupError {
602    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603        f.write_str(&self.message)
604    }
605}
606
607impl From<String> for CgroupError {
608    fn from(message: String) -> Self {
609        Self {
610            message,
611            errno: None,
612        }
613    }
614}
615
616impl From<CgroupError> for String {
617    fn from(error: CgroupError) -> Self {
618        error.message
619    }
620}
621
622/// [`CgroupScopeReport::reason`] for the two `not-required` causes, worded for the scope rather
623/// than for the I/O ceiling [`NO_SCOPE_REASON`] speaks of.
624const NO_CGROUP_SCOPE_REASON: &str = "this capsule declares no way to run a subprocess, so \
625     a launch creates no cgroup scope for it";
626const NOT_LINUX_CGROUP_REASON: &str =
627    "cgroups are a Linux mechanism and this host has none, so a launch creates no cgroup scope";
628
629/// Reason text for the two [`IoMaxStatus::NotRequired`] causes, in one place so `prepare_scope`
630/// and [`probe_scope`] cannot word the same finding differently.
631const NO_SCOPE_REASON: &str = "this capsule declares no way to run a subprocess, so it is \
632     given no cgroup scope and there is no process tree for an I/O ceiling to apply to";
633/// Why an unconfined session that can start a subprocess has no scope, as `cgroup_scope` and as
634/// `io_max` say it.
635const UNCONFINED_NO_CGROUP_SCOPE_REASON: &str = "this session runs unconfined and \
636     capabilities.resources declares no cgroup field, so a launch creates no cgroup scope for it";
637const UNCONFINED_NO_SCOPE_REASON: &str = "this session runs unconfined and \
638     capabilities.resources declares no cgroup field, so it is given no cgroup scope and no \
639     I/O ceiling applies";
640
641/// Why an unconfined session's scope carries no `io.max`.
642#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
643const UNCONFINED_IO_UNDECLARED_REASON: &str = "this session runs unconfined and \
644     capabilities.resources declares no cgroup_io_bytes_per_sec, so its cgroup scope writes no \
645     io.max";
646#[cfg_attr(target_os = "linux", allow(dead_code))]
647const NOT_LINUX_REASON: &str =
648    "cgroups are a Linux mechanism and this host has none, so no io.max ceiling can exist here";
649
650/// The message `W-SEC-021` carries, minus the limits still enforced and the reason the host gave.
651const IO_MAX_UNAVAILABLE_WARNING: &str = "the declared \
652capabilities.resources.cgroup_io_bytes_per_sec ceiling did not apply to this session's cgroup \
653scope, so this capsule's subprocess tree has no I/O bandwidth bound";
654
655/// Pure decision for the unenforced-`io.max` warning, split out of
656/// [`warn_for_unenforced_io_max`] the same way `sandbox::aggregate_bounding_warning` is split out
657/// of its emitter, so a test can assert it without capturing stderr.
658///
659/// Fires on [`IoMaxStatus::Unavailable`] alone. `NotRequired` is not a shortfall and `NotProbed`
660/// is not a finding, so neither warns. `cpu_capped` is whether the scope carries a `cpu.max`
661/// write ([`HostResourceLimits::cgroup_cpu_percent`] is set), so the message names only the
662/// limits that are in force.
663pub(crate) fn io_max_warning(
664    report: &IoMaxReport,
665    cpu_capped: bool,
666) -> Option<(&'static str, String)> {
667    if report.status != IoMaxStatus::Unavailable {
668        return None;
669    }
670    let declared = report.declared_bytes_per_sec;
671    let reason = report.reason.as_deref().unwrap_or("no reason reported");
672    let enforced = if cpu_capped {
673        "memory.max, pids.max and cpu.max"
674    } else {
675        "memory.max and pids.max"
676    };
677    Some((
678        murmur_artifact::W_SEC_021,
679        format!(
680            "{IO_MAX_UNAVAILABLE_WARNING}; {enforced} are still enforced on the scope \
681             (declared {declared} bytes/s): {reason}"
682        ),
683    ))
684}
685
686/// Fires at every launch, not just once.
687pub(crate) fn warn_for_unenforced_io_max(
688    workdir: &crate::workdir_fs::WorkdirRoot,
689    report: &IoMaxReport,
690    cpu_capped: bool,
691) {
692    if let Some((code, message)) = io_max_warning(report, cpu_capped) {
693        let link = murmur_artifact::security_warning_link(code);
694        crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
695        crate::agent::append_bootstrap_log(
696            workdir,
697            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
698        );
699    }
700}
701
702/// What [`prepare_scope`] establishes: the scope, and what became of its I/O ceiling.
703///
704/// A struct rather than a tuple so the two values cannot be swapped at a call site, and so a
705/// caller cannot take the scope without also being handed the ceiling's outcome.
706#[derive(Debug)]
707pub(crate) struct PreparedScope {
708    /// The scope, or `None` where none was required or none can exist.
709    pub(crate) scope: Option<Arc<CgroupScope>>,
710    /// Whether the declared `io.max` ceiling applied, established by performing the write rather
711    /// than by inferring it.
712    pub(crate) io_max: IoMaxReport,
713    /// What [`Self::scope`] is, as `session_start.effective_grants.cgroup_scope` records it.
714    pub(crate) cgroup_scope: CgroupScopeReport,
715}
716
717/// Decide-and-create entry point, called once per launch before any WASM is instantiated.
718///
719/// * `required == false` (the capsule declares no way to spawn a subprocess) → `Ok(None)`
720///   on every platform. There is no process tree to bound, so demanding host configuration here
721///   would be a regression for WASM-only capsules with nothing to show for it.
722/// * non-Linux → `Ok(None)` always. Never an error: cgroups are structurally impossible there,
723///   not a host misconfiguration.
724/// * Linux and required → probe delegation and create the scope; `Err(reason)` if the host
725///   cannot delegate one, which the caller turns into
726///   [`crate::errors::RuntimeError::CgroupDelegationUnavailable`].
727///
728/// The returned [`PreparedScope`] always carries an [`IoMaxReport`], on every one of those paths:
729/// the declared I/O ceiling is reported as applying, as not applying, or as not required, and
730/// never left silent.
731pub(crate) fn prepare_scope(
732    required: bool,
733    limits: &HostResourceLimits,
734    session_id: &str,
735    workdir: &Path,
736) -> Result<PreparedScope, String> {
737    if !required {
738        return Ok(PreparedScope {
739            scope: None,
740            io_max: IoMaxReport::no_scope_required(limits.cgroup_io_bytes_per_sec),
741            cgroup_scope: CgroupScopeReport::of_prepared(None, false),
742        });
743    }
744    #[cfg(target_os = "linux")]
745    {
746        #[cfg(test)]
747        if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
748            return Err("forced: delegation refused by the test seam".to_string());
749        }
750        let (scope, io_max) = CgroupScope::create(
751            &scope_limits(limits),
752            IoMaxWrite::Declared(limits.cgroup_io_bytes_per_sec),
753            session_id,
754            workdir,
755        )?;
756        let scope = Arc::new(scope);
757        Ok(PreparedScope {
758            cgroup_scope: CgroupScopeReport::of_prepared(Some(&scope), true),
759            scope: Some(scope),
760            io_max,
761        })
762    }
763    #[cfg(not(target_os = "linux"))]
764    {
765        let _ = (session_id, workdir);
766        Ok(PreparedScope {
767            scope: None,
768            io_max: IoMaxReport::not_required(limits.cgroup_io_bytes_per_sec, NOT_LINUX_REASON),
769            cgroup_scope: CgroupScopeReport::of_prepared(None, true),
770        })
771    }
772}
773
774/// The session's or a plan's scope for `policy`: [`prepare_scope`] with the resolved limits on a
775/// contained session, and on an unconfined one (a `containment_floor` of
776/// [`murmur_artifact::ContainmentClass::Unconfined`]) a scope that writes only the `cgroup_*`
777/// fields `capabilities.resources` declares. [`requires_process_bounding`] decides whether one is
778/// required either way.
779pub(crate) fn prepare_policy_scope(
780    policy: &crate::types::CapabilityPolicy,
781    session_id: &str,
782    workdir: &Path,
783) -> Result<PreparedScope, String> {
784    let required = requires_process_bounding(policy);
785    if policy.containment_floor != murmur_artifact::ContainmentClass::Unconfined {
786        return prepare_scope(required, &policy.resources, session_id, workdir);
787    }
788    let reported_bytes_per_sec = policy.resources.cgroup_io_bytes_per_sec;
789    if !required {
790        let (io_max, cgroup_scope) = unrequired_scope_reports(policy);
791        return Ok(PreparedScope {
792            scope: None,
793            io_max,
794            cgroup_scope,
795        });
796    }
797    #[cfg(target_os = "linux")]
798    {
799        #[cfg(test)]
800        if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
801            return Err("forced: delegation refused by the test seam".to_string());
802        }
803        let declared = &policy.declared_resources;
804        let io = match declared.cgroup_io_bytes_per_sec {
805            Some(bytes_per_sec) => IoMaxWrite::Declared(bytes_per_sec),
806            None => IoMaxWrite::Undeclared(reported_bytes_per_sec),
807        };
808        let (scope, io_max) =
809            CgroupScope::create(&declared_scope_limits(declared), io, session_id, workdir)?;
810        let scope = Arc::new(scope);
811        Ok(PreparedScope {
812            cgroup_scope: CgroupScopeReport::of_prepared(Some(&scope), true),
813            scope: Some(scope),
814            io_max,
815        })
816    }
817    #[cfg(not(target_os = "linux"))]
818    {
819        let _ = (session_id, workdir);
820        Ok(PreparedScope {
821            scope: None,
822            io_max: IoMaxReport::not_required(reported_bytes_per_sec, NOT_LINUX_REASON),
823            cgroup_scope: CgroupScopeReport::of_prepared(None, true),
824        })
825    }
826}
827
828/// The `io.max` and `cgroup_scope` reports of a session given no scope: the no-scope-required
829/// pair, or for an unconfined session that can start a subprocess and declares no `cgroup_*`
830/// field, the reason that is.
831fn unrequired_scope_reports(
832    policy: &crate::types::CapabilityPolicy,
833) -> (IoMaxReport, CgroupScopeReport) {
834    let reported_bytes_per_sec = policy.resources.cgroup_io_bytes_per_sec;
835    let has_route = crate::runtime::has_subprocess_route_at_staging(policy);
836    if policy.containment_floor == murmur_artifact::ContainmentClass::Unconfined && has_route {
837        return (
838            IoMaxReport::not_required(reported_bytes_per_sec, UNCONFINED_NO_SCOPE_REASON),
839            CgroupScopeReport::not_required(UNCONFINED_NO_CGROUP_SCOPE_REASON),
840        );
841    }
842    (
843        IoMaxReport::no_scope_required(reported_bytes_per_sec),
844        CgroupScopeReport::no_scope_required(),
845    )
846}
847
848/// Whether a scope writes `io.max`, and the ceiling it writes or reports.
849#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
850#[derive(Debug, Clone, Copy)]
851enum IoMaxWrite {
852    /// Write this ceiling.
853    Declared(u64),
854    /// An unconfined session declared no `cgroup_io_bytes_per_sec`: write nothing, and report the
855    /// resolved value as not required.
856    Undeclared(u64),
857}
858
859/// What [`probe_scope`] found: whether a launch would get its cgroup scope here, and whether the
860/// declared `io.max` ceiling would apply on it.
861#[derive(Debug, Clone, PartialEq, Eq)]
862pub struct ScopeProbe {
863    /// Whether a child cgroup could be created under the delegated base.
864    pub cgroup_scope: CgroupScopeReport,
865    /// Whether the `io.max` write succeeded on it.
866    pub io_max: IoMaxReport,
867}
868
869/// Answers "would a launch get its cgroup scope here, and would the declared `io.max` ceiling
870/// apply on it?" by doing what a launch does, against a throwaway cgroup created and removed
871/// inside this call: [`delegated_base`], then the scope directory through the same
872/// [`create_scope_dir`] [`prepare_scope`] calls, then the `io.max` write.
873///
874/// The diagnostic half of [`prepare_scope`], for `mur run --explain-scope` and `mur doctor`, which
875/// run before any workdir exists and before any session is staged. `required` is
876/// [`requires_process_bounding`] for the capsule: without it nothing is attempted and both answers
877/// are `not-required`, the reports a launch writes for such a capsule. A failure carries the
878/// reason `E-RUN-012` prints, so `--explain-scope` and the refusal word it identically.
879///
880/// Inferring the `io.max` answer from "is the `io` controller delegated" is wrong: the write can
881/// fail for a device the block layer will not accept (tmpfs, overlayfs, a device-mapper stack) on
882/// a host whose `io` controller is delegated perfectly well. Only performing the write answers it.
883///
884/// `workdir` need not exist. The device is resolved from its nearest existing ancestor, so the
885/// probe names the device a launch would use without creating anything.
886///
887/// Off Linux both answers are `not-required`. Whatever the outcome, the throwaway directory does
888/// not survive the call. Never run at launch: a launch learns the same answer from
889/// [`prepare_scope`].
890#[must_use]
891pub fn probe_scope(required: bool, limits: &HostResourceLimits, workdir: &Path) -> ScopeProbe {
892    let declared = limits.cgroup_io_bytes_per_sec;
893    if !required {
894        return ScopeProbe {
895            cgroup_scope: CgroupScopeReport::no_scope_required(),
896            io_max: IoMaxReport::no_scope_required(declared),
897        };
898    }
899    #[cfg(target_os = "linux")]
900    {
901        let base = match delegated_base() {
902            Ok(base) => base,
903            Err(error) => {
904                return ScopeProbe {
905                    cgroup_scope: CgroupScopeReport::unavailable(&error),
906                    io_max: IoMaxReport::unavailable(
907                        declared,
908                        format!("no delegated cgroup base to write io.max into: {error}"),
909                    ),
910                }
911            }
912        };
913        let path = base.join(scope_dir_name("explain-scope"));
914        if let Err(error) = create_scope_dir(&path) {
915            return ScopeProbe {
916                cgroup_scope: CgroupScopeReport::unavailable(&scope_dir_refusal(&path, &error)),
917                io_max: IoMaxReport::unavailable(
918                    declared,
919                    format!(
920                        "could not create a throwaway cgroup at {}: {error}",
921                        path.display()
922                    ),
923                ),
924            };
925        }
926        let io_max = write_io_max(&path, declared, workdir);
927        // Best-effort by necessity and not by preference: the scope holds no tasks, so the only
928        // way this fails is a host that took the directory away underneath us.
929        let _ = std::fs::remove_dir(&path);
930        ScopeProbe {
931            cgroup_scope: CgroupScopeReport::delegated(),
932            io_max,
933        }
934    }
935    #[cfg(not(target_os = "linux"))]
936    {
937        let _ = workdir;
938        ScopeProbe {
939            cgroup_scope: CgroupScopeReport::not_required(NOT_LINUX_CGROUP_REASON),
940            io_max: IoMaxReport::not_required(declared, NOT_LINUX_REASON),
941        }
942    }
943}
944
945/// `mkdir` of a scope directory, an existing one accepted: the one call [`prepare_scope`] and
946/// [`probe_scope`] both make to learn whether a child cgroup can be created.
947#[cfg(target_os = "linux")]
948fn create_scope_dir(path: &Path) -> std::io::Result<()> {
949    match std::fs::create_dir(path) {
950        Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => Err(error),
951        _ => Ok(()),
952    }
953}
954
955/// The `E-RUN-012` reason for a scope directory [`create_scope_dir`] could not create.
956#[cfg(target_os = "linux")]
957fn scope_dir_refusal(path: &Path, error: &std::io::Error) -> CgroupError {
958    CgroupError::io(
959        format!(
960            "could not create cgroup scope at {}: {error}",
961            path.display()
962        ),
963        error,
964    )
965}
966
967/// What `mur run --explain-scope` reports for `policy`'s cgroup scope and `io.max`, on the terms
968/// a launch reports them: [`probe_scope`] for a contained session, and for an unconfined one the
969/// no-scope reports when it declares no `cgroup_*` field, else the probe with `io.max` written
970/// only when `cgroup_io_bytes_per_sec` is declared.
971#[must_use]
972pub fn explain_scope_probe(policy: &crate::types::CapabilityPolicy, workdir: &Path) -> ScopeProbe {
973    let required = requires_process_bounding(policy);
974    if policy.containment_floor != murmur_artifact::ContainmentClass::Unconfined {
975        return probe_scope(required, &policy.resources, workdir);
976    }
977    if !required {
978        let (io_max, cgroup_scope) = unrequired_scope_reports(policy);
979        return ScopeProbe {
980            cgroup_scope,
981            io_max,
982        };
983    }
984    let declared_io = policy.declared_resources.cgroup_io_bytes_per_sec;
985    let mut probe = probe_scope(
986        true,
987        &HostResourceLimits {
988            cgroup_io_bytes_per_sec: declared_io
989                .unwrap_or(policy.resources.cgroup_io_bytes_per_sec),
990            ..policy.resources
991        },
992        workdir,
993    );
994    if cfg!(target_os = "linux") && declared_io.is_none() {
995        probe.io_max = IoMaxReport::not_required(
996            policy.resources.cgroup_io_bytes_per_sec,
997            UNCONFINED_IO_UNDECLARED_REASON,
998        );
999    }
1000    probe
1001}
1002
1003/// Does this policy give the capsule any way to run a subprocess at all?
1004///
1005/// The exact condition the Linux fail-closed launch refusal keys on: every route
1006/// [`crate::runtime::has_subprocess_route_at_staging`] counts. The same routes the `E-CAP-005`
1007/// egress refusal counts, from the same function.
1008///
1009/// An unconfined session (a `containment_floor` of
1010/// [`murmur_artifact::ContainmentClass::Unconfined`]) additionally requires a `cgroup_*` field
1011/// in `capabilities.resources`: it bounds only what the manifest declares.
1012pub fn requires_process_bounding(policy: &crate::types::CapabilityPolicy) -> bool {
1013    let has_route = crate::runtime::has_subprocess_route_at_staging(policy);
1014    if policy.containment_floor == murmur_artifact::ContainmentClass::Unconfined {
1015        return has_route && crate::resources::declares_cgroup_limit(&policy.declared_resources);
1016    }
1017    has_route
1018}
1019
1020impl CgroupScope {
1021    /// Absolute path of this scope's directory.
1022    pub(crate) fn path(&self) -> &Path {
1023        &self.path
1024    }
1025
1026    /// Move the **calling** process into this scope.
1027    ///
1028    /// Called from inside the forked child's `pre_exec` closure: it writes the child's own pid
1029    /// to the parent-opened `cgroup.procs` descriptor with a single `write(2)`, formatting the
1030    /// pid into a stack buffer so the whole path stays allocation-free and async-signal-safe.
1031    /// Every process the child subsequently forks inherits the scope, which is what makes the
1032    /// bound apply to the *tree* rather than to one process.
1033    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1034    #[allow(unsafe_code)]
1035    pub(crate) fn join_current_process(&self) -> std::io::Result<()> {
1036        use std::os::fd::AsRawFd;
1037
1038        let Some(fd) = self.procs_fd.as_ref() else {
1039            return Ok(());
1040        };
1041
1042        // SAFETY: `getpid` takes no arguments and cannot fail.
1043        let pid = unsafe { libc::getpid() };
1044        let mut buf = [0u8; 24];
1045        let written = format_pid(pid, &mut buf);
1046
1047        let mut offset = 0;
1048        while offset < written {
1049            // SAFETY: `fd` is an open write-only descriptor owned by `self`, and the slice
1050            // passed is a live sub-range of the stack buffer above.
1051            let rc = unsafe {
1052                libc::write(
1053                    fd.as_raw_fd(),
1054                    buf[offset..written].as_ptr().cast::<libc::c_void>(),
1055                    written - offset,
1056                )
1057            };
1058            if rc < 0 {
1059                let error = std::io::Error::last_os_error();
1060                if error.kind() == std::io::ErrorKind::Interrupted {
1061                    continue;
1062                }
1063                return Err(error);
1064            }
1065            offset += rc as usize;
1066        }
1067        Ok(())
1068    }
1069
1070    /// Mark one spawn as inside this scope until the returned guard drops. Taken before the
1071    /// counter snapshot a call's attribution is read against.
1072    pub(crate) fn enter(&self) -> ScopeOccupancy {
1073        self.occupancy.enter()
1074    }
1075
1076    /// Whether [`Self::join_current_process`] moves the caller into this scope, which it does
1077    /// exactly when the `cgroup.procs` descriptor is open; without it a join returns `Ok(())` and
1078    /// moves nothing. Every scope [`prepare_scope`] hands out has one. The `RLIMIT_NPROC` decision
1079    /// keys on this, not on the scope's presence, so a join that places nothing never leaves a
1080    /// spawn without its process bound.
1081    pub(crate) fn places_subprocesses(&self) -> bool {
1082        self.procs_fd.is_some()
1083    }
1084
1085    /// Current `memory.events`/`pids.events` counters.
1086    ///
1087    /// Must be read while the scope directory still exists — the files vanish with it, which is
1088    /// why cleanup happens in `Drop` at session end and never right after a child is reaped.
1089    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1090    pub(crate) fn event_counters(&self) -> CgroupEventCounters {
1091        CgroupEventCounters {
1092            oom_kill: read_event_counter(&self.path.join("memory.events"), "oom_kill"),
1093            pids_max: read_event_counter(&self.path.join("pids.events"), "max"),
1094        }
1095    }
1096}
1097
1098impl CgroupScope {
1099    /// Kill every process in this scope, whatever session or process group it put itself in.
1100    ///
1101    /// Writes `1` to `cgroup.kill` (Linux 5.14+), which `SIGKILL`s the whole scope in one kernel
1102    /// operation that races no fork. Where that file does not exist, falls back to freezing the
1103    /// scope, `SIGKILL`ing every pid `cgroup.procs` lists and thawing it, repeated until the scope
1104    /// is empty or [`KILL_FALLBACK_DEADLINE`] has passed. Returns as soon as the signals are sent;
1105    /// [`Self::wait_until_empty`] waits for the processes to be gone.
1106    ///
1107    /// The scope is a child of the delegated base, never the runtime's own cgroup, so the runtime
1108    /// is never in what this kills.
1109    pub(crate) fn kill_all(&self) -> std::io::Result<()> {
1110        if self.path.as_os_str().is_empty() {
1111            return Ok(());
1112        }
1113        #[cfg(target_os = "linux")]
1114        {
1115            #[cfg(test)]
1116            let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
1117            #[cfg(not(test))]
1118            let forced = false;
1119            if !forced {
1120                match write_existing(&self.path.join("cgroup.kill"), "1") {
1121                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1122                    written => return written,
1123                }
1124            }
1125            self.kill_by_freezing()
1126        }
1127        #[cfg(not(target_os = "linux"))]
1128        Ok(())
1129    }
1130
1131    /// Whether `cgroup.procs` lists no process, polled until it does or `timeout` passes.
1132    ///
1133    /// An exited process leaves `cgroup.procs` when it exits, not when its parent reaps it, so
1134    /// a killed command the runtime has not yet waited for does not hold this up.
1135    pub(crate) fn wait_until_empty(&self, timeout: std::time::Duration) -> bool {
1136        let deadline = std::time::Instant::now() + timeout;
1137        loop {
1138            if self.member_pids().is_empty() {
1139                return true;
1140            }
1141            if std::time::Instant::now() >= deadline {
1142                return false;
1143            }
1144            std::thread::sleep(KILL_POLL_INTERVAL);
1145        }
1146    }
1147
1148    /// Every pid `cgroup.procs` lists now. Empty when the file cannot be read, which is a scope
1149    /// whose directory is already gone.
1150    pub(crate) fn member_pids(&self) -> Vec<i32> {
1151        if self.path.as_os_str().is_empty() {
1152            return Vec::new();
1153        }
1154        std::fs::read_to_string(self.path.join("cgroup.procs"))
1155            .map(|procs| {
1156                procs
1157                    .lines()
1158                    .filter_map(|line| line.trim().parse().ok())
1159                    .collect()
1160            })
1161            .unwrap_or_default()
1162    }
1163
1164    /// `cgroup.kill` for a kernel that has none: freeze, `SIGKILL` each listed pid, thaw.
1165    ///
1166    /// Freezing first stops a listed process from forking a child the loop has not seen, and
1167    /// keeps a pid from being reused between reading it and signalling it. A frozen task still
1168    /// takes `SIGKILL`; it dies once thawed.
1169    #[cfg(target_os = "linux")]
1170    #[allow(unsafe_code)]
1171    fn kill_by_freezing(&self) -> std::io::Result<()> {
1172        let freeze = self.path.join("cgroup.freeze");
1173        let deadline = std::time::Instant::now() + KILL_FALLBACK_DEADLINE;
1174        loop {
1175            let pids = self.member_pids();
1176            if pids.is_empty() {
1177                return Ok(());
1178            }
1179            // Best-effort: a kernel without the freezer still gets every listed pid killed.
1180            let _ = write_existing(&freeze, "1");
1181            for pid in pids {
1182                // SAFETY: `kill` takes two integers and dereferences nothing.
1183                unsafe { libc::kill(pid, libc::SIGKILL) };
1184            }
1185            let _ = write_existing(&freeze, "0");
1186            if std::time::Instant::now() >= deadline {
1187                return Err(std::io::Error::other(format!(
1188                    "processes were still in {} after {} ms of SIGKILL",
1189                    self.path.display(),
1190                    KILL_FALLBACK_DEADLINE.as_millis()
1191                )));
1192            }
1193            std::thread::sleep(KILL_POLL_INTERVAL);
1194        }
1195    }
1196}
1197
1198/// How long [`CgroupScope::kill_all`]'s fallback keeps signalling a scope that is not yet empty.
1199const KILL_FALLBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
1200
1201/// How often a kill or an emptiness wait re-reads `cgroup.procs`.
1202const KILL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1203
1204/// Writes `value` to a control file that must already exist, so an absent file reads as
1205/// `NotFound` rather than as a refused create.
1206#[cfg(target_os = "linux")]
1207fn write_existing(path: &Path, value: &str) -> std::io::Result<()> {
1208    use std::io::Write as _;
1209    let file = std::fs::OpenOptions::new().write(true).open(path)?;
1210    (&file).write_all(value.as_bytes())
1211}
1212
1213// Forces `CgroupScope::kill_all` onto its freeze-and-signal fallback, as on a kernel with no
1214// `cgroup.kill`. Read on the calling thread, so it reaches a `kill_all` the test calls itself.
1215#[cfg(all(test, target_os = "linux"))]
1216thread_local! {
1217    pub(crate) static FORCE_KILL_FALLBACK: std::cell::Cell<bool> =
1218        const { std::cell::Cell::new(false) };
1219}
1220
1221// Makes a required `prepare_scope` fail as on a host that cannot delegate a scope. Read on the
1222// calling thread, so it reaches a `prepare_scope` the test calls itself.
1223#[cfg(all(test, target_os = "linux"))]
1224thread_local! {
1225    pub(crate) static FORCE_DELEGATION_FAILURE: std::cell::Cell<bool> =
1226        const { std::cell::Cell::new(false) };
1227}
1228
1229impl Drop for CgroupScope {
1230    /// Kills whatever is still in the scope, then removes the scope directory, best-effort.
1231    ///
1232    /// The backstop for a session that never reached `ShellEnforcement::end_session`, which has
1233    /// already killed the scope on every ordinary path. A cgroup directory can only be removed
1234    /// once empty, so the removal waits, bounded, for the kill to land; a scope still occupied
1235    /// after that is logged and moved past rather than retried, because blocking teardown on a
1236    /// process that will not die would turn a leaked directory into a hung exit.
1237    fn drop(&mut self) {
1238        if self.path.as_os_str().is_empty() {
1239            return;
1240        }
1241        if !self.member_pids().is_empty() {
1242            let _ = self.kill_all();
1243            self.wait_until_empty(KILL_FALLBACK_DEADLINE);
1244        }
1245        // Drop the descriptor before the rmdir so nothing holds the directory open, and reap the
1246        // watcher, which holds its own descriptors on the scope's files until it exits.
1247        self.procs_fd = None;
1248        #[cfg(target_os = "linux")]
1249        {
1250            self.watcher = None;
1251        }
1252        if let Err(error) = std::fs::remove_dir(&self.path) {
1253            crate::runtime_err!(
1254                "[capsule-runtime] note: could not remove cgroup scope {} ({error}); \
1255                 it will be reclaimed once its last task exits",
1256                self.path.display()
1257            );
1258        }
1259    }
1260}
1261
1262/// The process that kills one scope once nothing holds its lifeline's write end open.
1263///
1264/// [`Self::spawn`] forks it from the runtime; dropping this closes the write end and reaps it.
1265/// See the module documentation, "A scope outlives no runtime".
1266#[cfg(target_os = "linux")]
1267#[derive(Debug)]
1268struct ScopeWatcher {
1269    /// The lifeline's write end. Opened `O_CLOEXEC`, so no program the runtime execs holds a copy,
1270    /// and the watcher closes the copy it inherited: its read sees end-of-file exactly when this
1271    /// is closed or the runtime is gone.
1272    lifeline: std::sync::Mutex<Option<std::os::fd::OwnedFd>>,
1273    pid: libc::pid_t,
1274}
1275
1276/// The descriptors a watcher kills its scope through, opened by the runtime before the fork:
1277/// `cgroup.kill` where the kernel has it, else `cgroup.freeze` (when present) and `cgroup.procs`
1278/// for the freeze-and-signal loop [`CgroupScope::kill_all`] falls back to.
1279#[cfg(target_os = "linux")]
1280#[derive(Clone, Copy)]
1281struct WatcherKill {
1282    kill: libc::c_int,
1283    freeze: libc::c_int,
1284    procs: libc::c_int,
1285}
1286
1287#[cfg(target_os = "linux")]
1288impl ScopeWatcher {
1289    /// Fork the watcher of the scope at `scope`. Everything it needs is opened here, in the
1290    /// runtime, so the forked child — one thread of a multithreaded process — runs only
1291    /// async-signal-safe syscalls on descriptors and stack buffers that already exist.
1292    #[allow(unsafe_code)]
1293    fn spawn(scope: &Path) -> std::io::Result<Self> {
1294        use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
1295
1296        #[cfg(test)]
1297        let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
1298        #[cfg(not(test))]
1299        let forced = false;
1300        let kill = if forced {
1301            None
1302        } else {
1303            open_if_present(&scope.join("cgroup.kill"), libc::O_WRONLY)?
1304        };
1305        let (freeze, procs) = match kill {
1306            Some(_) => (None, None),
1307            None => (
1308                open_if_present(&scope.join("cgroup.freeze"), libc::O_WRONLY)?,
1309                Some(open_with(&scope.join("cgroup.procs"), libc::O_RDONLY)?),
1310            ),
1311        };
1312        let devnull = open_with(Path::new("/dev/null"), libc::O_RDWR)?;
1313
1314        let mut ends = [0 as libc::c_int; 2];
1315        // SAFETY: `ends` is a two-element array, the size `pipe2` writes into.
1316        if unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
1317            return Err(std::io::Error::last_os_error());
1318        }
1319        // SAFETY: both descriptors were just returned by the successful `pipe2` above and nothing
1320        // else owns them.
1321        let (read_end, write_end) =
1322            unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
1323
1324        let raw = |fd: &Option<OwnedFd>| fd.as_ref().map_or(-1, AsRawFd::as_raw_fd);
1325        let plan = WatcherKill {
1326            kill: raw(&kill),
1327            freeze: raw(&freeze),
1328            procs: raw(&procs),
1329        };
1330        // Sorted here, before the fork: the child keeps exactly these. The entries are distinct:
1331        // Rust's startup opens 0-2 when they are closed, so every descriptor opened above is 3 or
1332        // higher, and the child's `dup2` onto 0-2 replaces none of them.
1333        let mut keep = [-1 as libc::c_int; 7];
1334        let mut kept = 0;
1335        for fd in [
1336            0,
1337            1,
1338            2,
1339            read_end.as_raw_fd(),
1340            plan.kill,
1341            plan.freeze,
1342            plan.procs,
1343        ] {
1344            if fd >= 0 {
1345                keep[kept] = fd;
1346                kept += 1;
1347            }
1348        }
1349        keep[..kept].sort_unstable();
1350        let devnull_fd = devnull.as_raw_fd();
1351        let read_fd = read_end.as_raw_fd();
1352        let deadline_ns = KILL_FALLBACK_DEADLINE.as_nanos() as i64;
1353
1354        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
1355        // touches nothing but async-signal-safe primitives. The child runs only `dup2`,
1356        // `close_range`/`close`, `setpgid`, `sigaction`, `read`, `lseek`, `write`, `kill`,
1357        // `clock_gettime` and `nanosleep` on descriptors and stack values prepared above, then
1358        // `_exit`: no allocation, no locks, no stdio, no destructor.
1359        let pid = unsafe { libc::fork() };
1360        if pid < 0 {
1361            return Err(std::io::Error::last_os_error());
1362        }
1363        if pid == 0 {
1364            // SAFETY: forked-child context; `watch` ends in `_exit` and never returns.
1365            unsafe { watch(devnull_fd, &keep[..kept], read_fd, plan, deadline_ns) }
1366        }
1367        Ok(Self {
1368            lifeline: std::sync::Mutex::new(Some(write_end)),
1369            pid,
1370        })
1371    }
1372
1373    /// Close the lifeline's write end, as the runtime's death would, without reaping.
1374    #[cfg(test)]
1375    fn cut_lifeline(&self) {
1376        self.lifeline
1377            .lock()
1378            .unwrap_or_else(std::sync::PoisonError::into_inner)
1379            .take();
1380    }
1381}
1382
1383#[cfg(target_os = "linux")]
1384impl Drop for ScopeWatcher {
1385    /// Closes the lifeline, so the watcher kills the scope (by now empty, on every path through
1386    /// [`CgroupScope`]'s `Drop`) and exits, then reaps it.
1387    #[allow(unsafe_code)]
1388    fn drop(&mut self) {
1389        self.lifeline
1390            .get_mut()
1391            .unwrap_or_else(std::sync::PoisonError::into_inner)
1392            .take();
1393        let mut status = 0;
1394        loop {
1395            // SAFETY: `self.pid` is the watcher this value forked; `status` is a live local.
1396            let waited = unsafe { libc::waitpid(self.pid, &mut status, 0) };
1397            if waited >= 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
1398                return;
1399            }
1400        }
1401    }
1402}
1403
1404/// `path` opened with `flags | O_CLOEXEC`.
1405#[cfg(target_os = "linux")]
1406fn open_with(path: &Path, flags: libc::c_int) -> std::io::Result<std::os::fd::OwnedFd> {
1407    use std::os::unix::fs::OpenOptionsExt;
1408
1409    std::fs::OpenOptions::new()
1410        .read(flags & libc::O_ACCMODE != libc::O_WRONLY)
1411        .write(flags & libc::O_ACCMODE != libc::O_RDONLY)
1412        .custom_flags(libc::O_CLOEXEC)
1413        .open(path)
1414        .map(std::os::fd::OwnedFd::from)
1415}
1416
1417/// [`open_with`], or `None` for a control file this kernel does not expose.
1418#[cfg(target_os = "linux")]
1419fn open_if_present(
1420    path: &Path,
1421    flags: libc::c_int,
1422) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
1423    match open_with(path, flags) {
1424        Ok(fd) => Ok(Some(fd)),
1425        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1426        Err(error) => Err(error),
1427    }
1428}
1429
1430/// The watcher's whole life, in the forked child: hold no descriptor but `keep`, leave the
1431/// runtime's process group, ignore the signals a terminal or launcher sends a group, block until
1432/// the lifeline reads end-of-file, kill the scope, exit.
1433///
1434/// # Safety
1435///
1436/// Called only in a child just forked by [`ScopeWatcher::spawn`], with `keep` sorted and holding
1437/// 0, 1, 2, `read_fd` and every descriptor `plan` names. Never returns.
1438#[cfg(target_os = "linux")]
1439#[allow(unsafe_code)]
1440unsafe fn watch(
1441    devnull: libc::c_int,
1442    keep: &[libc::c_int],
1443    read_fd: libc::c_int,
1444    plan: WatcherKill,
1445    deadline_ns: i64,
1446) -> ! {
1447    // SAFETY (whole body): every call is an async-signal-safe syscall on descriptors the parent
1448    // opened before the fork or on stack values; nothing allocates or takes a lock.
1449    unsafe {
1450        // Standard streams onto /dev/null, so no pipe of the runtime's stays open through them.
1451        for target in 0..3 {
1452            libc::dup2(devnull, target);
1453        }
1454        let mut low: libc::c_uint = 0;
1455        for &fd in keep {
1456            let fd = fd as libc::c_uint;
1457            if fd > low {
1458                close_range(low, fd - 1);
1459            }
1460            low = fd + 1;
1461        }
1462        close_range(low, libc::c_uint::MAX);
1463
1464        libc::setpgid(0, 0);
1465        let mut ignore: libc::sigaction = std::mem::zeroed();
1466        ignore.sa_sigaction = libc::SIG_IGN;
1467        for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGPIPE] {
1468            libc::sigaction(signal, &ignore, std::ptr::null_mut());
1469        }
1470
1471        let mut byte = [0u8; 1];
1472        loop {
1473            let read = libc::read(read_fd, byte.as_mut_ptr().cast(), 1);
1474            if read == 0 {
1475                break;
1476            }
1477            if read < 0 && *libc::__errno_location() != libc::EINTR {
1478                break;
1479            }
1480        }
1481
1482        kill_scope(plan, deadline_ns);
1483        libc::_exit(0)
1484    }
1485}
1486
1487/// Close every descriptor in `low..=high`: `close_range(2)`, else one `close` per descriptor up to
1488/// the open-files limit, for a kernel older than 5.9 or a seccomp profile that refuses the call.
1489///
1490/// The watcher, the session PID namespace's holder and a command's relay each rely on holding
1491/// no descriptor they were not meant to keep, so none of them may skip the fallback.
1492///
1493/// # Safety
1494///
1495/// Closes descriptors other code may own: post-`fork()` child context only, in a process that
1496/// uses none of them again. Async-signal-safe and allocation-free.
1497#[cfg(target_os = "linux")]
1498#[allow(unsafe_code)]
1499pub(crate) unsafe fn close_range(low: libc::c_uint, high: libc::c_uint) {
1500    // SAFETY: integer arguments only.
1501    unsafe {
1502        if libc::syscall(libc::SYS_close_range, low, high, 0 as libc::c_uint) == 0 {
1503            return;
1504        }
1505        let mut limit: libc::rlimit = std::mem::zeroed();
1506        let ceiling = if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) == 0 {
1507            limit.rlim_cur.min(libc::c_uint::MAX as libc::rlim_t) as libc::c_uint
1508        } else {
1509            1 << 20
1510        };
1511        let mut fd = low;
1512        while fd <= high && fd < ceiling {
1513            libc::close(fd as libc::c_int);
1514            fd += 1;
1515        }
1516    }
1517}
1518
1519/// [`CgroupScope::kill_all`] for the watcher: one write to `cgroup.kill`, else freeze, `SIGKILL`
1520/// every pid `cgroup.procs` lists, thaw, until none is listed or `deadline_ns` has passed.
1521///
1522/// # Safety
1523///
1524/// Async-signal-safe; called only from [`watch`].
1525#[cfg(target_os = "linux")]
1526#[allow(unsafe_code)]
1527unsafe fn kill_scope(plan: WatcherKill, deadline_ns: i64) {
1528    // SAFETY: syscalls on the descriptors `plan` names and on stack values.
1529    unsafe {
1530        if plan.kill >= 0 {
1531            write_control(plan.kill, b"1");
1532            return;
1533        }
1534        let start = monotonic_ns();
1535        loop {
1536            if plan.freeze >= 0 {
1537                write_control(plan.freeze, b"1");
1538            }
1539            let signalled = signal_listed(plan.procs);
1540            if plan.freeze >= 0 {
1541                write_control(plan.freeze, b"0");
1542            }
1543            if signalled == 0 || monotonic_ns() - start >= deadline_ns {
1544                return;
1545            }
1546            let pause = libc::timespec {
1547                tv_sec: 0,
1548                tv_nsec: KILL_POLL_INTERVAL.as_nanos() as libc::c_long,
1549            };
1550            libc::nanosleep(&pause, std::ptr::null_mut());
1551        }
1552    }
1553}
1554
1555/// Write `value` at the start of the control file `fd`.
1556///
1557/// # Safety
1558///
1559/// Async-signal-safe.
1560#[cfg(target_os = "linux")]
1561#[allow(unsafe_code)]
1562unsafe fn write_control(fd: libc::c_int, value: &[u8]) {
1563    // SAFETY: `value` is a live slice for the duration of the call.
1564    unsafe {
1565        libc::lseek(fd, 0, libc::SEEK_SET);
1566        libc::write(fd, value.as_ptr().cast(), value.len());
1567    }
1568}
1569
1570/// `SIGKILL` every pid the `cgroup.procs` descriptor `procs` lists now, read from its start into a
1571/// stack buffer and parsed as it arrives. Returns how many were signalled.
1572///
1573/// # Safety
1574///
1575/// Async-signal-safe.
1576#[cfg(target_os = "linux")]
1577#[allow(unsafe_code)]
1578unsafe fn signal_listed(procs: libc::c_int) -> usize {
1579    // SAFETY: reads into a stack buffer of the length passed; `kill` takes integers.
1580    unsafe {
1581        if procs < 0 || libc::lseek(procs, 0, libc::SEEK_SET) < 0 {
1582            return 0;
1583        }
1584        let mut buf = [0u8; 512];
1585        let mut pid: libc::pid_t = 0;
1586        let mut digits = false;
1587        let mut signalled = 0;
1588        loop {
1589            let read = libc::read(procs, buf.as_mut_ptr().cast(), buf.len());
1590            if read < 0 && *libc::__errno_location() == libc::EINTR {
1591                continue;
1592            }
1593            if read <= 0 {
1594                break;
1595            }
1596            for &byte in &buf[..read as usize] {
1597                if byte.is_ascii_digit() {
1598                    pid = pid
1599                        .saturating_mul(10)
1600                        .saturating_add(libc::pid_t::from(byte - b'0'));
1601                    digits = true;
1602                } else {
1603                    if digits && pid > 0 {
1604                        libc::kill(pid, libc::SIGKILL);
1605                        signalled += 1;
1606                    }
1607                    pid = 0;
1608                    digits = false;
1609                }
1610            }
1611        }
1612        if digits && pid > 0 {
1613            libc::kill(pid, libc::SIGKILL);
1614            signalled += 1;
1615        }
1616        signalled
1617    }
1618}
1619
1620/// `CLOCK_MONOTONIC` in nanoseconds.
1621///
1622/// # Safety
1623///
1624/// Async-signal-safe.
1625#[cfg(target_os = "linux")]
1626#[allow(unsafe_code)]
1627unsafe fn monotonic_ns() -> i64 {
1628    // SAFETY: `now` is a live local `clock_gettime` writes into.
1629    unsafe {
1630        let mut now: libc::timespec = std::mem::zeroed();
1631        libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now);
1632        now.tv_sec as i64 * 1_000_000_000 + now.tv_nsec as i64
1633    }
1634}
1635
1636/// Format `pid` into `buf` as decimal ASCII followed by a newline, returning the byte count.
1637///
1638/// Hand-rolled because [`CgroupScope::join_current_process`] runs between `fork` and `execve`,
1639/// where `format!` (and the allocator lock behind it) is not async-signal-safe.
1640#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1641fn format_pid(pid: i32, buf: &mut [u8; 24]) -> usize {
1642    let mut digits = [0u8; 20];
1643    let mut count = 0;
1644    let mut value = pid.unsigned_abs() as u64;
1645    if value == 0 {
1646        digits[0] = b'0';
1647        count = 1;
1648    }
1649    while value > 0 {
1650        digits[count] = b'0' + (value % 10) as u8;
1651        value /= 10;
1652        count += 1;
1653    }
1654
1655    let mut written = 0;
1656    for index in (0..count).rev() {
1657        buf[written] = digits[index];
1658        written += 1;
1659    }
1660    buf[written] = b'\n';
1661    written + 1
1662}
1663
1664/// Read one `<key> <value>` counter out of a cgroup `*.events` file, or `0` if the file or key
1665/// is absent. Absence is not an error: `memory.events` only exists once the memory controller is
1666/// enabled, and treating a missing counter as zero means attribution simply declines to name a
1667/// limit rather than inventing one.
1668#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1669fn read_event_counter(path: &Path, key: &str) -> u64 {
1670    let Ok(contents) = std::fs::read_to_string(path) else {
1671        return 0;
1672    };
1673    parse_event_counter(&contents, key)
1674}
1675
1676/// Pure parse half of [`read_event_counter`] — no syscalls, so it is unit-testable on macOS.
1677#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1678fn parse_event_counter(contents: &str, key: &str) -> u64 {
1679    for line in contents.lines() {
1680        let mut parts = line.split_whitespace();
1681        if parts.next() == Some(key) {
1682            return parts
1683                .next()
1684                .and_then(|value| value.parse().ok())
1685                .unwrap_or(0);
1686        }
1687    }
1688    0
1689}
1690
1691/// Turn a session id into a directory name safe to create under the delegated base: everything
1692/// outside `[A-Za-z0-9._-]` becomes `_`.
1693///
1694/// The name is also made unique per scope, with the pid and a per-process serial. The id alone
1695/// cannot carry that: `plan::execute` passes the plan's *authored* id, so two plans running
1696/// concurrently in one process would otherwise land on the same directory — and because
1697/// [`CgroupScope`]'s `Drop` removes the scope by path, the first to finish would delete a cgroup
1698/// the other is still bounded by, leaving that subprocess tree unbounded. Sanitizing stays a
1699/// belt-and-braces guard against a hostile id rather than a live escape vector.
1700///
1701/// Operators still find scopes by the documented `murmur-*` glob.
1702#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1703fn scope_dir_name(session_id: &str) -> String {
1704    static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1705    let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1706
1707    let sanitized: String = session_id
1708        .chars()
1709        .map(|c| {
1710            if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
1711                c
1712            } else {
1713                '_'
1714            }
1715        })
1716        .collect();
1717    let pid = std::process::id();
1718    if sanitized.is_empty() {
1719        format!("murmur-{pid}-{serial}")
1720    } else {
1721        format!("murmur-{sanitized}-{pid}-{serial}")
1722    }
1723}
1724
1725/// Extract the unified-hierarchy path from `/proc/self/cgroup` contents.
1726///
1727/// A cgroup v2 host writes exactly one line whose hierarchy id is `0` and whose controller list
1728/// is empty: `0::/user.slice/...`. Returning `None` for anything else is what makes a v1-only
1729/// (or hybrid, v2-less) host a clean "cannot delegate" rather than a wrong path.
1730#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1731fn parse_unified_cgroup_path(contents: &str) -> Option<String> {
1732    for line in contents.lines() {
1733        let mut parts = line.splitn(3, ':');
1734        let (Some("0"), Some(""), Some(path)) = (parts.next(), parts.next(), parts.next()) else {
1735            continue;
1736        };
1737        if path.starts_with('/') {
1738            return Some(path.to_string());
1739        }
1740    }
1741    None
1742}
1743
1744/// Find the `cgroup2` mount point in `/proc/mounts` contents, falling back to the conventional
1745/// `/sys/fs/cgroup`.
1746#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1747fn parse_cgroup2_mount(contents: &str) -> String {
1748    for line in contents.lines() {
1749        let mut parts = line.split_whitespace();
1750        let (Some(_source), Some(target), Some(fstype)) =
1751            (parts.next(), parts.next(), parts.next())
1752        else {
1753            continue;
1754        };
1755        if fstype == "cgroup2" {
1756            return target.to_string();
1757        }
1758    }
1759    DEFAULT_CGROUP2_MOUNT.to_string()
1760}
1761
1762/// Which of `wanted` are missing from a `cgroup.subtree_control` file's contents.
1763#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1764fn missing_controllers(subtree_control: &str, wanted: &[&str]) -> Vec<String> {
1765    let enabled: Vec<&str> = subtree_control.split_whitespace().collect();
1766    wanted
1767        .iter()
1768        .filter(|controller| !enabled.contains(*controller))
1769        .map(|controller| (*controller).to_string())
1770        .collect()
1771}
1772
1773/// Encode a `st_dev` value as the `MAJ:MIN` string `io.max` expects, using glibc's device-number
1774/// layout (12+20 bit split, not the legacy 8+8).
1775#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1776fn device_major_minor(dev: u64) -> (u64, u64) {
1777    let major = ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff);
1778    let minor = (dev & 0xff) | ((dev >> 12) & !0xff);
1779    (major, minor)
1780}
1781
1782#[cfg(target_os = "linux")]
1783impl CgroupScope {
1784    /// Probe delegation, enable the controllers, create the scope directory, and write its
1785    /// limits from [`scope_limits`]. Any failure to establish `memory.max`, `memory.swap.max`,
1786    /// `pids.max` or `cpu.max` is fatal — they are settable on any cgroup v2 host once the
1787    /// controllers are delegated, so a failure there means the bound genuinely does not exist and
1788    /// the launch must not proceed. The one exception is a kernel that exposes no
1789    /// `memory.swap.max` at all (see [`ScopeLimit::absent_ok`]).
1790    fn create(
1791        limits: &[ScopeLimit],
1792        io: IoMaxWrite,
1793        session_id: &str,
1794        workdir: &Path,
1795    ) -> Result<(Self, IoMaxReport), String> {
1796        let base = delegated_base()?;
1797
1798        let path = base.join(scope_dir_name(session_id));
1799        create_scope_dir(&path).map_err(|error| scope_dir_refusal(&path, &error))?;
1800
1801        let scope = Self {
1802            path,
1803            procs_fd: None,
1804            occupancy: Arc::default(),
1805            watcher: None,
1806        };
1807
1808        for limit in limits {
1809            scope.write_limit(limit)?;
1810        }
1811        let io_max = match io {
1812            IoMaxWrite::Declared(bytes_per_sec) => scope.apply_io_max(bytes_per_sec, workdir),
1813            IoMaxWrite::Undeclared(bytes_per_sec) => {
1814                IoMaxReport::not_required(bytes_per_sec, UNCONFINED_IO_UNDECLARED_REASON)
1815            }
1816        };
1817
1818        // Opened last, so a scope that failed any of the fatal writes above is never handed out
1819        // with a usable join descriptor.
1820        let procs_fd = open_write_only(&scope.path.join("cgroup.procs")).map_err(|error| {
1821            format!(
1822                "could not open {}/cgroup.procs for subprocess placement: {error}",
1823                scope.path.display()
1824            )
1825        })?;
1826
1827        let watcher = ScopeWatcher::spawn(&scope.path).map_err(|error| {
1828            format!(
1829                "could not start the watcher that kills {} if the runtime dies: {error}",
1830                scope.path.display()
1831            )
1832        })?;
1833
1834        Ok((
1835            Self {
1836                path: scope.take_path(),
1837                procs_fd: Some(procs_fd),
1838                occupancy: Arc::default(),
1839                watcher: Some(watcher),
1840            },
1841            io_max,
1842        ))
1843    }
1844
1845    /// Move the path out of a scope that must not run its `Drop` (which would `rmdir` the
1846    /// directory the returned value is about to take ownership of).
1847    fn take_path(mut self) -> PathBuf {
1848        let path = std::mem::take(&mut self.path);
1849        // `self.path` is now empty, which `Drop` treats as "nothing to remove".
1850        path
1851    }
1852
1853    /// Opened without `O_CREAT`, so a file this kernel does not expose reads as `NotFound`
1854    /// rather than as a refused create.
1855    fn write_limit(&self, limit: &ScopeLimit) -> Result<(), String> {
1856        let path = self.path.join(limit.file);
1857        let value = &limit.value;
1858        match write_existing(&path, &format!("{value}\n")) {
1859            Err(error) if limit.absent_ok && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1860            written => written
1861                .map_err(|error| format!("could not write {value} to {}: {error}", path.display())),
1862        }
1863    }
1864
1865    /// Best-effort `io.max` on the workdir's backing block device, reported rather than printed.
1866    ///
1867    /// Non-fatal by design, and the only one of the four controllers treated that way: the
1868    /// backing device of a path cannot always be resolved to a real `MAJ:MIN` the block layer
1869    /// accepts (overlayfs, tmpfs, btrfs subvolumes and device-mapper stacks all break the
1870    /// assumption), and I/O throughput is the least safety-critical of the four — a capsule that
1871    /// saturates disk bandwidth is slow, where one that exhausts memory or pids is fatal.
1872    ///
1873    /// Non-fatal is not the same as unreported: the returned [`IoMaxReport`] travels into
1874    /// `--explain-scope`, into `session_start.effective_grants.io_max` and into `W-SEC-021`, so
1875    /// the manifest's declared ceiling and what the kernel accepted cannot disagree in silence.
1876    fn apply_io_max(&self, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1877        write_io_max(&self.path, bytes_per_sec, workdir)
1878    }
1879}
1880
1881/// Writes `io.max` into `scope_path` for the device behind `workdir`, and says what happened.
1882///
1883/// The one place the `io.max` line is composed, shared by [`CgroupScope::apply_io_max`] at launch
1884/// and by [`probe_scope`] under `--explain-scope`, so the diagnostic exercises the identical
1885/// write the launch performs rather than a paraphrase of it.
1886///
1887/// The device comes from [`io_max_device`], which resolves it from the nearest *existing*
1888/// ancestor of `workdir`: `--explain-scope` runs before the workdir is created, and a launch's
1889/// workdir sits on the same filesystem as the project directory it is created under.
1890#[cfg(target_os = "linux")]
1891fn write_io_max(scope_path: &Path, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1892    let (major, minor) = match io_max_device(workdir) {
1893        Ok(device) => device,
1894        Err(reason) => return IoMaxReport::unavailable(bytes_per_sec, reason),
1895    };
1896    let value = format!("{major}:{minor} rbps={bytes_per_sec} wbps={bytes_per_sec}");
1897    match std::fs::write(scope_path.join("io.max"), format!("{value}\n")) {
1898        Ok(()) => IoMaxReport::enforced(bytes_per_sec),
1899        // The scope directory is deliberately not named: `--explain-scope` writes into a throwaway
1900        // cgroup and a launch writes into the session's, so including it would make the two
1901        // reports differ for no reason a reader could act on — and
1902        // `session_start.effective_grants` has to be the byte-for-byte object `--explain-scope`
1903        // prints. The `MAJ:MIN` and the errno are what identify the failure.
1904        Err(error) => IoMaxReport::unavailable(
1905            bytes_per_sec,
1906            format!("writing `{value}` to the scope's io.max: {error}"),
1907        ),
1908    }
1909}
1910
1911/// The `MAJ:MIN` an `io.max` line has to name for the filesystem behind `path`, or why that
1912/// filesystem has no device a ceiling can name.
1913///
1914/// `st_dev` is not the answer on its own. Two things stand between a path and a device the block
1915/// layer accepts:
1916///
1917/// * A filesystem the kernel gives an anonymous `dev_t` — btrfs, overlayfs, tmpfs, every FUSE
1918///   mount — has major `0`, which names no device. `/proc/self/mountinfo` records what the
1919///   covering mount was mounted from, and where that source is a block device node its `st_rdev`
1920///   is the device the filesystem's I/O actually reaches.
1921/// * `blkcg` binds a limit to a request queue and only a whole disk carries one, so the block
1922///   layer answers `ENODEV` for a partition — which is what `st_dev` reports for the common case
1923///   of a filesystem on `/dev/sda1` or `/dev/nvme0n1p7`. [`whole_disk_device`] takes the last hop.
1924///
1925/// A filesystem with no block device behind it at all carries no I/O ceiling, and the `Err` says
1926/// which mount and what it is backed by. That is a measurement of the resolution, not a guess
1927/// about the write: there is no device for a write to name.
1928#[cfg(target_os = "linux")]
1929fn io_max_device(path: &Path) -> Result<(u64, u64), String> {
1930    use std::os::linux::fs::MetadataExt;
1931
1932    let Some(existing) = nearest_existing_ancestor(path) else {
1933        return Err(format!(
1934            "could not stat {} or any ancestor of it to find its backing device",
1935            path.display()
1936        ));
1937    };
1938    let metadata = existing.metadata().map_err(|error| {
1939        format!(
1940            "could not stat {} to find its backing device: {error}",
1941            existing.display()
1942        )
1943    })?;
1944    let (major, minor) = device_major_minor(metadata.st_dev());
1945    let (major, minor) = if major == 0 {
1946        mount_source_device(existing)?
1947    } else {
1948        (major, minor)
1949    };
1950    Ok(whole_disk_device(major, minor))
1951}
1952
1953/// The block device the mount covering `path` was mounted from.
1954///
1955/// Reached only for a filesystem whose `st_dev` is anonymous. `path` must exist, so that
1956/// canonicalizing it yields the path the kernel's own mount lookup would take — a mount point
1957/// reached through a symlink matches nothing in `mountinfo`.
1958#[cfg(target_os = "linux")]
1959fn mount_source_device(path: &Path) -> Result<(u64, u64), String> {
1960    use std::os::unix::fs::{FileTypeExt, MetadataExt};
1961
1962    let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1963    let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(|error| {
1964        format!(
1965            "reading /proc/self/mountinfo to find the device behind {}: {error}",
1966            resolved.display()
1967        )
1968    })?;
1969    let Some(mount) = mount_covering(&mountinfo, &resolved) else {
1970        return Err(format!(
1971            "no mount in /proc/self/mountinfo covers {}, so its backing device is unknown",
1972            resolved.display()
1973        ));
1974    };
1975    match std::fs::metadata(&mount.source) {
1976        Ok(metadata) if metadata.file_type().is_block_device() => {
1977            Ok(device_major_minor(metadata.rdev()))
1978        }
1979        _ => Err(format!(
1980            "the filesystem mounted at {} is backed by `{}`, which is not a block device, so no \
1981             io.max ceiling can name one",
1982            mount.mount_point.display(),
1983            mount.source.display()
1984        )),
1985    }
1986}
1987
1988/// `major:minor` of the whole disk carrying `major:minor`, which is itself where it is not a
1989/// partition.
1990///
1991/// A partition has a `partition` attribute in sysfs and sits inside its disk's directory, so the
1992/// parent's `dev` is the disk. Every failure to read sysfs answers with the device as given: the
1993/// write that follows is what decides, and reporting a device the block layer then refuses is a
1994/// better outcome than reporting a device nothing was tried against.
1995#[cfg(target_os = "linux")]
1996fn whole_disk_device(major: u64, minor: u64) -> (u64, u64) {
1997    let device = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
1998    if !device.join("partition").exists() {
1999        return (major, minor);
2000    }
2001    std::fs::read_to_string(device.join("../dev"))
2002        .ok()
2003        .and_then(|contents| parse_dev_line(&contents))
2004        .unwrap_or((major, minor))
2005}
2006
2007/// The `MAJ:MIN` on a sysfs `dev` attribute.
2008#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2009fn parse_dev_line(contents: &str) -> Option<(u64, u64)> {
2010    let (major, minor) = contents.trim().split_once(':')?;
2011    Some((major.parse().ok()?, minor.parse().ok()?))
2012}
2013
2014/// One `/proc/self/mountinfo` line, reduced to the two fields that identify a filesystem's
2015/// backing device.
2016#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2017#[derive(Debug, PartialEq, Eq)]
2018struct MountEntry {
2019    /// Where the filesystem is mounted, as the kernel spells it.
2020    mount_point: PathBuf,
2021    /// What it was mounted from: a block device node for an ordinary disk filesystem, and a bare
2022    /// name such as `tmpfs` for one with nothing behind it.
2023    source: PathBuf,
2024}
2025
2026/// The mount `path` lands in: the entry whose mount point is the longest prefix of it.
2027///
2028/// Later lines win a tie. `mountinfo` is in mount order, so a filesystem mounted over an earlier
2029/// one at the same point is the one a lookup reaches.
2030#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2031fn mount_covering(mountinfo: &str, path: &Path) -> Option<MountEntry> {
2032    let mut best: Option<(usize, MountEntry)> = None;
2033    for line in mountinfo.lines() {
2034        let Some(entry) = parse_mount_entry(line) else {
2035            continue;
2036        };
2037        if !path.starts_with(&entry.mount_point) {
2038            continue;
2039        }
2040        let depth = entry.mount_point.components().count();
2041        if best.as_ref().is_none_or(|(best, _)| depth >= *best) {
2042            best = Some((depth, entry));
2043        }
2044    }
2045    best.map(|(_, entry)| entry)
2046}
2047
2048/// Mount point and source out of one `mountinfo` line.
2049///
2050/// The optional-fields run is terminated by a lone `-`, which no field can contain: `mountinfo`
2051/// escapes space, tab, newline and backslash as octal, so splitting on `" - "` is unambiguous.
2052#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2053fn parse_mount_entry(line: &str) -> Option<MountEntry> {
2054    let (mounted, backing) = line.split_once(" - ")?;
2055    let mount_point = mounted.split_whitespace().nth(4)?;
2056    let source = backing.split_whitespace().nth(1)?;
2057    Some(MountEntry {
2058        mount_point: unescape_octal(mount_point),
2059        source: unescape_octal(source),
2060    })
2061}
2062
2063/// Decode `mountinfo`'s `\NNN` octal escapes, byte-wise, so a mount point containing a space or a
2064/// non-ASCII byte compares equal to the path the kernel reports for it.
2065#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2066fn unescape_octal(field: &str) -> PathBuf {
2067    use std::os::unix::ffi::OsStringExt;
2068
2069    let bytes = field.as_bytes();
2070    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
2071    let mut index = 0;
2072    while index < bytes.len() {
2073        let escape = bytes[index] == b'\\'
2074            && index + 3 < bytes.len()
2075            && bytes[index + 1..index + 4]
2076                .iter()
2077                .all(|byte| (b'0'..=b'7').contains(byte));
2078        if escape {
2079            let digits = &field[index + 1..index + 4];
2080            out.push(u8::from_str_radix(digits, 8).expect("three octal digits"));
2081            index += 4;
2082        } else {
2083            out.push(bytes[index]);
2084            index += 1;
2085        }
2086    }
2087    PathBuf::from(std::ffi::OsString::from_vec(out))
2088}
2089
2090/// The closest ancestor of `path` that exists, which is `path` itself where it does.
2091///
2092/// The backing device is a property of the filesystem, not of the leaf, so an ancestor's answer
2093/// is the answer for a path that has not been created yet — the state `--explain-scope` finds the
2094/// would-be workdir in.
2095#[cfg(target_os = "linux")]
2096fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
2097    let mut candidate = Some(path);
2098    while let Some(current) = candidate {
2099        if current.exists() {
2100            return Some(current);
2101        }
2102        candidate = current.parent();
2103    }
2104    None
2105}
2106
2107#[cfg(all(test, target_os = "linux"))]
2108pub(crate) static DELEGATED_BASE_RESOLUTION_SPAWNS: std::sync::atomic::AtomicUsize =
2109    std::sync::atomic::AtomicUsize::new(0);
2110
2111/// Resolves [`delegated_base`] on a `murmur-cgroup-base` thread of its own, so the systemd
2112/// `StartTransientUnit` request, the wait for the kernel to move this process, the delegation
2113/// probe and the controller enable overlap the rest of staging instead of delaying
2114/// [`prepare_scope`]. A [`prepare_scope`] call that arrives mid-resolution blocks on the same lock
2115/// and finds the base cached. A failed resolution caches nothing, so [`prepare_scope`] resolves
2116/// again and reports the same reason; either way systemd is asked at most once per process. A
2117/// failed spawn leaves the whole resolution to [`prepare_scope`]. Off Linux nothing is spawned.
2118///
2119/// Callers start it after the last fork they make before [`prepare_scope`]. The resolution moves
2120/// this process into its own scope and possibly into `<base>/murmur-supervisor`, and a child
2121/// forked meanwhile stays in the cgroup the process held at the fork, where it can occupy the base
2122/// whose `cgroup.subtree_control` the resolution writes.
2123#[cfg(all(test, target_os = "linux"))]
2124pub(crate) fn resolve_delegated_base_in_background() {
2125    resolve_delegated_base_in_background_with(&[]);
2126}
2127
2128/// The guest-worker processes that move with this process: into its transient scope and into
2129/// `<base>/murmur-supervisor`. Registered before the resolution starts and removed once it has
2130/// run.
2131#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2132static COMPANION_PIDS: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());
2133
2134#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2135fn companion_pids() -> Vec<u32> {
2136    COMPANION_PIDS
2137        .lock()
2138        .unwrap_or_else(std::sync::PoisonError::into_inner)
2139        .clone()
2140}
2141
2142/// Waits for the delegated-base resolution [`resolve_delegated_base_in_background_with`] started,
2143/// or runs it here, and returns its refusal: the reason [`prepare_scope`] would refuse the
2144/// session's scope with, for `E-RUN-012`. Once this returns `Ok`, this process is in the cgroup it
2145/// will stay in (its own transient scope, or the delegated root's supervisor leaf), so a child
2146/// forked after it is created there. Staging calls it for a capsule that needs a scope before it
2147/// creates any session folder or forks a guest worker, whose spawn also forks the session
2148/// namespace's holder processes. Off Linux there is nothing to wait for.
2149pub(crate) fn await_delegated_base() -> Result<(), String> {
2150    #[cfg(target_os = "linux")]
2151    {
2152        #[cfg(test)]
2153        if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
2154            return Err("forced: delegation refused by the test seam".to_string());
2155        }
2156        delegated_base()?;
2157    }
2158    Ok(())
2159}
2160
2161/// The delegated-base resolution described above, with `companions` (this session's guest-worker
2162/// pids, children of this process forked before this call) moving wherever this process moves.
2163pub(crate) fn resolve_delegated_base_in_background_with(companions: &[u32]) {
2164    {
2165        let mut registered = COMPANION_PIDS
2166            .lock()
2167            .unwrap_or_else(std::sync::PoisonError::into_inner);
2168        for pid in companions {
2169            if !registered.contains(pid) {
2170                registered.push(*pid);
2171            }
2172        }
2173    }
2174    #[cfg(target_os = "linux")]
2175    {
2176        #[cfg(test)]
2177        DELEGATED_BASE_RESOLUTION_SPAWNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2178        let companions = companions.to_vec();
2179        let _ = std::thread::Builder::new()
2180            .name("murmur-cgroup-base".to_string())
2181            .spawn(move || {
2182                let _ = delegated_base();
2183                // This session's workers have moved, or never will: a later session's
2184                // resolution must not find their pids, which the kernel may hand to an unrelated
2185                // process once they exit.
2186                COMPANION_PIDS
2187                    .lock()
2188                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2189                    .retain(|pid| !companions.contains(pid));
2190            });
2191    }
2192}
2193
2194/// The delegated cgroup root this process creates its scopes under, resolved once.
2195///
2196/// [`probe_delegation`] derives the root from `/proc/self/cgroup`, but establishing the first
2197/// scope may move this process into `<root>/murmur-supervisor` to vacate the root — cgroup v2
2198/// forbids a cgroup from both holding processes and enabling controllers for its children. Once
2199/// that move has happened the process's own cgroup is no longer the delegated root, so
2200/// re-deriving it per call mistakes the leaf for the root and nests one level deeper every time,
2201/// until the nested leaf has no delegated controllers at all and every further session is
2202/// refused. Any process that opens a second session — concurrently or in sequence — hits this.
2203///
2204/// Resolving once, under a lock, keeps every scope a sibling directly under the real root, and
2205/// makes concurrent first-callers agree on that root instead of racing to relocate each other.
2206/// Only success is cached: a host that is not delegated must keep reporting so on every attempt.
2207#[cfg(target_os = "linux")]
2208fn delegated_base() -> Result<PathBuf, CgroupError> {
2209    static BASE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2210
2211    // A poisoned lock guards no invariant worth honouring here: the cache is either populated or
2212    // it is not, and a caller that panicked cannot have left it half-written.
2213    let mut cached = BASE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2214    if let Some(base) = cached.as_ref() {
2215        return Ok(base.clone());
2216    }
2217
2218    // Best-effort: relocate into our own scope before probing. On failure, the probe below runs
2219    // against the inherited cgroup — the correct fallback on a host with no systemd to ask.
2220    move_self_into_own_systemd_scope();
2221
2222    let base = probe_delegation()?;
2223    enable_controllers(&base)?;
2224    *cached = Some(base.clone());
2225    Ok(base)
2226}
2227
2228/// Ask the systemd user manager for a freshly created, delegated transient scope holding this
2229/// process, so the cgroup this runtime builds under is one it is the only occupant of.
2230///
2231/// Best-effort and non-fatal: every failure here (no systemd, no session bus, policy denial, a
2232/// migration that never lands) leaves the process exactly where it was — the state
2233/// [`probe_delegation`] handles. Never let a failed optimisation become a launch refusal.
2234///
2235/// Runs at most once per process — a scope is a property of the process, not the session, and
2236/// [`delegated_base`] retries on failure but caches only success, so the guard has to live here.
2237#[cfg(target_os = "linux")]
2238fn move_self_into_own_systemd_scope() {
2239    static ATTEMPTED: std::sync::Once = std::sync::Once::new();
2240    ATTEMPTED.call_once(|| {
2241        let _ = request_transient_scope(&transient_scope_unit_name());
2242    });
2243}
2244
2245/// Name of the transient scope unit to ask systemd for.
2246///
2247/// `murmur-` keeps the operator-facing convention the scope *directories* already use
2248/// ([`scope_dir_name`]), so `systemctl --user list-units 'murmur-*.scope'` finds the runtime's
2249/// units the same way a `murmur-*` glob finds its cgroup directories. The pid and a per-process
2250/// serial keep it unique against a recycled pid whose earlier unit systemd has not yet collected.
2251#[cfg(target_os = "linux")]
2252fn transient_scope_unit_name() -> String {
2253    static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2254    let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2255    format!("murmur-{}-{serial}.scope", std::process::id())
2256}
2257
2258/// The D-Bus half of [`move_self_into_own_systemd_scope`], plus confirmation that the kernel
2259/// actually moved us.
2260#[cfg(target_os = "linux")]
2261fn request_transient_scope(unit: &str) -> Result<(), String> {
2262    // `zbus::block_on` drives the connection on the calling thread and panics or deadlocks inside
2263    // another runtime's task; run it on a thread of its own so a caller inside tokio is safe.
2264    let owned_unit = unit.to_string();
2265    std::thread::Builder::new()
2266        .name("murmur-cgroup-scope".to_string())
2267        .spawn(move || start_transient_scope(&owned_unit))
2268        .map_err(|error| format!("could not spawn the D-Bus thread: {error}"))?
2269        .join()
2270        .map_err(|_| "the D-Bus thread panicked".to_string())??;
2271
2272    await_scope_membership(unit)
2273}
2274
2275/// This process, then every companion still alive: what the transient scope is started with.
2276#[cfg(target_os = "linux")]
2277fn scope_pids() -> Vec<u32> {
2278    let mut pids = vec![std::process::id()];
2279    pids.extend(
2280        companion_pids()
2281            .into_iter()
2282            .filter(|pid| Path::new(&format!("/proc/{pid}")).exists()),
2283    );
2284    pids
2285}
2286
2287/// `StartTransientUnit` on the systemd user manager: create `unit` as a scope containing this
2288/// process, with its cgroup subtree delegated to us.
2289///
2290/// The call is
2291/// `org.freedesktop.systemd1.Manager.StartTransientUnit(in s name, in s mode, in a(sv) properties,
2292/// in a(sa(sv)) aux)` on `/org/freedesktop/systemd1` at the well-known name
2293/// `org.freedesktop.systemd1`, over the **session** bus (`$DBUS_SESSION_BUS_ADDRESS`, falling back
2294/// to `$XDG_RUNTIME_DIR/bus`) so the unit lands in this user's manager and needs no privilege.
2295/// The properties are the same set `systemd-run --user --scope -p Delegate=yes` sends:
2296///
2297/// * `PIDs` (`au`) — this process, which is what makes systemd *move* it rather than fork a new one,
2298///   then its guest workers ([`scope_pids`]).
2299/// * `Delegate` (`b`) — hand the scope's subtree to us, controllers and all, and stop managing it.
2300/// * `CollectMode` (`s`) — `inactive-or-failed`, so the unit is garbage-collected when this
2301///   process exits rather than lingering as a failed unit an operator has to reset.
2302/// * `Description` (`s`) — what `systemctl --user list-units` shows beside the unit.
2303#[cfg(target_os = "linux")]
2304fn start_transient_scope(unit: &str) -> Result<(), String> {
2305    use zbus::Variant;
2306
2307    let properties: Vec<(&str, Variant<'_>)> = vec![
2308        (
2309            "Description",
2310            Variant::from("Murmur capsule subprocess scope"),
2311        ),
2312        ("PIDs", Variant::from(scope_pids())),
2313        ("Delegate", Variant::from(true)),
2314        ("CollectMode", Variant::from("inactive-or-failed")),
2315    ];
2316    // No auxiliary units: a scope has none.
2317    let aux: Vec<(&str, Vec<(&str, Variant<'_>)>)> = Vec::new();
2318
2319    zbus::block_on(async {
2320        let connection = zbus::Connection::session()
2321            .await
2322            .map_err(|error| format!("no systemd session bus: {error}"))?;
2323        connection
2324            .call_method(
2325                Some("org.freedesktop.systemd1"),
2326                "/org/freedesktop/systemd1",
2327                Some("org.freedesktop.systemd1.Manager"),
2328                "StartTransientUnit",
2329                // "fail" rather than "replace": a name collision means something is wrong with
2330                // our assumption of uniqueness, and displacing an unrelated unit is never the fix.
2331                &(unit, "fail", properties, aux),
2332            )
2333            .await
2334            .map(|_| ())
2335            .map_err(|error| format!("systemd refused to start {unit}: {error}"))
2336    })
2337}
2338
2339/// Wait until `/proc/self/cgroup` actually reports us inside `unit`'s cgroup.
2340///
2341/// `StartTransientUnit` returns once the job is *enqueued*, not once the move has happened, and
2342/// every later step (the delegation probe, the controller enable, the scope directory) depends on
2343/// where this process actually lives — so migration is confirmed from `/proc/self/cgroup`, the
2344/// same source the rest of this module reads, rather than inferred from a job object path.
2345///
2346/// The 2s deadline mirrors [`enable_controllers`]: both wait on systemd/kernel bookkeeping that
2347/// normally completes in a millisecond or two, and a wait that long means something structural is
2348/// wrong. A timeout here is cheap — the caller ignores it and falls back to probing the inherited
2349/// cgroup. The step is 1ms because this wait sits on the launch path of every capsule that needs a
2350/// scope, and the move lands a millisecond or two after `StartTransientUnit` returns, so every
2351/// step the loop oversleeps is added to the launch.
2352#[cfg(target_os = "linux")]
2353fn await_scope_membership(unit: &str) -> Result<(), String> {
2354    let deadline = std::time::Instant::now() + Duration::from_secs(2);
2355    loop {
2356        let current = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
2357        if let Some(path) = parse_unified_cgroup_path(&current) {
2358            if path.rsplit('/').next() == Some(unit) {
2359                return Ok(());
2360            }
2361        }
2362        if std::time::Instant::now() >= deadline {
2363            return Err(format!(
2364                "systemd accepted the request for {unit} but this process is not in it"
2365            ));
2366        }
2367        std::thread::sleep(Duration::from_millis(1));
2368    }
2369}
2370
2371/// Locate the delegated cgroup v2 directory this process may create children under, confirming
2372/// the controllers this runtime needs are actually available there.
2373///
2374/// Returns the base directory on success, or a message naming what is missing — that message is
2375/// what reaches the operator inside
2376/// [`crate::errors::RuntimeError::CgroupDelegationUnavailable`], so it names the delegation
2377/// requirement rather than only the failed syscall.
2378#[cfg(target_os = "linux")]
2379fn probe_delegation() -> Result<PathBuf, CgroupError> {
2380    let self_cgroup = std::fs::read_to_string("/proc/self/cgroup").map_err(|error| {
2381        CgroupError::io(format!("could not read /proc/self/cgroup: {error}"), &error)
2382    })?;
2383    let relative = parse_unified_cgroup_path(&self_cgroup).ok_or_else(|| {
2384        "this host has no cgroup v2 unified hierarchy (no `0::` line in /proc/self/cgroup); \
2385         cgroup v1-only and hybrid hosts cannot be delegated a v2 scope"
2386            .to_string()
2387    })?;
2388
2389    let mount = std::fs::read_to_string("/proc/mounts")
2390        .map(|contents| parse_cgroup2_mount(&contents))
2391        .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
2392
2393    let base = ascend_out_of_supervisor_leaves(
2394        PathBuf::from(mount).join(relative.trim_start_matches('/')),
2395    );
2396    if !base.is_dir() {
2397        return Err(format!(
2398            "delegated cgroup directory {} does not exist",
2399            base.display()
2400        )
2401        .into());
2402    }
2403
2404    let controllers =
2405        std::fs::read_to_string(base.join("cgroup.controllers")).map_err(|error| {
2406            CgroupError::io(
2407                format!(
2408                    "could not read {}/cgroup.controllers: {error}",
2409                    base.display()
2410                ),
2411                &error,
2412            )
2413        })?;
2414    let unavailable = missing_controllers(&controllers, REQUIRED_CONTROLLERS);
2415    if !unavailable.is_empty() {
2416        return Err(format!(
2417            "cgroup controllers [{}] are not delegated to {}; the unit `mur` runs under needs \
2418             `Delegate=yes` (or `Delegate=memory pids cpu io`)",
2419            unavailable.join(", "),
2420            base.display()
2421        )
2422        .into());
2423    }
2424
2425    // The probe must confirm we can actually *create* a child here, not just read the directory:
2426    // a readable cgroup with no write permission is the exact shape of an undelegated host.
2427    // Named per-pid so two `mur` processes probing concurrently never race on the same path.
2428    let probe_dir = base.join(format!("murmur-delegation-probe-{}", std::process::id()));
2429    let _ = std::fs::remove_dir(&probe_dir);
2430    std::fs::create_dir(&probe_dir).map_err(|error| {
2431        CgroupError::io(
2432            format!(
2433                "cannot create a child cgroup under {} ({error}); the unit `mur` runs under needs \
2434                 `Delegate=yes` for memory, pids, cpu and io",
2435                base.display()
2436            ),
2437            &error,
2438        )
2439    })?;
2440    let _ = std::fs::remove_dir(&probe_dir);
2441
2442    Ok(base)
2443}
2444
2445/// Enable `memory`, `pids`, `cpu` (and `io`, best-effort) in the delegated base's
2446/// `cgroup.subtree_control` so a child cgroup can carry their limit files.
2447///
2448/// If the base still holds this process directly, the write is refused (`EBUSY`) by cgroup v2's
2449/// "no internal processes" rule; the recovery is to move this process into its own leaf and try
2450/// once more. That relocation stays entirely inside the delegated subtree.
2451#[cfg(target_os = "linux")]
2452fn enable_controllers(base: &Path) -> Result<(), CgroupError> {
2453    let subtree_control = base.join("cgroup.subtree_control");
2454    let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
2455
2456    // `io` is requested opportunistically: a host that delegates only the three required
2457    // controllers still gets a fully-enforced scope, just without `io.max`.
2458    let available = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
2459    let mut wanted: Vec<&str> = REQUIRED_CONTROLLERS.to_vec();
2460    if available.split_whitespace().any(|c| c == "io") {
2461        wanted.push("io");
2462    }
2463
2464    let missing = missing_controllers(&current, &wanted);
2465    if missing.is_empty() {
2466        return Ok(());
2467    }
2468    let directive = missing
2469        .iter()
2470        .map(|controller| format!("+{controller}"))
2471        .collect::<Vec<_>>()
2472        .join(" ");
2473
2474    if std::fs::write(&subtree_control, format!("{directive}\n")).is_ok() {
2475        return Ok(());
2476    }
2477
2478    move_self_to_supervisor_leaf(base)?;
2479
2480    // Stepping aside vacates *this* process, and cgroup v2 refuses the write while the base holds
2481    // any task at all. When several murmur processes share a base — a test binary and the `mur`
2482    // children it spawns, or two capsules launched together — each is independently discovering
2483    // it must step aside, so the first write after our own move can still hit `EBUSY` on peers
2484    // that have not moved yet. Retrying briefly lets the group finish draining; a peer that wins
2485    // the race and enables the controllers first is just as good, which is why the loop re-reads
2486    // `subtree_control` rather than only retrying the write.
2487    //
2488    // Bounded, because a task that never leaves is a genuine misconfiguration and has to surface
2489    // as one rather than hang the launch.
2490    let deadline = std::time::Instant::now() + Duration::from_secs(2);
2491    // Left uninitialised: every path that reaches the deadline check has just gone through the
2492    // write's `Err` arm, so the fallback below only ever describes a real failure.
2493    let mut last_error;
2494    loop {
2495        let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
2496        if missing_controllers(&current, &wanted).is_empty() {
2497            return Ok(());
2498        }
2499        match std::fs::write(&subtree_control, format!("{directive}\n")) {
2500            Ok(()) => return Ok(()),
2501            Err(error) => last_error = Some(error),
2502        }
2503        if std::time::Instant::now() >= deadline {
2504            break;
2505        }
2506        std::thread::sleep(Duration::from_millis(20));
2507    }
2508
2509    Err(CgroupError {
2510        errno: last_error.as_ref().and_then(std::io::Error::raw_os_error),
2511        message: format!(
2512            "could not enable controllers [{}] in {} ({}); the unit `mur` runs under needs \
2513             `Delegate=yes` for memory, pids, cpu and io",
2514            missing.join(", "),
2515            subtree_control.display(),
2516            last_error.map_or_else(|| "still occupied".to_string(), |error| error.to_string()),
2517        ),
2518    })
2519}
2520
2521/// Walk up out of any `murmur-supervisor` leaves the path ends in.
2522///
2523/// The leaf is where a murmur process steps aside so controllers can be enabled on the delegated
2524/// base. Every process it then spawns *inherits that leaf as its own cgroup* — so a `mur`
2525/// subprocess reading `/proc/self/cgroup` would take the leaf for its delegated base and step
2526/// aside again inside it, one level deeper per generation, until the innermost leaf has no
2527/// controllers delegated to it at all and every launch below that point is refused.
2528///
2529/// Ascending here makes every generation agree on the same real base, which is also what lets
2530/// them share one scope parent rather than fragmenting the delegation.
2531#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2532fn ascend_out_of_supervisor_leaves(mut base: PathBuf) -> PathBuf {
2533    while base.file_name().and_then(|name| name.to_str()) == Some(SUPERVISOR_LEAF) {
2534        base.pop();
2535    }
2536    base
2537}
2538
2539/// Move this process into `<base>/murmur-supervisor`, creating it if needed.
2540#[cfg(target_os = "linux")]
2541fn move_self_to_supervisor_leaf(base: &Path) -> Result<(), CgroupError> {
2542    let leaf = base.join(SUPERVISOR_LEAF);
2543    if let Err(error) = std::fs::create_dir(&leaf) {
2544        if error.kind() != std::io::ErrorKind::AlreadyExists {
2545            return Err(CgroupError::io(
2546                format!(
2547                    "could not create {} to vacate the delegated cgroup: {error}",
2548                    leaf.display()
2549                ),
2550                &error,
2551            ));
2552        }
2553    }
2554    std::fs::write(
2555        leaf.join("cgroup.procs"),
2556        format!("{}\n", std::process::id()),
2557    )
2558    .map_err(|error| {
2559        CgroupError::io(
2560            format!(
2561                "could not move this process into {} to vacate the delegated cgroup: {error}",
2562                leaf.display()
2563            ),
2564            &error,
2565        )
2566    })?;
2567    // The guest workers vacate the base with this process. A worker that already exited has
2568    // nothing to move.
2569    for pid in companion_pids() {
2570        let _ = std::fs::write(leaf.join("cgroup.procs"), format!("{pid}\n"));
2571    }
2572    Ok(())
2573}
2574
2575/// `open(path, O_WRONLY | O_CLOEXEC)`.
2576///
2577/// `CLOEXEC` matters twice over: the descriptor is written from `pre_exec` (still open at that
2578/// point) and must not survive into the exec'd binary, where it would hand a capsule the ability
2579/// to move arbitrary pids into its own cgroup.
2580#[cfg(target_os = "linux")]
2581#[allow(unsafe_code)]
2582fn open_write_only(path: &Path) -> std::io::Result<std::os::fd::OwnedFd> {
2583    use std::os::unix::fs::OpenOptionsExt;
2584
2585    std::fs::OpenOptions::new()
2586        .write(true)
2587        .custom_flags(libc::O_CLOEXEC)
2588        .open(path)
2589        .map(std::os::fd::OwnedFd::from)
2590}
2591
2592/// Whether this host can hand the runtime a delegated cgroup v2 scope — the precondition
2593/// [`prepare_scope`] fails closed on.
2594///
2595/// Test support, not a runtime code path: `mur` never asks this question, it asks systemd for a
2596/// scope and reports [`crate::errors::RuntimeError::CgroupDelegationUnavailable`] when it cannot
2597/// get one. Tests that launch a capsule tripping [`requires_process_bounding`] call this to skip
2598/// visibly instead, because a containerised runner has no delegable subtree to give and the
2599/// refusal there says nothing about the code under test. What CI therefore cannot cover is
2600/// covered by hand: `docs/content/reference/resource-limits-manual-verification.md`.
2601///
2602/// Probed by actually creating a transient `--user` scope with `Delegate=yes`, since a
2603/// `systemd-run` binary on `PATH` says nothing about a reachable user manager — and the user
2604/// manager is the half a runner is missing. Off Linux there are no cgroups, this module is inert
2605/// and nothing is ever refused, so the answer is `true`.
2606pub fn cgroup_delegation_available() -> bool {
2607    #[cfg(not(target_os = "linux"))]
2608    {
2609        true
2610    }
2611
2612    #[cfg(target_os = "linux")]
2613    {
2614        // Answered through `delegated_base`, the same call `prepare_scope` makes, so the answer
2615        // cannot disagree with what a launch does on this host. A separate `systemd-run` probe
2616        // answers a different question and is wrong in both directions: a user manager hands out
2617        // a scope on a host whose controllers `enable_controllers` cannot delegate, and the
2618        // inherited cgroup carries delegated controllers on a host with no reachable session bus.
2619        delegated_base().is_ok()
2620    }
2621}
2622
2623/// Whether the filesystem behind `path` has a block device an `io.max` ceiling can name.
2624///
2625/// Test support beside [`cgroup_delegation_available`], for the half of the `io.max` scenario
2626/// that needs a ceiling to actually apply: a checkout on tmpfs, on overlayfs or on a FUSE mount
2627/// has no device for the block layer to bind a limit to, and `enforced` is unreachable there
2628/// however well the host delegates cgroups. Answered through [`io_max_device`], the same
2629/// resolution a launch performs, so a test cannot stand down on a host the launch would have
2630/// satisfied. Off Linux there is no `io.max` at all.
2631#[must_use]
2632pub fn io_max_device_available(path: &Path) -> bool {
2633    #[cfg(not(target_os = "linux"))]
2634    {
2635        let _ = path;
2636        false
2637    }
2638
2639    #[cfg(target_os = "linux")]
2640    {
2641        io_max_device(path).is_ok()
2642    }
2643}
2644
2645/// Whether a test that launches a subprocess-capable capsule must stand down on this host,
2646/// printing the blocker when it must.
2647///
2648/// Both blockers are asked through the same calls a launch makes -- `delegated_base` for the
2649/// cgroup scope, a fresh `HostProbe`'s network attempt for the network namespace -- so the gate and
2650/// the launch cannot reach different conclusions about the host. A containerised CI runner
2651/// typically clears the first and fails the second.
2652pub fn skip_without_host_support(test_name: &str) -> bool {
2653    if crate::network_namespace::skip_without_egress_namespace(test_name) {
2654        return true;
2655    }
2656    if !cgroup_delegation_available() {
2657        crate::runtime_err!(
2658            "[SKIP-HOST] {test_name}: this host cannot delegate a cgroup v2 scope, so a capsule \
2659             that can spawn subprocesses refuses to launch with E-RUN-012 before anything \
2660             this test observes happens -- see {}",
2661            murmur_artifact::docs_reference_url!("resource-limits/#platform-behavior")
2662        );
2663        return true;
2664    }
2665    false
2666}
2667
2668#[cfg(test)]
2669mod tests {
2670    use super::*;
2671    use crate::types::CapabilityPolicy;
2672
2673    /// The wire spelling every consumer reads: `--explain-scope --json`, `mur doctor` and
2674    /// `session_start.effective_grants.io_max` in `trace.jsonl`. Kebab-case, not the `Debug`
2675    /// rendering, which is free to change.
2676    #[test]
2677    fn io_max_status_wire_names_are_kebab_case() {
2678        let spellings: Vec<String> = [
2679            IoMaxStatus::Enforced,
2680            IoMaxStatus::Unavailable,
2681            IoMaxStatus::NotRequired,
2682            IoMaxStatus::NotProbed,
2683        ]
2684        .into_iter()
2685        .map(|status| {
2686            serde_json::to_value(status)
2687                .unwrap()
2688                .as_str()
2689                .unwrap()
2690                .to_string()
2691        })
2692        .collect();
2693        assert_eq!(
2694            spellings,
2695            vec!["enforced", "unavailable", "not-required", "not-probed"]
2696        );
2697    }
2698
2699    /// A report nobody filled in must claim nothing, which is what makes it safe to construct one
2700    /// at stage time and overwrite it once the scope exists.
2701    #[test]
2702    fn an_unfilled_report_claims_nothing() {
2703        let report = IoMaxReport::default();
2704        assert_eq!(report.status, IoMaxStatus::NotProbed);
2705        assert_eq!(report.declared_bytes_per_sec, 0);
2706        assert_eq!(report.reason, None);
2707        assert_eq!(io_max_warning(&report, false), None);
2708    }
2709
2710    /// `reason` is the only optional key, and it is present for every status but `Enforced`.
2711    #[test]
2712    fn only_a_failed_write_omits_nothing_and_a_successful_one_explains_nothing() {
2713        let enforced = serde_json::to_value(IoMaxReport::enforced(1_000)).unwrap();
2714        assert_eq!(enforced["status"], "enforced");
2715        assert_eq!(enforced["declared_bytes_per_sec"], 1_000);
2716        assert!(
2717            enforced.get("reason").is_none(),
2718            "an applied ceiling has nothing to explain: {enforced}"
2719        );
2720
2721        let unavailable =
2722            serde_json::to_value(IoMaxReport::unavailable(1_000, "no io controller")).unwrap();
2723        assert_eq!(unavailable["status"], "unavailable");
2724        assert_eq!(unavailable["reason"], "no io controller");
2725    }
2726
2727    /// `W-SEC-021` fires on the one status that is a shortfall, and its message names the
2728    /// declared ceiling, the host's reason, and the limits that *are* still enforced —
2729    /// without which "io.max did not apply" reads as "this session is unbounded".
2730    #[test]
2731    fn the_warning_fires_only_where_a_declared_ceiling_did_not_apply() {
2732        assert_eq!(io_max_warning(&IoMaxReport::enforced(4_096), true), None);
2733        assert_eq!(
2734            io_max_warning(&IoMaxReport::not_required(4_096, NO_SCOPE_REASON), true),
2735            None
2736        );
2737
2738        let unavailable = IoMaxReport::unavailable(4_096, "device 0:24 is not a block device");
2739        let (code, message) =
2740            io_max_warning(&unavailable, true).expect("an unapplied ceiling must be reported");
2741        assert_eq!(code, murmur_artifact::W_SEC_021);
2742        assert!(message.contains("4096 bytes/s"), "message was: {message}");
2743        assert!(
2744            message.contains("device 0:24 is not a block device"),
2745            "message was: {message}"
2746        );
2747        assert!(
2748            message.contains("memory.max, pids.max and cpu.max are still enforced on the scope"),
2749            "message was: {message}"
2750        );
2751    }
2752
2753    /// With no `cgroup_cpu_percent` the scope has no `cpu.max` write, so the warning does not
2754    /// claim one.
2755    #[test]
2756    fn the_warning_names_cpu_max_only_when_a_cpu_cap_is_set() {
2757        let unavailable = IoMaxReport::unavailable(4_096, "no io controller");
2758        let (_, message) =
2759            io_max_warning(&unavailable, false).expect("an unapplied ceiling must be reported");
2760        assert!(
2761            message.contains("memory.max and pids.max are still enforced on the scope"),
2762            "message was: {message}"
2763        );
2764        assert!(!message.contains("cpu.max"), "message was: {message}");
2765    }
2766
2767    /// `--explain-scope` runs before the workdir exists, so the probe has to name the device a
2768    /// launch would use without creating anything.
2769    #[cfg(target_os = "linux")]
2770    #[test]
2771    fn the_device_is_resolved_from_the_nearest_existing_ancestor() {
2772        let temp = tempfile::tempdir().unwrap();
2773        let missing = temp
2774            .path()
2775            .join("workdir")
2776            .join("does")
2777            .join("not")
2778            .join("exist");
2779        assert_eq!(
2780            nearest_existing_ancestor(&missing),
2781            Some(temp.path()),
2782            "an ancestor of a tempdir always exists"
2783        );
2784        assert_eq!(
2785            io_max_device(&missing).ok(),
2786            io_max_device(temp.path()).ok(),
2787            "a path that does not exist yet must resolve to its filesystem's device"
2788        );
2789        assert!(!missing.exists(), "resolving a device must create nothing");
2790    }
2791
2792    /// The mount a path lands in is the deepest one covering it, not the first line that matches
2793    /// — `/` covers every path, and a nested mount is what a lookup actually reaches.
2794    #[test]
2795    fn the_covering_mount_is_the_deepest_one() {
2796        let mountinfo = "\
279725 30 0:29 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p5 rw,subvol=/
279826 25 0:31 / /dev/shm rw,nosuid shared:2 - tmpfs tmpfs rw,inode64
279927 25 0:41 / /space rw,relatime shared:3 - btrfs /dev/nvme0n1p7 rw,subvol=/
2800";
2801        let covering = |path: &str| mount_covering(mountinfo, Path::new(path)).unwrap();
2802
2803        assert_eq!(
2804            covering("/space/project/workdir").source,
2805            PathBuf::from("/dev/nvme0n1p7")
2806        );
2807        assert_eq!(covering("/dev/shm/project").source, PathBuf::from("tmpfs"));
2808        assert_eq!(
2809            covering("/home/someone").source,
2810            PathBuf::from("/dev/nvme0n1p5"),
2811            "a path under no nested mount belongs to the root filesystem"
2812        );
2813        assert_eq!(
2814            covering("/spaceship").source,
2815            PathBuf::from("/dev/nvme0n1p5"),
2816            "mount points match whole path components, not string prefixes"
2817        );
2818    }
2819
2820    /// A filesystem mounted over another at the same point is the one a lookup reaches, so the
2821    /// later line wins.
2822    #[test]
2823    fn a_mount_over_another_at_the_same_point_wins() {
2824        let mountinfo = "\
282525 30 0:29 / / rw shared:1 - ext4 /dev/sda1 rw
282626 25 0:31 / /mnt rw shared:2 - ext4 /dev/sdb1 rw
282727 25 0:32 / /mnt rw shared:3 - ext4 /dev/sdc1 rw
2828";
2829        assert_eq!(
2830            mount_covering(mountinfo, Path::new("/mnt/data"))
2831                .unwrap()
2832                .source,
2833            PathBuf::from("/dev/sdc1")
2834        );
2835    }
2836
2837    /// `mountinfo` escapes the four characters that would break its own field separation, and a
2838    /// mount point that does not decode compares equal to nothing.
2839    #[test]
2840    fn mount_point_escapes_are_decoded() {
2841        let mountinfo = "\
284225 30 0:29 / /media/a\\040disk rw shared:1 - ext4 /dev/sdb1 rw
2843";
2844        let entry = mount_covering(mountinfo, Path::new("/media/a disk/project")).unwrap();
2845        assert_eq!(entry.mount_point, PathBuf::from("/media/a disk"));
2846        assert_eq!(entry.source, PathBuf::from("/dev/sdb1"));
2847        assert_eq!(unescape_octal("no\\134escape"), PathBuf::from("no\\escape"));
2848        assert_eq!(
2849            unescape_octal("\\04"),
2850            PathBuf::from("\\04"),
2851            "a truncated escape is not an escape"
2852        );
2853    }
2854
2855    /// A sysfs `dev` attribute is the only place the parent disk's number comes from.
2856    #[test]
2857    fn sysfs_dev_attributes_are_read_as_major_minor() {
2858        assert_eq!(parse_dev_line("259:0\n"), Some((259, 0)));
2859        assert_eq!(parse_dev_line(""), None);
2860        assert_eq!(parse_dev_line("259"), None);
2861    }
2862
2863    /// The block layer binds an `io.max` entry to a request queue, and only a whole disk carries
2864    /// one — so whatever device this checkout sits on, the resolved one must not be a partition.
2865    /// Naming a partition is the failure that makes `enforced` unreachable on an ordinary host.
2866    #[cfg(target_os = "linux")]
2867    #[test]
2868    fn the_resolved_device_is_never_a_partition() {
2869        let Ok((major, minor)) = io_max_device(Path::new(".")) else {
2870            // A checkout on a filesystem with no block device behind it has no device to check,
2871            // and `write_io_max` reports that rather than naming one.
2872            return;
2873        };
2874        let sysfs = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
2875        assert!(
2876            !sysfs.join("partition").exists(),
2877            "io.max was given the partition {major}:{minor}, which the block layer refuses"
2878        );
2879    }
2880
2881    /// The diagnostic answers the question by exercising it, and leaves nothing behind whichever
2882    /// way it answers.
2883    #[cfg(target_os = "linux")]
2884    #[test]
2885    fn probing_io_max_creates_nothing_that_survives_the_call() {
2886        if skip_without_host_support("probing_io_max_creates_nothing_that_survives_the_call") {
2887            return;
2888        }
2889        let temp = tempfile::tempdir().unwrap();
2890        let probe = probe_scope(
2891            true,
2892            &HostResourceLimits::default(),
2893            &temp.path().join("workdir"),
2894        );
2895        assert_eq!(probe.cgroup_scope, CgroupScopeReport::delegated());
2896        let report = probe.io_max;
2897        assert!(
2898            matches!(
2899                report.status,
2900                IoMaxStatus::Enforced | IoMaxStatus::Unavailable
2901            ),
2902            "a performed probe must report what it performed, got {report:?}"
2903        );
2904
2905        // Counted by name rather than by directory count: this binary's other tests create and
2906        // remove scopes under the same base concurrently, and only `probe_scope` names one
2907        // `explain-scope`.
2908        let leftovers: Vec<String> = std::fs::read_dir(delegated_base().expect("a delegated base"))
2909            .unwrap()
2910            .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
2911            .filter(|name| name.contains("explain-scope"))
2912            .collect();
2913        assert!(
2914            leftovers.is_empty(),
2915            "the throwaway cgroup outlived the probe: {leftovers:?}"
2916        );
2917    }
2918
2919    /// A probe for a capsule that needs no scope attempts nothing and reports the `not-required`
2920    /// answers a launch writes for it, byte for byte.
2921    #[test]
2922    fn a_probe_that_is_not_required_reports_what_a_launch_writes() {
2923        let limits = HostResourceLimits::default();
2924        let probe = probe_scope(false, &limits, Path::new("/nonexistent/workdir"));
2925        let prepared = prepare_scope(false, &limits, "s", Path::new("/nonexistent/workdir"))
2926            .expect("no scope is required");
2927        assert_eq!(probe.cgroup_scope, prepared.cgroup_scope);
2928        assert_eq!(probe.io_max, prepared.io_max);
2929        assert_eq!(probe.cgroup_scope.status, CgroupScopeStatus::NotRequired);
2930        assert_eq!(
2931            serde_json::to_value(&probe.cgroup_scope).unwrap(),
2932            serde_json::json!({"status": "not-required", "reason": NO_CGROUP_SCOPE_REASON})
2933        );
2934    }
2935
2936    /// `unavailable` carries the failing call's errno by name and the reason verbatim; `delegated`
2937    /// carries neither key; the error displays as its message alone, so `E-RUN-012`'s text does
2938    /// not change with the errno carried beside it.
2939    #[test]
2940    fn the_cgroup_scope_report_serializes_each_status() {
2941        let error = CgroupError::io(
2942            "cannot create a child cgroup under /sys/fs/cgroup/x (Read-only file system (os \
2943             error 30))"
2944                .to_string(),
2945            &std::io::Error::from_raw_os_error(libc::EROFS),
2946        );
2947        assert_eq!(error.to_string(), error.message);
2948        assert_eq!(String::from(error.clone()), error.message);
2949        let unavailable = CgroupScopeReport::unavailable(&error);
2950        assert_eq!(
2951            serde_json::to_value(&unavailable).unwrap(),
2952            serde_json::json!({
2953                "status": "unavailable",
2954                "errno": "EROFS",
2955                "reason": error.message,
2956            })
2957        );
2958        assert_eq!(
2959            render_cgroup_scope(&unavailable),
2960            format!(
2961                "cgroup scope: unavailable (EROFS) \u{2014} {}",
2962                error.message
2963            )
2964        );
2965        let delegated = CgroupScopeReport::delegated();
2966        assert_eq!(
2967            serde_json::to_value(&delegated).unwrap(),
2968            serde_json::json!({"status": "delegated"})
2969        );
2970        assert_eq!(render_cgroup_scope(&delegated), "cgroup scope: delegated");
2971        assert_eq!(
2972            serde_json::to_value(CgroupScopeReport::default()).unwrap(),
2973            serde_json::json!({"status": "not-probed"})
2974        );
2975        let no_errno = CgroupScopeReport::unavailable(&CgroupError::from("no v2".to_string()));
2976        assert_eq!(no_errno.errno, None);
2977    }
2978
2979    /// A launch that created its scope records `delegated`; one that needed none records
2980    /// `not-required`.
2981    #[cfg(target_os = "linux")]
2982    #[test]
2983    fn a_prepared_scope_reports_delegated() {
2984        if skip_without_host_support("a_prepared_scope_reports_delegated") {
2985            return;
2986        }
2987        let temp = tempfile::tempdir().unwrap();
2988        let prepared = prepare_scope(
2989            true,
2990            &HostResourceLimits::default(),
2991            "cgroup-scope-report",
2992            &temp.path().join("workdir"),
2993        )
2994        .expect("a delegated host creates the scope");
2995        assert!(prepared.scope.is_some());
2996        assert_eq!(prepared.cgroup_scope, CgroupScopeReport::delegated());
2997    }
2998
2999    #[test]
3000    fn scope_is_required_by_any_route_to_a_subprocess() {
3001        let shell = CapabilityPolicy {
3002            shell_allow: vec!["bash".to_string()],
3003            ..CapabilityPolicy::default()
3004        };
3005        let spawn = CapabilityPolicy {
3006            spawn_allow: vec!["child".to_string()],
3007            ..CapabilityPolicy::default()
3008        };
3009        let install_tool = CapabilityPolicy {
3010            install_tool: vec!["local/tool-*".to_string()],
3011            ..CapabilityPolicy::default()
3012        };
3013        let install_skill = CapabilityPolicy {
3014            install_skill: vec!["local/skill-*".to_string()],
3015            ..CapabilityPolicy::default()
3016        };
3017        let bare = CapabilityPolicy::default();
3018
3019        assert!(requires_process_bounding(&shell));
3020        assert!(requires_process_bounding(&spawn));
3021        assert!(
3022            !requires_process_bounding(&install_tool),
3023            "a pulled tool runs a program only through murmur:process, which obeys shell.allow"
3024        );
3025        assert!(
3026            !requires_process_bounding(&install_skill),
3027            "a pulled skill gains no grant and runs no subprocess"
3028        );
3029        for policy in [&shell, &spawn, &install_tool, &install_skill, &bare] {
3030            assert_eq!(
3031                requires_process_bounding(policy),
3032                crate::runtime::has_subprocess_route_at_staging(policy),
3033                "{policy:?}"
3034            );
3035        }
3036        assert!(
3037            !requires_process_bounding(&bare),
3038            "a WASM-only capsule has no process tree to bound and must not be blocked"
3039        );
3040    }
3041
3042    /// An unconfined session requires a scope only when it can start a subprocess and declares a
3043    /// `cgroup_*` field; a contained one requires one for any subprocess route.
3044    #[test]
3045    fn an_unconfined_session_requires_a_scope_only_for_a_declared_cgroup_field() {
3046        use murmur_artifact::{ContainmentClass, ResourceCapabilities};
3047        let shell = |containment_floor, declared_resources| crate::types::CapabilityPolicy {
3048            shell_allow: vec!["bash".to_string()],
3049            containment_floor,
3050            declared_resources,
3051            ..Default::default()
3052        };
3053        assert!(requires_process_bounding(&shell(
3054            ContainmentClass::Advisory,
3055            ResourceCapabilities::default()
3056        )));
3057        assert!(!requires_process_bounding(&shell(
3058            ContainmentClass::Unconfined,
3059            ResourceCapabilities::default()
3060        )));
3061        assert!(!requires_process_bounding(&shell(
3062            ContainmentClass::Unconfined,
3063            ResourceCapabilities {
3064                max_open_files: Some(256),
3065                workdir_max_bytes: Some(1 << 20),
3066                ..Default::default()
3067            }
3068        )));
3069        for declared in [
3070            ResourceCapabilities {
3071                cgroup_memory_bytes: Some(1 << 30),
3072                ..Default::default()
3073            },
3074            ResourceCapabilities {
3075                cgroup_pids_max: Some(64),
3076                ..Default::default()
3077            },
3078            ResourceCapabilities {
3079                cgroup_cpu_percent: Some(50),
3080                ..Default::default()
3081            },
3082            ResourceCapabilities {
3083                cgroup_io_bytes_per_sec: Some(1 << 20),
3084                ..Default::default()
3085            },
3086        ] {
3087            assert!(
3088                requires_process_bounding(&shell(ContainmentClass::Unconfined, declared)),
3089                "{declared:?}"
3090            );
3091            let bare = crate::types::CapabilityPolicy {
3092                containment_floor: ContainmentClass::Unconfined,
3093                declared_resources: declared,
3094                ..Default::default()
3095            };
3096            assert!(
3097                !requires_process_bounding(&bare),
3098                "no subprocess route, no scope"
3099            );
3100        }
3101
3102        // An unconfined session with no scope says why, without claiming it starts nothing.
3103        let temp = tempfile::tempdir().unwrap();
3104        let prepared = prepare_policy_scope(
3105            &shell(
3106                ContainmentClass::Unconfined,
3107                ResourceCapabilities::default(),
3108            ),
3109            "ses_unconfined",
3110            temp.path(),
3111        )
3112        .expect("an unconfined session with no cgroup field is never refused");
3113        assert!(prepared.scope.is_none());
3114        assert_eq!(prepared.io_max.status, IoMaxStatus::NotRequired);
3115        assert_eq!(
3116            prepared.io_max.reason.as_deref(),
3117            Some(UNCONFINED_NO_SCOPE_REASON)
3118        );
3119    }
3120
3121    /// An unconfined scope writes exactly the `cgroup_*` fields declared, and no swap bound.
3122    #[test]
3123    fn an_unconfined_scope_writes_only_the_declared_fields() {
3124        use murmur_artifact::ResourceCapabilities;
3125        assert!(declared_scope_limits(&ResourceCapabilities::default()).is_empty());
3126        let files = |declared: ResourceCapabilities| -> Vec<(&str, String)> {
3127            declared_scope_limits(&declared)
3128                .into_iter()
3129                .map(|limit| (limit.file, limit.value))
3130                .collect()
3131        };
3132        assert_eq!(
3133            files(ResourceCapabilities {
3134                cgroup_pids_max: Some(64),
3135                max_open_files: Some(256),
3136                ..Default::default()
3137            }),
3138            vec![("pids.max", "64".to_string())]
3139        );
3140        assert_eq!(
3141            files(ResourceCapabilities {
3142                cgroup_memory_bytes: Some(1024),
3143                cgroup_cpu_percent: Some(50),
3144                ..Default::default()
3145            }),
3146            vec![
3147                ("memory.max", "1024".to_string()),
3148                ("cpu.max", format!("50000 {CPU_MAX_PERIOD_US}")),
3149            ]
3150        );
3151    }
3152
3153    /// A capsule with no subprocess capability never needs delegation — on any platform, and
3154    /// notably including Linux, where a blanket requirement would be a disproportionate
3155    /// regression for WASM-only capsules.
3156    #[test]
3157    fn prepare_scope_returns_none_when_bounding_is_not_required() {
3158        let temp = tempfile::tempdir().unwrap();
3159        let scope = prepare_scope(
3160            false,
3161            &HostResourceLimits::default(),
3162            "ses_test",
3163            temp.path(),
3164        )
3165        .expect("a capsule with no subprocess capability must never be refused");
3166        assert!(scope.scope.is_none());
3167        // Not a shortfall and never a warning: there is no process tree for a ceiling to apply to.
3168        assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
3169        assert!(
3170            scope.io_max.reason.is_some(),
3171            "not-required must say which cause"
3172        );
3173        assert_eq!(io_max_warning(&scope.io_max, false), None);
3174    }
3175
3176    /// macOS can never have a cgroup, so a required scope there must be a silent `None` rather
3177    /// than a launch-blocking error.
3178    #[cfg(not(target_os = "linux"))]
3179    #[test]
3180    fn prepare_scope_is_never_an_error_off_linux() {
3181        let temp = tempfile::tempdir().unwrap();
3182        let scope = prepare_scope(
3183            true,
3184            &HostResourceLimits::default(),
3185            "ses_test",
3186            temp.path(),
3187        )
3188        .expect("a non-Linux host must not refuse to launch for a missing cgroup");
3189        assert!(scope.scope.is_none());
3190        assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
3191        assert!(scope
3192            .io_max
3193            .reason
3194            .as_deref()
3195            .expect("not-required off Linux must say cgroups cannot exist here")
3196            .contains("Linux"));
3197    }
3198
3199    /// The point of asking systemd for a scope: a Linux host with a working user session hands
3200    /// out a bounded scope no matter what this process's *inherited* cgroup looked like — an
3201    /// interactive shell's cgroup full of co-resident processes, or the one `cargo test` keeps
3202    /// itself resident in while running this very binary.
3203    #[cfg(target_os = "linux")]
3204    #[test]
3205    #[allow(clippy::print_stdout)]
3206    fn a_required_scope_is_created_whatever_cgroup_this_process_inherited() {
3207        if skip_without_host_support(
3208            "a_required_scope_is_created_whatever_cgroup_this_process_inherited",
3209        ) {
3210            return;
3211        }
3212        let temp = tempfile::tempdir().unwrap();
3213        let prepared = prepare_scope(
3214            true,
3215            &HostResourceLimits::default(),
3216            "ses_selftest",
3217            temp.path(),
3218        )
3219        .expect("a Linux host with a systemd user session must be able to bound a subprocess tree");
3220        let io_max = prepared.io_max.clone();
3221        let scope = prepared
3222            .scope
3223            .expect("a required scope on Linux is never `None`");
3224
3225        for file in ["memory.max", "pids.max"] {
3226            let contents = std::fs::read_to_string(scope.path().join(file))
3227                .unwrap_or_else(|error| panic!("{file} unreadable in the scope: {error}"));
3228            assert!(!contents.trim().is_empty(), "{file} was left unset");
3229            assert_ne!(contents.trim(), "max", "{file} carries no ceiling");
3230        }
3231        // No `cgroup_cpu_percent`, no `cpu.max` write: the scope keeps the kernel's own value.
3232        let cpu_max = std::fs::read_to_string(scope.path().join("cpu.max"))
3233            .unwrap_or_else(|error| panic!("cpu.max unreadable in the scope: {error}"));
3234        assert_eq!(cpu_max.trim(), "max 100000");
3235        match std::fs::read_to_string(scope.path().join("memory.swap.max")) {
3236            Ok(contents) => {
3237                assert_eq!(
3238                    contents.trim(),
3239                    SCOPE_SWAP_MAX,
3240                    "memory.swap.max was not bounded"
3241                );
3242                assert_eq!(contents.trim(), "0");
3243                println!(
3244                    "memory.swap.max in the created scope reads {}",
3245                    contents.trim()
3246                );
3247            }
3248            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3249                assert_eq!(probe_swap_control(), SwapControl::Absent);
3250                println!("this kernel exposes no memory.swap.max; swap is not bounded here");
3251            }
3252            Err(error) => panic!("memory.swap.max unreadable in the scope: {error}"),
3253        }
3254
3255        // Whatever the host did with `io.max`, the scope says which of the two it was, and never
3256        // `NotProbed`: a scope that exists has had the write attempted against it.
3257        assert!(
3258            matches!(
3259                io_max.status,
3260                IoMaxStatus::Enforced | IoMaxStatus::Unavailable
3261            ),
3262            "a created scope must report a performed io.max write, got {io_max:?}"
3263        );
3264        assert_eq!(
3265            io_max.declared_bytes_per_sec,
3266            HostResourceLimits::default().cgroup_io_bytes_per_sec
3267        );
3268        if io_max.status == IoMaxStatus::Unavailable {
3269            assert!(io_max.reason.is_some(), "unavailable must carry a reason");
3270            assert!(io_max_warning(&io_max, false).is_some());
3271        } else {
3272            assert_eq!(io_max.reason, None);
3273            assert_eq!(io_max_warning(&io_max, false), None);
3274        }
3275    }
3276
3277    /// A declared `cgroup_cpu_percent` reaches the kernel as the quota it names.
3278    #[cfg(target_os = "linux")]
3279    #[test]
3280    fn a_declared_cpu_cap_is_written_to_the_scope() {
3281        if skip_without_host_support("a_declared_cpu_cap_is_written_to_the_scope") {
3282            return;
3283        }
3284        let temp = tempfile::tempdir().unwrap();
3285        let limits = HostResourceLimits {
3286            cgroup_cpu_percent: Some(50),
3287            ..HostResourceLimits::default()
3288        };
3289        let scope = prepare_scope(true, &limits, "ses_cpucap", temp.path())
3290            .expect(
3291                "a Linux host with a systemd user session must be able to bound a subprocess tree",
3292            )
3293            .scope
3294            .expect("a required scope on Linux is never `None`");
3295        let cpu_max = std::fs::read_to_string(scope.path().join("cpu.max"))
3296            .unwrap_or_else(|error| panic!("cpu.max unreadable in the scope: {error}"));
3297        assert_eq!(cpu_max.trim(), "50000 100000");
3298    }
3299
3300    /// On a kernel with no `cgroup.kill`, killing a scope freezes it, signals every member and
3301    /// thaws it until it is empty — reaching a `setsid` process and a grandchild as surely as the
3302    /// one-write kill does.
3303    #[cfg(target_os = "linux")]
3304    #[test]
3305    #[allow(clippy::print_stdout)]
3306    fn killing_a_scope_without_cgroup_kill_empties_it() {
3307        if skip_without_host_support("killing_a_scope_without_cgroup_kill_empties_it") {
3308            return;
3309        }
3310        let temp = tempfile::tempdir().unwrap();
3311        let scope = prepare_scope(
3312            true,
3313            &HostResourceLimits::default(),
3314            "ses_kill_fallback",
3315            temp.path(),
3316        )
3317        .expect("a delegated scope")
3318        .scope
3319        .expect("a required scope on Linux is never None");
3320        let enforcement = crate::sandbox::ShellEnforcement {
3321            cgroup_scope: Some(Arc::clone(&scope)),
3322            ..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
3323        };
3324        let mut command = std::process::Command::new("bash");
3325        command
3326            .args([
3327                "-c",
3328                "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
3329            ])
3330            .stdout(std::process::Stdio::null())
3331            .stderr(std::process::Stdio::null());
3332        crate::sandbox::attach_process_limits(&mut command, &enforcement);
3333        let mut child = command.spawn().expect("bash spawns into the scope");
3334
3335        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3336        while scope.member_pids().len() < 3 {
3337            assert!(
3338                std::time::Instant::now() < deadline,
3339                "the scope never held bash and both sleepers: {:?}",
3340                scope.member_pids()
3341            );
3342            std::thread::sleep(std::time::Duration::from_millis(20));
3343        }
3344        let members = scope.member_pids();
3345
3346        FORCE_KILL_FALLBACK.with(|flag| flag.set(true));
3347        let killed = scope.kill_all();
3348        FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
3349        let emptied = scope.wait_until_empty(std::time::Duration::from_secs(2));
3350        let status = child.wait().expect("bash is reaped");
3351        println!(
3352            "members before: {members:?}; fallback kill: {killed:?}; empty: {emptied}; \
3353             cgroup.procs after: {:?}; bash: {status}",
3354            scope.member_pids()
3355        );
3356        killed.expect("the fallback kill succeeds");
3357        assert!(emptied, "the scope still holds {:?}", scope.member_pids());
3358        assert_eq!(
3359            std::os::unix::process::ExitStatusExt::signal(&status),
3360            Some(libc::SIGKILL)
3361        );
3362    }
3363
3364    /// A required scope holding a `bash` that started a `setsid` sleeper and a plain one, once all
3365    /// three are in it. `fallback` makes the scope's watcher use the freeze-and-signal loop.
3366    #[cfg(target_os = "linux")]
3367    fn scope_with_sleepers(
3368        session: &str,
3369        fallback: bool,
3370    ) -> (Arc<CgroupScope>, std::process::Child) {
3371        let temp = tempfile::tempdir().unwrap();
3372        FORCE_KILL_FALLBACK.with(|flag| flag.set(fallback));
3373        let scope = prepare_scope(true, &HostResourceLimits::default(), session, temp.path());
3374        FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
3375        let scope = scope
3376            .expect("a delegated scope")
3377            .scope
3378            .expect("a required scope on Linux is never None");
3379        let enforcement = crate::sandbox::ShellEnforcement {
3380            cgroup_scope: Some(Arc::clone(&scope)),
3381            ..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
3382        };
3383        let mut command = std::process::Command::new("bash");
3384        command
3385            .args([
3386                "-c",
3387                "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
3388            ])
3389            .stdout(std::process::Stdio::null())
3390            .stderr(std::process::Stdio::null());
3391        crate::sandbox::attach_process_limits(&mut command, &enforcement);
3392        let child = command.spawn().expect("bash spawns into the scope");
3393        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3394        while scope.member_pids().len() < 3 {
3395            assert!(
3396                std::time::Instant::now() < deadline,
3397                "the scope never held bash and both sleepers: {:?}",
3398                scope.member_pids()
3399            );
3400            std::thread::sleep(std::time::Duration::from_millis(20));
3401        }
3402        (scope, child)
3403    }
3404
3405    #[cfg(target_os = "linux")]
3406    fn watcher_of(scope: &CgroupScope) -> &ScopeWatcher {
3407        scope
3408            .watcher
3409            .as_ref()
3410            .expect("a scope on Linux has a watcher")
3411    }
3412
3413    /// Closing the lifeline is what the kernel does when the runtime dies: the watcher empties the
3414    /// scope with no `Drop`, no `kill_all` and no call from the runtime.
3415    #[cfg(target_os = "linux")]
3416    fn assert_a_cut_lifeline_empties_the_scope(test_name: &str, fallback: bool) {
3417        if skip_without_host_support(test_name) {
3418            return;
3419        }
3420        let (scope, mut child) = scope_with_sleepers(test_name, fallback);
3421        let members = scope.member_pids();
3422        watcher_of(&scope).cut_lifeline();
3423        assert!(
3424            scope.wait_until_empty(std::time::Duration::from_secs(5)),
3425            "the watcher left {:?} of {members:?} in the scope",
3426            scope.member_pids()
3427        );
3428        for pid in members {
3429            assert!(
3430                !Path::new(&format!("/proc/{pid}")).exists()
3431                    || std::fs::read_to_string(format!("/proc/{pid}/stat"))
3432                        .is_ok_and(|stat| stat.contains(") Z ")),
3433                "{pid} is still running"
3434            );
3435        }
3436        let status = child.wait().expect("bash is reaped");
3437        assert_eq!(
3438            std::os::unix::process::ExitStatusExt::signal(&status),
3439            Some(libc::SIGKILL)
3440        );
3441    }
3442
3443    #[cfg(target_os = "linux")]
3444    #[test]
3445    fn scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes() {
3446        assert_a_cut_lifeline_empties_the_scope(
3447            "scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes",
3448            false,
3449        );
3450    }
3451
3452    #[cfg(target_os = "linux")]
3453    #[test]
3454    fn scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes() {
3455        assert_a_cut_lifeline_empties_the_scope(
3456            "scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes",
3457            true,
3458        );
3459    }
3460
3461    /// The watcher sits outside the scope it kills, in a process group of its own, holding
3462    /// `/dev/null` on 0–2, its lifeline's read end and the scope's control files — none of this
3463    /// process's descriptors.
3464    #[cfg(target_os = "linux")]
3465    #[test]
3466    #[allow(unsafe_code)]
3467    fn scope_watcher_holds_only_its_own_descriptors_outside_the_scope() {
3468        if skip_without_host_support(
3469            "scope_watcher_holds_only_its_own_descriptors_outside_the_scope",
3470        ) {
3471            return;
3472        }
3473        // A descriptor of this process's own that the watcher must not hold.
3474        let own = tempfile::tempfile().unwrap();
3475        let own_target = std::fs::read_link(format!(
3476            "/proc/self/fd/{}",
3477            std::os::fd::AsRawFd::as_raw_fd(&own)
3478        ))
3479        .unwrap();
3480        let (scope, mut child) = scope_with_sleepers("ses_watcher_fds", false);
3481        let pid = watcher_of(&scope).pid;
3482
3483        let fd_dir = format!("/proc/{pid}/fd");
3484        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3485        let targets = loop {
3486            let targets: Vec<(String, PathBuf)> = std::fs::read_dir(&fd_dir)
3487                .unwrap()
3488                .filter_map(Result::ok)
3489                .filter_map(|entry| {
3490                    let target = std::fs::read_link(entry.path()).ok()?;
3491                    Some((entry.file_name().to_string_lossy().into_owned(), target))
3492                })
3493                .collect();
3494            let settled = targets.iter().all(|(_, target)| {
3495                target == Path::new("/dev/null")
3496                    || target.to_string_lossy().starts_with("pipe:")
3497                    || target.starts_with(scope.path())
3498            });
3499            if settled || std::time::Instant::now() >= deadline {
3500                break targets;
3501            }
3502            std::thread::sleep(std::time::Duration::from_millis(20));
3503        };
3504        let mut numbers: Vec<u32> = targets
3505            .iter()
3506            .map(|(name, _)| name.parse().unwrap())
3507            .collect();
3508        numbers.sort_unstable();
3509        assert!(numbers.starts_with(&[0, 1, 2]), "{targets:?}");
3510        for (name, target) in &targets {
3511            assert_ne!(target, &own_target, "fd {name}");
3512            if ["0", "1", "2"].contains(&name.as_str()) {
3513                assert_eq!(target, Path::new("/dev/null"), "fd {name}");
3514            } else {
3515                assert!(
3516                    target.to_string_lossy().starts_with("pipe:")
3517                        || target.starts_with(scope.path()),
3518                    "fd {name} -> {}",
3519                    target.display()
3520                );
3521            }
3522        }
3523        let pipes = targets
3524            .iter()
3525            .filter(|(_, target)| target.to_string_lossy().starts_with("pipe:"))
3526            .count();
3527        assert_eq!(pipes, 1, "the lifeline is its only pipe: {targets:?}");
3528
3529        assert!(!scope.member_pids().contains(&pid));
3530        // SAFETY: `getpgid` takes an integer and dereferences nothing.
3531        assert_eq!(
3532            unsafe { libc::getpgid(pid) },
3533            pid,
3534            "a process group of its own"
3535        );
3536
3537        scope.kill_all().unwrap();
3538        child.wait().unwrap();
3539    }
3540
3541    /// Dropping the scope reaps its watcher: a second wait finds no such child.
3542    #[cfg(target_os = "linux")]
3543    #[test]
3544    #[allow(unsafe_code)]
3545    fn scope_watcher_is_reaped_when_the_scope_drops() {
3546        if skip_without_host_support("scope_watcher_is_reaped_when_the_scope_drops") {
3547            return;
3548        }
3549        let (scope, mut child) = scope_with_sleepers("ses_watcher_reaped", false);
3550        let pid = watcher_of(&scope).pid;
3551        let path = scope.path().to_path_buf();
3552        drop(Arc::try_unwrap(scope).expect("the test holds the only reference"));
3553        child.wait().unwrap();
3554        let mut status = 0;
3555        // SAFETY: `pid` is an integer and `status` a live local.
3556        let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
3557        assert_eq!(waited, -1);
3558        assert_eq!(
3559            std::io::Error::last_os_error().raw_os_error(),
3560            Some(libc::ECHILD)
3561        );
3562        assert!(!path.exists(), "the scope directory is removed");
3563    }
3564
3565    /// Swap is bounded at zero whatever the manifest declares, beside a `memory.max` that is the
3566    /// declared ceiling itself.
3567    #[test]
3568    fn the_scope_limits_bound_swap_at_zero() {
3569        let declared = HostResourceLimits {
3570            cgroup_memory_bytes: 268_435_456,
3571            cgroup_cpu_percent: Some(50),
3572            ..HostResourceLimits::default()
3573        };
3574        for limits in [HostResourceLimits::default(), declared] {
3575            let writes: Vec<(&str, String)> = scope_limits(&limits)
3576                .into_iter()
3577                .map(|limit| (limit.file, limit.value))
3578                .collect();
3579            assert!(
3580                writes.contains(&("memory.swap.max", "0".to_string())),
3581                "writes were {writes:?}"
3582            );
3583            assert!(writes.contains(&("memory.max", limits.cgroup_memory_bytes.to_string())));
3584            assert_eq!(
3585                &writes.iter().map(|(file, _)| *file).collect::<Vec<_>>()[..3],
3586                ["memory.max", "memory.swap.max", "pids.max"]
3587            );
3588        }
3589        let absent_ok: Vec<&str> = scope_limits(&HostResourceLimits::default())
3590            .into_iter()
3591            .filter(|limit| limit.absent_ok)
3592            .map(|limit| limit.file)
3593            .collect();
3594        assert_eq!(absent_ok, ["memory.swap.max"]);
3595    }
3596
3597    /// A silent manifest writes no `cpu.max`; a declared `cgroup_cpu_percent` writes it last, as a
3598    /// quota against the kernel's 100 000 µs period.
3599    #[test]
3600    fn the_scope_limits_write_cpu_max_only_for_a_declared_cap() {
3601        let files: Vec<&str> = scope_limits(&HostResourceLimits::resolve(None))
3602            .into_iter()
3603            .map(|limit| limit.file)
3604            .collect();
3605        assert_eq!(files, ["memory.max", "memory.swap.max", "pids.max"]);
3606
3607        let capped = HostResourceLimits {
3608            cgroup_cpu_percent: Some(50),
3609            ..HostResourceLimits::default()
3610        };
3611        let writes = scope_limits(&capped);
3612        assert_eq!(writes.len(), 4);
3613        assert_eq!(writes[3].file, "cpu.max");
3614        assert_eq!(writes[3].value, "50000 100000");
3615        assert!(!writes[3].absent_ok);
3616    }
3617
3618    #[test]
3619    fn active_swap_areas_are_the_lines_below_the_header() {
3620        let header = "Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n";
3621        assert_eq!(count_swap_areas(header), 0);
3622        assert_eq!(count_swap_areas(""), 0);
3623        assert_eq!(
3624            count_swap_areas(&format!(
3625                "{header}/swapfile                               file\t\t33554428\t\t0\t\t-2\n"
3626            )),
3627            1
3628        );
3629    }
3630
3631    /// The unit name systemd is asked for stays inside the `murmur-*` convention operators
3632    /// already use to find this runtime's cgroup directories, and stays unique per request.
3633    #[cfg(target_os = "linux")]
3634    #[test]
3635    fn transient_scope_unit_names_are_murmur_prefixed_and_unique() {
3636        let first = transient_scope_unit_name();
3637        let second = transient_scope_unit_name();
3638        assert!(first.starts_with(&format!("murmur-{}-", std::process::id())));
3639        assert!(first.ends_with(".scope"));
3640        assert_ne!(first, second);
3641    }
3642
3643    #[test]
3644    fn unified_cgroup_path_is_read_from_the_zero_hierarchy_line() {
3645        let v2_only = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope\n";
3646        assert_eq!(
3647            parse_unified_cgroup_path(v2_only).as_deref(),
3648            Some("/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope")
3649        );
3650
3651        let hybrid = "12:pids:/user.slice\n11:memory:/user.slice\n0::/user.slice/session.scope\n";
3652        assert_eq!(
3653            parse_unified_cgroup_path(hybrid).as_deref(),
3654            Some("/user.slice/session.scope")
3655        );
3656
3657        let v1_only = "12:pids:/user.slice\n11:memory:/user.slice\n";
3658        assert_eq!(parse_unified_cgroup_path(v1_only), None);
3659    }
3660
3661    #[test]
3662    fn cgroup2_mount_falls_back_to_the_conventional_path() {
3663        let mounts = "cgroup2 /custom/cgroup cgroup2 rw,nsdelegate 0 0\n";
3664        assert_eq!(parse_cgroup2_mount(mounts), "/custom/cgroup");
3665        assert_eq!(
3666            parse_cgroup2_mount("proc /proc proc rw 0 0\n"),
3667            "/sys/fs/cgroup"
3668        );
3669    }
3670
3671    #[test]
3672    fn missing_controllers_reports_only_what_is_absent() {
3673        assert_eq!(
3674            missing_controllers("cpu memory", REQUIRED_CONTROLLERS),
3675            vec!["pids".to_string()]
3676        );
3677        assert!(missing_controllers("cpu io memory pids", REQUIRED_CONTROLLERS).is_empty());
3678        assert_eq!(
3679            missing_controllers("", REQUIRED_CONTROLLERS),
3680            vec!["memory".to_string(), "pids".to_string(), "cpu".to_string()]
3681        );
3682    }
3683
3684    #[test]
3685    fn event_counters_parse_the_named_key_only() {
3686        let memory_events = "low 0\nhigh 0\nmax 12\noom 3\noom_kill 2\n";
3687        assert_eq!(parse_event_counter(memory_events, "oom_kill"), 2);
3688        assert_eq!(parse_event_counter(memory_events, "max"), 12);
3689        assert_eq!(parse_event_counter("max 7\n", "max"), 7);
3690        assert_eq!(parse_event_counter("", "oom_kill"), 0);
3691        assert_eq!(parse_event_counter("oom_kill\n", "oom_kill"), 0);
3692    }
3693
3694    /// Attribution keys on the delta, not the absolute counter: a session-cumulative total would
3695    /// mis-attribute every later call once any earlier one had been OOM-killed.
3696    #[test]
3697    fn attribution_uses_the_delta_across_one_call() {
3698        let before = CgroupEventCounters {
3699            oom_kill: 1,
3700            pids_max: 4,
3701        };
3702        assert_eq!(before.attribution_since(before), None);
3703        assert_eq!(
3704            CgroupEventCounters {
3705                oom_kill: 2,
3706                pids_max: 4
3707            }
3708            .attribution_since(before),
3709            Some("cgroup_memory_bytes")
3710        );
3711        assert_eq!(
3712            CgroupEventCounters {
3713                oom_kill: 1,
3714                pids_max: 5
3715            }
3716            .attribution_since(before),
3717            Some("cgroup_pids_max")
3718        );
3719    }
3720
3721    /// A scope with no directory behind it: occupancy needs no kernel, and `Drop` skips the
3722    /// `rmdir` for an empty path.
3723    fn scope_without_kernel() -> CgroupScope {
3724        CgroupScope {
3725            path: PathBuf::new(),
3726            procs_fd: None,
3727            occupancy: Arc::default(),
3728            #[cfg(target_os = "linux")]
3729            watcher: None,
3730        }
3731    }
3732
3733    #[test]
3734    fn occupancy_alone_is_exclusive() {
3735        let scope = scope_without_kernel();
3736        let call = scope.enter();
3737        assert!(call.exclusive());
3738    }
3739
3740    #[test]
3741    fn occupancy_entering_beside_another_is_not_exclusive() {
3742        let scope = scope_without_kernel();
3743        let first = scope.enter();
3744        let second = scope.enter();
3745        assert!(!second.exclusive(), "someone was already inside");
3746        drop(first);
3747        assert!(
3748            !second.exclusive(),
3749            "the sibling leaving does not clean the window it shared"
3750        );
3751    }
3752
3753    #[test]
3754    fn occupancy_someone_entering_during_the_window_makes_it_shared() {
3755        let scope = scope_without_kernel();
3756        let first = scope.enter();
3757        assert!(first.exclusive());
3758        drop(scope.enter());
3759        assert!(
3760            !first.exclusive(),
3761            "a call that entered and left inside this window could have moved the counters"
3762        );
3763    }
3764
3765    #[test]
3766    fn occupancy_drop_releases_the_scope() {
3767        let scope = scope_without_kernel();
3768        drop(scope.enter());
3769        let next = scope.enter();
3770        assert!(
3771            next.exclusive(),
3772            "an earlier call that has left does not share this window"
3773        );
3774    }
3775
3776    /// The guard is owned: it moves to a demoted command's thread and keeps the scope occupied
3777    /// there after the scope's borrow has ended.
3778    #[test]
3779    fn occupancy_guard_outlives_its_borrow_on_another_thread() {
3780        let scope = scope_without_kernel();
3781        let demoted = scope.enter();
3782        let held = std::thread::spawn(move || demoted);
3783        let demoted = held.join().unwrap();
3784        let foreground = scope.enter();
3785        assert!(!foreground.exclusive());
3786        assert!(!demoted.exclusive());
3787    }
3788
3789    #[test]
3790    fn scope_dir_name_is_sanitized_and_never_empty() {
3791        assert!(scope_dir_name("ses_abc123").starts_with("murmur-ses_abc123-"));
3792        assert!(scope_dir_name("../escape").starts_with("murmur-.._escape-"));
3793        assert!(!scope_dir_name("../escape").contains('/'));
3794        assert!(scope_dir_name("").starts_with("murmur-"));
3795    }
3796
3797    /// A subprocess inherits its parent's cgroup, so a `mur` spawned by a runtime that already
3798    /// stepped aside starts life *inside* the supervisor leaf. Reading that as its delegated base
3799    /// would nest another leaf inside it, one per generation, until nothing is delegated.
3800    #[test]
3801    fn delegated_base_ascends_out_of_inherited_supervisor_leaves() {
3802        let base = PathBuf::from("/sys/fs/cgroup/user.slice/app.slice/mur.scope");
3803
3804        assert_eq!(ascend_out_of_supervisor_leaves(base.clone()), base);
3805        assert_eq!(
3806            ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF)),
3807            base
3808        );
3809        assert_eq!(
3810            ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF).join(SUPERVISOR_LEAF)),
3811            base,
3812            "a base already nested by an earlier build must still resolve to the real root"
3813        );
3814    }
3815
3816    /// A background resolution racing direct callers leaves them agreeing, whichever reaches the
3817    /// lock first, on a delegated host and on one that is not: every caller gets the one base, or
3818    /// each fails afresh with the same reason rather than inheriting a cached error or a half-made
3819    /// base.
3820    #[cfg(target_os = "linux")]
3821    #[test]
3822    fn background_resolution_agrees_with_a_direct_one() {
3823        resolve_delegated_base_in_background();
3824        let first = delegated_base();
3825        let second = delegated_base();
3826        assert_eq!(first, second);
3827    }
3828
3829    /// Two scopes must never share a directory even when handed the same id: `Drop` removes the
3830    /// scope by path, so a collision would have one session delete the cgroup still bounding
3831    /// another's subprocess tree.
3832    #[test]
3833    fn scope_dir_name_is_unique_per_call_for_one_id() {
3834        let first = scope_dir_name("p");
3835        let second = scope_dir_name("p");
3836        assert_ne!(
3837            first, second,
3838            "same plan id must not reuse a scope directory"
3839        );
3840    }
3841
3842    #[test]
3843    fn pid_is_formatted_without_allocating() {
3844        let mut buf = [0u8; 24];
3845        let len = format_pid(4321, &mut buf);
3846        assert_eq!(&buf[..len], b"4321\n");
3847
3848        let len = format_pid(7, &mut buf);
3849        assert_eq!(&buf[..len], b"7\n");
3850    }
3851
3852    #[test]
3853    fn device_numbers_use_the_wide_glibc_encoding() {
3854        // 8:0 (`/dev/sda`) encodes as 0x800 under the 12+20 bit layout.
3855        assert_eq!(device_major_minor(0x800), (8, 0));
3856        // 259:3 (`nvme0n1p3`) needs the wide major field, which the legacy 8+8 layout truncates.
3857        assert_eq!(device_major_minor((259 << 8) | 3), (259, 3));
3858    }
3859}