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