Skip to main content

capsule_runtime/
cgroup.rs

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