Skip to main content

capsule_runtime/
cgroup.rs

1//! Linux cgroup v2 scoping for a capsule's whole native-subprocess tree.
2//!
3//! `resources::apply_hard_rlimits` bounds each spawned process individually. That is necessary
4//! and not sufficient: `RLIMIT_NPROC` is a **per-uid** ceiling, so a tree of distinct,
5//! short-lived processes that fork and exit faster than the count is observed slips past it,
6//! and no rlimit bounds *aggregate* memory or CPU across a tree at all. A cgroup v2 scope does
7//! exactly what rlimits structurally cannot: `pids.max`, `memory.max` and `cpu.max` apply to
8//! every task in the scope together, and the kernel enforces them at fork/allocate time rather
9//! than after the fact.
10//!
11//! ## Install requirement: systemd user cgroup delegation
12//!
13//! Creating a cgroup requires write access to a cgroup directory, and this runtime has none by
14//! default. The usual alternative — start as root, create the cgroup, then drop privileges —
15//! is not available here: nothing in this codebase ever runs as root or drops privileges (there
16//! is no setuid or privilege-drop path anywhere in `capsule-runtime` or `murmur-cli`), so there
17//! is no privileged phase to create a cgroup from. The mechanism that fits how this runtime
18//! actually runs is **systemd user delegation**: the user unit or scope `mur` runs under carries
19//! `Delegate=yes` for `memory pids cpu io`, which hands the unprivileged user write access to
20//! its own cgroup subtree. This is the same mechanism rootless Docker and Podman use for the
21//! identical problem. The exact unit configuration is in
22//! `docs/content/reference/resource-limits-manual-verification.md`.
23//!
24//! ## Self-relocation before probing an inherited cgroup
25//!
26//! cgroup v2 refuses to enable controllers for a cgroup's children while that cgroup still holds
27//! tasks, so a bounded child can only be built under a base this process is the *sole* occupant
28//! of. An interactive shell's cgroup holds the shell, its pane scope, and everything else the
29//! user is running; under `cargo test`, `cargo` stays resident beside every test binary. Stepping
30//! aside into [`SUPERVISOR_LEAF`] moves only this process, so co-residency alone makes the base
31//! unusable regardless of delegation.
32//!
33//! [`move_self_into_own_systemd_scope`] asks the systemd *user* manager over the session bus for
34//! a freshly created transient scope carrying `Delegate=yes`, moving this process into a
35//! brand-new cgroup — a sibling of whatever it left, not a child — that it is the sole occupant
36//! of by construction. The step-aside logic below then always succeeds, since nothing else is
37//! ever in that scope to contend with.
38//!
39//! The request is best-effort and silent: with no systemd, no session bus, or a policy denial,
40//! the runtime probes whatever cgroup it inherited, exactly as it does when this step is skipped.
41//!
42//! ## Fail closed, but only where the threat exists
43//!
44//! On Linux, a capsule that can spawn *any* native subprocess and cannot be given a cgroup
45//! refuses to launch (`RuntimeError::CgroupDelegationUnavailable`) rather than running that
46//! subprocess tree unbounded. A capsule that declares no subprocess capability at all needs no
47//! scope and is never blocked — there is no process tree to bound. On macOS, cgroups cannot
48//! exist at all, so this module is inert and the runtime falls back to rlimits-only enforcement
49//! with a `W-SEC-010` warning; refusing every launch on the primary development platform for a
50//! kernel feature that platform will never have would be a policy, not a protection.
51//!
52//! ## Cross-platform shape
53//!
54//! Following `sandbox.rs`: the types here are defined unconditionally and stay type-checked and
55//! unit-testable on macOS, with the *behavior* gated internally by `cfg(target_os = "linux")`
56//! and cross-platform-dead surface marked `#[cfg_attr(not(target_os = "linux"), allow(dead_code))]`.
57//! Nothing is hidden behind a module-level `cfg`.
58
59use std::path::{Path, PathBuf};
60use std::sync::Arc;
61use std::time::Duration;
62
63use crate::resources::HostResourceLimits;
64
65/// Where the unified cgroup v2 hierarchy is mounted on a conventional host. Only used as the
66/// fallback when `/proc/mounts` does not name a `cgroup2` mount explicitly.
67#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
68const DEFAULT_CGROUP2_MOUNT: &str = "/sys/fs/cgroup";
69
70/// The `cpu.max` period, in microseconds. The kernel's own default; the quota this module
71/// writes is expressed against it (`cgroup_cpu_percent * 1000` µs of every 100 000 µs).
72#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
73const CPU_MAX_PERIOD_US: u64 = 100_000;
74
75/// Controllers that must be delegated and settable. `io` is deliberately absent: it is
76/// best-effort (see [`CgroupScope::apply_io_max`]).
77#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
78const REQUIRED_CONTROLLERS: &[&str] = &["memory", "pids", "cpu"];
79
80/// Name of the leaf cgroup the runtime moves *itself* into when its delegated base still holds
81/// processes. cgroup v2's "no internal processes" rule forbids enabling controllers in a
82/// cgroup's `subtree_control` while that cgroup directly contains tasks, so the manager has to
83/// step down into its own leaf first — the same move rootless Podman/Docker make.
84#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
85const SUPERVISOR_LEAF: &str = "murmur-supervisor";
86
87/// A cgroup v2 scope created for one capsule session, holding that session's whole native
88/// subprocess tree.
89///
90/// Constructed once per launch (never per subprocess) so that `pids.max` and `memory.max` bound
91/// the tree *in aggregate* — a per-call scope would let N sequential shell calls each spend the
92/// full budget, which is precisely the aggregate case rlimits already fail to cover.
93#[derive(Debug)]
94pub(crate) struct CgroupScope {
95    /// Absolute path of the scope directory, e.g.
96    /// `/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope/murmur-ses_abc123`.
97    path: PathBuf,
98    /// `cgroup.procs`, opened write-only in the **parent** before any fork. The forked child's
99    /// `pre_exec` closure only ever writes its own pid to this already-open descriptor — it
100    /// never performs a path lookup, matching how `sandbox.rs` pre-opens its Landlock fds rather
101    /// than opening paths inside `pre_exec`.
102    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
103    procs_fd: Option<std::os::fd::OwnedFd>,
104}
105
106/// Snapshot of the kernel-maintained kill/denial counters on a scope.
107///
108/// Read before and after a subprocess runs, so attribution rests on the *delta* for that call
109/// rather than on a session-cumulative total that any earlier call could have moved.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub(crate) struct CgroupEventCounters {
112    /// `memory.events`' `oom_kill` — tasks the kernel killed for exceeding `memory.max`.
113    pub(crate) oom_kill: u64,
114    /// `pids.events`' `max` — forks refused for exceeding `pids.max`.
115    pub(crate) pids_max: u64,
116}
117
118impl CgroupEventCounters {
119    /// The named limit this delta unambiguously attributes to, or `None`.
120    ///
121    /// Both counters are maintained by the kernel and incremented only by the condition they
122    /// name, so a nonzero delta is evidence rather than inference — the property that makes
123    /// these two safe to report where a bare `SIGKILL` is not (see
124    /// [`crate::resources::limit_from_signal`]).
125    pub(crate) fn attribution_since(&self, before: Self) -> Option<&'static str> {
126        if self.oom_kill > before.oom_kill {
127            return Some("cgroup_memory_bytes");
128        }
129        if self.pids_max > before.pids_max {
130            return Some("cgroup_pids_max");
131        }
132        None
133    }
134}
135
136/// Decide-and-create entry point, called once per launch before any WASM is instantiated.
137///
138/// * `required == false` (the capsule declares no way to spawn a native subprocess) → `Ok(None)`
139///   on every platform. There is no process tree to bound, so demanding host configuration here
140///   would be a regression for WASM-only capsules with nothing to show for it.
141/// * non-Linux → `Ok(None)` always. Never an error: cgroups are structurally impossible there,
142///   not a host misconfiguration.
143/// * Linux and required → probe delegation and create the scope; `Err(reason)` if the host
144///   cannot delegate one, which the caller turns into
145///   [`crate::errors::RuntimeError::CgroupDelegationUnavailable`].
146pub(crate) fn prepare_scope(
147    required: bool,
148    limits: &HostResourceLimits,
149    session_id: &str,
150    workdir: &Path,
151) -> Result<Option<Arc<CgroupScope>>, String> {
152    if !required {
153        return Ok(None);
154    }
155    #[cfg(target_os = "linux")]
156    {
157        let scope = CgroupScope::create(limits, session_id, workdir)?;
158        Ok(Some(Arc::new(scope)))
159    }
160    #[cfg(not(target_os = "linux"))]
161    {
162        let _ = (limits, session_id, workdir);
163        Ok(None)
164    }
165}
166
167/// Does this policy give the capsule any way to run a native subprocess at all?
168///
169/// The exact condition the Linux fail-closed launch refusal keys on: `capabilities.shell.allow`,
170/// `capabilities.spawn.allow`, or a declared artifact whose implementation is native (passed in
171/// by the caller, which is the only place the installed-artifact list is in hand).
172pub(crate) fn requires_process_bounding(
173    policy: &crate::types::CapabilityPolicy,
174    has_native_artifact: bool,
175) -> bool {
176    !policy.shell_allow.is_empty() || !policy.spawn_allow.is_empty() || has_native_artifact
177}
178
179impl CgroupScope {
180    /// Absolute path of this scope's directory.
181    // The scope's own path is read only by this module's tests, which assert the limit files
182    // systemd wrote under it; the launch path carries the descriptors it needs instead.
183    #[allow(dead_code)]
184    pub(crate) fn path(&self) -> &Path {
185        &self.path
186    }
187
188    /// Move the **calling** process into this scope.
189    ///
190    /// Called from inside the forked child's `pre_exec` closure: it writes the child's own pid
191    /// to the parent-opened `cgroup.procs` descriptor with a single `write(2)`, formatting the
192    /// pid into a stack buffer so the whole path stays allocation-free and async-signal-safe.
193    /// Every process the child subsequently forks inherits the scope, which is what makes the
194    /// bound apply to the *tree* rather than to one process.
195    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
196    #[allow(unsafe_code)]
197    pub(crate) fn join_current_process(&self) -> std::io::Result<()> {
198        use std::os::fd::AsRawFd;
199
200        let Some(fd) = self.procs_fd.as_ref() else {
201            return Ok(());
202        };
203
204        // SAFETY: `getpid` takes no arguments and cannot fail.
205        let pid = unsafe { libc::getpid() };
206        let mut buf = [0u8; 24];
207        let written = format_pid(pid, &mut buf);
208
209        let mut offset = 0;
210        while offset < written {
211            // SAFETY: `fd` is an open write-only descriptor owned by `self`, and the slice
212            // passed is a live sub-range of the stack buffer above.
213            let rc = unsafe {
214                libc::write(
215                    fd.as_raw_fd(),
216                    buf[offset..written].as_ptr().cast::<libc::c_void>(),
217                    written - offset,
218                )
219            };
220            if rc < 0 {
221                let error = std::io::Error::last_os_error();
222                if error.kind() == std::io::ErrorKind::Interrupted {
223                    continue;
224                }
225                return Err(error);
226            }
227            offset += rc as usize;
228        }
229        Ok(())
230    }
231
232    /// Current `memory.events`/`pids.events` counters.
233    ///
234    /// Must be read while the scope directory still exists — the files vanish with it, which is
235    /// why cleanup happens in `Drop` at session end and never right after a child is reaped.
236    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
237    pub(crate) fn event_counters(&self) -> CgroupEventCounters {
238        CgroupEventCounters {
239            oom_kill: read_event_counter(&self.path.join("memory.events"), "oom_kill"),
240            pids_max: read_event_counter(&self.path.join("pids.events"), "max"),
241        }
242    }
243}
244
245impl Drop for CgroupScope {
246    /// Best-effort `rmdir` of the scope directory.
247    ///
248    /// A cgroup directory can only be removed once empty, so a lingering grandchild the runtime
249    /// never reaped keeps it alive. That is logged and moved past rather than retried in a loop:
250    /// blocking session teardown on a process the runtime does not control would turn a leaked
251    /// directory into a hung exit.
252    fn drop(&mut self) {
253        // Drop the descriptor before the rmdir so nothing holds the directory open.
254        self.procs_fd = None;
255        if self.path.as_os_str().is_empty() {
256            return;
257        }
258        if let Err(error) = std::fs::remove_dir(&self.path) {
259            eprintln!(
260                "[capsule-runtime] note: could not remove cgroup scope {} ({error}); \
261                 it will be reclaimed once its last task exits",
262                self.path.display()
263            );
264        }
265    }
266}
267
268/// Format `pid` into `buf` as decimal ASCII followed by a newline, returning the byte count.
269///
270/// Hand-rolled because [`CgroupScope::join_current_process`] runs between `fork` and `execve`,
271/// where `format!` (and the allocator lock behind it) is not async-signal-safe.
272#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
273fn format_pid(pid: i32, buf: &mut [u8; 24]) -> usize {
274    let mut digits = [0u8; 20];
275    let mut count = 0;
276    let mut value = pid.unsigned_abs() as u64;
277    if value == 0 {
278        digits[0] = b'0';
279        count = 1;
280    }
281    while value > 0 {
282        digits[count] = b'0' + (value % 10) as u8;
283        value /= 10;
284        count += 1;
285    }
286
287    let mut written = 0;
288    for index in (0..count).rev() {
289        buf[written] = digits[index];
290        written += 1;
291    }
292    buf[written] = b'\n';
293    written + 1
294}
295
296/// Read one `<key> <value>` counter out of a cgroup `*.events` file, or `0` if the file or key
297/// is absent. Absence is not an error: `memory.events` only exists once the memory controller is
298/// enabled, and treating a missing counter as zero means attribution simply declines to name a
299/// limit rather than inventing one.
300#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
301fn read_event_counter(path: &Path, key: &str) -> u64 {
302    let Ok(contents) = std::fs::read_to_string(path) else {
303        return 0;
304    };
305    parse_event_counter(&contents, key)
306}
307
308/// Pure parse half of [`read_event_counter`] — no syscalls, so it is unit-testable on macOS.
309#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
310fn parse_event_counter(contents: &str, key: &str) -> u64 {
311    for line in contents.lines() {
312        let mut parts = line.split_whitespace();
313        if parts.next() == Some(key) {
314            return parts
315                .next()
316                .and_then(|value| value.parse().ok())
317                .unwrap_or(0);
318        }
319    }
320    0
321}
322
323/// Turn a session id into a directory name safe to create under the delegated base: everything
324/// outside `[A-Za-z0-9._-]` becomes `_`.
325///
326/// The name is also made unique per scope, with the pid and a per-process serial. The id alone
327/// cannot carry that: `plan::execute` passes the plan's *authored* id, so two plans running
328/// concurrently in one process would otherwise land on the same directory — and because
329/// [`CgroupScope`]'s `Drop` removes the scope by path, the first to finish would delete a cgroup
330/// the other is still bounded by, leaving that subprocess tree unbounded. Sanitizing stays a
331/// belt-and-braces guard against a hostile id rather than a live escape vector.
332///
333/// Operators still find scopes by the documented `murmur-*` glob.
334#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
335fn scope_dir_name(session_id: &str) -> String {
336    static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
337    let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
338
339    let sanitized: String = session_id
340        .chars()
341        .map(|c| {
342            if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
343                c
344            } else {
345                '_'
346            }
347        })
348        .collect();
349    let pid = std::process::id();
350    if sanitized.is_empty() {
351        format!("murmur-{pid}-{serial}")
352    } else {
353        format!("murmur-{sanitized}-{pid}-{serial}")
354    }
355}
356
357/// Extract the unified-hierarchy path from `/proc/self/cgroup` contents.
358///
359/// A cgroup v2 host writes exactly one line whose hierarchy id is `0` and whose controller list
360/// is empty: `0::/user.slice/...`. Returning `None` for anything else is what makes a v1-only
361/// (or hybrid, v2-less) host a clean "cannot delegate" rather than a wrong path.
362#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
363fn parse_unified_cgroup_path(contents: &str) -> Option<String> {
364    for line in contents.lines() {
365        let mut parts = line.splitn(3, ':');
366        let (Some("0"), Some(""), Some(path)) = (parts.next(), parts.next(), parts.next()) else {
367            continue;
368        };
369        if path.starts_with('/') {
370            return Some(path.to_string());
371        }
372    }
373    None
374}
375
376/// Find the `cgroup2` mount point in `/proc/mounts` contents, falling back to the conventional
377/// `/sys/fs/cgroup`.
378#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
379fn parse_cgroup2_mount(contents: &str) -> String {
380    for line in contents.lines() {
381        let mut parts = line.split_whitespace();
382        let (Some(_source), Some(target), Some(fstype)) =
383            (parts.next(), parts.next(), parts.next())
384        else {
385            continue;
386        };
387        if fstype == "cgroup2" {
388            return target.to_string();
389        }
390    }
391    DEFAULT_CGROUP2_MOUNT.to_string()
392}
393
394/// Which of `wanted` are missing from a `cgroup.subtree_control` file's contents.
395#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
396fn missing_controllers(subtree_control: &str, wanted: &[&str]) -> Vec<String> {
397    let enabled: Vec<&str> = subtree_control.split_whitespace().collect();
398    wanted
399        .iter()
400        .filter(|controller| !enabled.contains(*controller))
401        .map(|controller| (*controller).to_string())
402        .collect()
403}
404
405/// Encode a `st_dev` value as the `MAJ:MIN` string `io.max` expects, using glibc's device-number
406/// layout (12+20 bit split, not the legacy 8+8).
407#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
408fn device_major_minor(dev: u64) -> (u64, u64) {
409    let major = ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff);
410    let minor = (dev & 0xff) | ((dev >> 12) & !0xff);
411    (major, minor)
412}
413
414#[cfg(target_os = "linux")]
415impl CgroupScope {
416    /// Probe delegation, enable the controllers, create the scope directory, and write its
417    /// limits. Any failure to establish `memory.max`, `pids.max` or `cpu.max` is fatal — those
418    /// three are settable on any cgroup v2 host once the controllers are delegated, so a failure
419    /// there means the bound genuinely does not exist and the launch must not proceed.
420    fn create(
421        limits: &HostResourceLimits,
422        session_id: &str,
423        workdir: &Path,
424    ) -> Result<Self, String> {
425        let base = delegated_base()?;
426
427        let path = base.join(scope_dir_name(session_id));
428        if let Err(error) = std::fs::create_dir(&path) {
429            if error.kind() != std::io::ErrorKind::AlreadyExists {
430                return Err(format!(
431                    "could not create cgroup scope at {}: {error}",
432                    path.display()
433                ));
434            }
435        }
436
437        let scope = Self {
438            path,
439            procs_fd: None,
440        };
441
442        scope.write_limit("memory.max", &limits.cgroup_memory_bytes.to_string())?;
443        scope.write_limit("pids.max", &limits.cgroup_pids_max.to_string())?;
444        scope.write_limit(
445            "cpu.max",
446            &format!(
447                "{} {CPU_MAX_PERIOD_US}",
448                u64::from(limits.cgroup_cpu_percent) * (CPU_MAX_PERIOD_US / 100)
449            ),
450        )?;
451        scope.apply_io_max(limits.cgroup_io_bytes_per_sec, workdir);
452
453        // Opened last, so a scope that failed any of the fatal writes above is never handed out
454        // with a usable join descriptor.
455        let procs_fd = open_write_only(&scope.path.join("cgroup.procs")).map_err(|error| {
456            format!(
457                "could not open {}/cgroup.procs for subprocess placement: {error}",
458                scope.path.display()
459            )
460        })?;
461
462        Ok(Self {
463            path: scope.take_path(),
464            procs_fd: Some(procs_fd),
465        })
466    }
467
468    /// Move the path out of a scope that must not run its `Drop` (which would `rmdir` the
469    /// directory the returned value is about to take ownership of).
470    fn take_path(mut self) -> PathBuf {
471        let path = std::mem::take(&mut self.path);
472        // `self.path` is now empty, which `Drop` treats as "nothing to remove".
473        path
474    }
475
476    fn write_limit(&self, file: &str, value: &str) -> Result<(), String> {
477        let path = self.path.join(file);
478        std::fs::write(&path, format!("{value}\n"))
479            .map_err(|error| format!("could not write {} to {}: {error}", value, path.display()))
480    }
481
482    /// Best-effort `io.max` on the workdir's backing block device.
483    ///
484    /// Non-fatal by design, and the only one of the four controllers treated that way: the
485    /// backing device of a path cannot always be resolved to a real `MAJ:MIN` the block layer
486    /// accepts (overlayfs, tmpfs, btrfs subvolumes and device-mapper stacks all break the
487    /// assumption), and I/O throughput is the least safety-critical of the four — a capsule that
488    /// saturates disk bandwidth is slow, where one that exhausts memory or pids is fatal.
489    fn apply_io_max(&self, bytes_per_sec: u64, workdir: &Path) {
490        use std::os::linux::fs::MetadataExt;
491
492        let Ok(metadata) = workdir.metadata() else {
493            eprintln!(
494                "[capsule-runtime] note: cgroup io.max not applied — could not stat workdir {}",
495                workdir.display()
496            );
497            return;
498        };
499        let (major, minor) = device_major_minor(metadata.st_dev());
500        let value = format!("{major}:{minor} rbps={bytes_per_sec} wbps={bytes_per_sec}");
501        if let Err(error) = std::fs::write(self.path.join("io.max"), format!("{value}\n")) {
502            eprintln!(
503                "[capsule-runtime] note: cgroup io.max not applied ({error}); memory.max, \
504                 pids.max and cpu.max are still enforced on this scope"
505            );
506        }
507    }
508}
509
510/// The delegated cgroup root this process creates its scopes under, resolved once.
511///
512/// [`probe_delegation`] derives the root from `/proc/self/cgroup`, but establishing the first
513/// scope may move this process into `<root>/murmur-supervisor` to vacate the root — cgroup v2
514/// forbids a cgroup from both holding processes and enabling controllers for its children. Once
515/// that move has happened the process's own cgroup is no longer the delegated root, so
516/// re-deriving it per call mistakes the leaf for the root and nests one level deeper every time,
517/// until the nested leaf has no delegated controllers at all and every further session is
518/// refused. Any process that opens a second session — concurrently or in sequence — hits this.
519///
520/// Resolving once, under a lock, keeps every scope a sibling directly under the real root, and
521/// makes concurrent first-callers agree on that root instead of racing to relocate each other.
522/// Only success is cached: a host that is not delegated must keep reporting so on every attempt.
523#[cfg(target_os = "linux")]
524fn delegated_base() -> Result<PathBuf, String> {
525    static BASE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
526
527    // A poisoned lock guards no invariant worth honouring here: the cache is either populated or
528    // it is not, and a caller that panicked cannot have left it half-written.
529    let mut cached = BASE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
530    if let Some(base) = cached.as_ref() {
531        return Ok(base.clone());
532    }
533
534    // Best-effort: relocate into our own scope before probing. On failure, the probe below runs
535    // against the inherited cgroup — the correct fallback on a host with no systemd to ask.
536    move_self_into_own_systemd_scope();
537
538    let base = probe_delegation()?;
539    enable_controllers(&base)?;
540    *cached = Some(base.clone());
541    Ok(base)
542}
543
544/// Ask the systemd user manager for a freshly created, delegated transient scope holding this
545/// process, so the cgroup this runtime builds under is one it is the only occupant of.
546///
547/// Best-effort and non-fatal: every failure here (no systemd, no session bus, policy denial, a
548/// migration that never lands) leaves the process exactly where it was — the state
549/// [`probe_delegation`] handles. Never let a failed optimisation become a launch refusal.
550///
551/// Runs at most once per process — a scope is a property of the process, not the session, and
552/// [`delegated_base`] retries on failure but caches only success, so the guard has to live here.
553#[cfg(target_os = "linux")]
554fn move_self_into_own_systemd_scope() {
555    static ATTEMPTED: std::sync::Once = std::sync::Once::new();
556    ATTEMPTED.call_once(|| {
557        let _ = request_transient_scope(&transient_scope_unit_name());
558    });
559}
560
561/// Name of the transient scope unit to ask systemd for.
562///
563/// `murmur-` keeps the operator-facing convention the scope *directories* already use
564/// ([`scope_dir_name`]), so `systemctl --user list-units 'murmur-*.scope'` finds the runtime's
565/// units the same way a `murmur-*` glob finds its cgroup directories. The pid and a per-process
566/// serial keep it unique against a recycled pid whose earlier unit systemd has not yet collected.
567#[cfg(target_os = "linux")]
568fn transient_scope_unit_name() -> String {
569    static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
570    let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
571    format!("murmur-{}-{serial}.scope", std::process::id())
572}
573
574/// The D-Bus half of [`move_self_into_own_systemd_scope`], plus confirmation that the kernel
575/// actually moved us.
576#[cfg(target_os = "linux")]
577fn request_transient_scope(unit: &str) -> Result<(), String> {
578    // zbus's blocking API drives its own executor; run it off-thread so it never blocks a
579    // caller already inside another async runtime (e.g. tokio) on a nested reactor.
580    let owned_unit = unit.to_string();
581    std::thread::Builder::new()
582        .name("murmur-cgroup-scope".to_string())
583        .spawn(move || start_transient_scope(&owned_unit))
584        .map_err(|error| format!("could not spawn the D-Bus thread: {error}"))?
585        .join()
586        .map_err(|_| "the D-Bus thread panicked".to_string())??;
587
588    await_scope_membership(unit)
589}
590
591/// `StartTransientUnit` on the systemd user manager: create `unit` as a scope containing this
592/// process, with its cgroup subtree delegated to us.
593///
594/// The call is
595/// `org.freedesktop.systemd1.Manager.StartTransientUnit(in s name, in s mode, in a(sv) properties,
596/// in a(sa(sv)) aux)` on `/org/freedesktop/systemd1` at the well-known name
597/// `org.freedesktop.systemd1`, over the **session** bus (`$DBUS_SESSION_BUS_ADDRESS`, falling back
598/// to `$XDG_RUNTIME_DIR/bus`) so the unit lands in this user's manager and needs no privilege.
599/// The properties are the same set `systemd-run --user --scope -p Delegate=yes` sends:
600///
601/// * `PIDs` (`au`) — this process, which is what makes systemd *move* it rather than fork a new one.
602/// * `Delegate` (`b`) — hand the scope's subtree to us, controllers and all, and stop managing it.
603/// * `CollectMode` (`s`) — `inactive-or-failed`, so the unit is garbage-collected when this
604///   process exits rather than lingering as a failed unit an operator has to reset.
605/// * `Description` (`s`) — what `systemctl --user list-units` shows beside the unit.
606#[cfg(target_os = "linux")]
607fn start_transient_scope(unit: &str) -> Result<(), String> {
608    use zbus::zvariant::Value;
609
610    let connection = zbus::blocking::Connection::session()
611        .map_err(|error| format!("no systemd session bus: {error}"))?;
612
613    let properties: Vec<(&str, Value<'_>)> = vec![
614        (
615            "Description",
616            Value::from("Murmur capsule subprocess scope"),
617        ),
618        ("PIDs", Value::from(vec![std::process::id()])),
619        ("Delegate", Value::from(true)),
620        ("CollectMode", Value::from("inactive-or-failed")),
621    ];
622    // No auxiliary units: a scope has none.
623    let aux: Vec<(&str, Vec<(&str, Value<'_>)>)> = Vec::new();
624
625    connection
626        .call_method(
627            Some("org.freedesktop.systemd1"),
628            "/org/freedesktop/systemd1",
629            Some("org.freedesktop.systemd1.Manager"),
630            "StartTransientUnit",
631            // "fail" rather than "replace": a name collision means something is wrong with our
632            // assumption of uniqueness, and displacing an unrelated unit is never the fix.
633            &(unit, "fail", properties, aux),
634        )
635        .map(|_| ())
636        .map_err(|error| format!("systemd refused to start {unit}: {error}"))
637}
638
639/// Wait until `/proc/self/cgroup` actually reports us inside `unit`'s cgroup.
640///
641/// `StartTransientUnit` returns once the job is *enqueued*, not once the move has happened, and
642/// every later step (the delegation probe, the controller enable, the scope directory) depends on
643/// where this process actually lives — so migration is confirmed from `/proc/self/cgroup`, the
644/// same source the rest of this module reads, rather than inferred from a job object path.
645///
646/// The 2s deadline in 20ms steps mirrors [`enable_controllers`]: both wait on systemd/kernel
647/// bookkeeping that normally completes in a millisecond or two, and a wait that long means
648/// something structural is wrong. A timeout here is cheap — the caller ignores it and falls back
649/// to probing the inherited cgroup.
650#[cfg(target_os = "linux")]
651fn await_scope_membership(unit: &str) -> Result<(), String> {
652    let deadline = std::time::Instant::now() + Duration::from_secs(2);
653    loop {
654        let current = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
655        if let Some(path) = parse_unified_cgroup_path(&current) {
656            if path.rsplit('/').next() == Some(unit) {
657                return Ok(());
658            }
659        }
660        if std::time::Instant::now() >= deadline {
661            return Err(format!(
662                "systemd accepted the request for {unit} but this process is not in it"
663            ));
664        }
665        std::thread::sleep(Duration::from_millis(20));
666    }
667}
668
669/// Locate the delegated cgroup v2 directory this process may create children under, confirming
670/// the controllers this runtime needs are actually available there.
671///
672/// Returns the base directory on success, or a message naming what is missing — that message is
673/// what reaches the operator inside
674/// [`crate::errors::RuntimeError::CgroupDelegationUnavailable`], so it names the delegation
675/// requirement rather than only the failed syscall.
676#[cfg(target_os = "linux")]
677fn probe_delegation() -> Result<PathBuf, String> {
678    let self_cgroup = std::fs::read_to_string("/proc/self/cgroup")
679        .map_err(|error| format!("could not read /proc/self/cgroup: {error}"))?;
680    let relative = parse_unified_cgroup_path(&self_cgroup).ok_or_else(|| {
681        "this host has no cgroup v2 unified hierarchy (no `0::` line in /proc/self/cgroup); \
682         cgroup v1-only and hybrid hosts cannot be delegated a v2 scope"
683            .to_string()
684    })?;
685
686    let mount = std::fs::read_to_string("/proc/mounts")
687        .map(|contents| parse_cgroup2_mount(&contents))
688        .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
689
690    let base = ascend_out_of_supervisor_leaves(
691        PathBuf::from(mount).join(relative.trim_start_matches('/')),
692    );
693    if !base.is_dir() {
694        return Err(format!(
695            "delegated cgroup directory {} does not exist",
696            base.display()
697        ));
698    }
699
700    let controllers =
701        std::fs::read_to_string(base.join("cgroup.controllers")).map_err(|error| {
702            format!(
703                "could not read {}/cgroup.controllers: {error}",
704                base.display()
705            )
706        })?;
707    let unavailable = missing_controllers(&controllers, REQUIRED_CONTROLLERS);
708    if !unavailable.is_empty() {
709        return Err(format!(
710            "cgroup controllers [{}] are not delegated to {}; the unit `mur` runs under needs \
711             `Delegate=yes` (or `Delegate=memory pids cpu io`)",
712            unavailable.join(", "),
713            base.display()
714        ));
715    }
716
717    // The probe must confirm we can actually *create* a child here, not just read the directory:
718    // a readable cgroup with no write permission is the exact shape of an undelegated host.
719    // Named per-pid so two `mur` processes probing concurrently never race on the same path.
720    let probe_dir = base.join(format!("murmur-delegation-probe-{}", std::process::id()));
721    let _ = std::fs::remove_dir(&probe_dir);
722    std::fs::create_dir(&probe_dir).map_err(|error| {
723        format!(
724            "cannot create a child cgroup under {} ({error}); the unit `mur` runs under needs \
725             `Delegate=yes` for memory, pids, cpu and io",
726            base.display()
727        )
728    })?;
729    let _ = std::fs::remove_dir(&probe_dir);
730
731    Ok(base)
732}
733
734/// Enable `memory`, `pids`, `cpu` (and `io`, best-effort) in the delegated base's
735/// `cgroup.subtree_control` so a child cgroup can carry their limit files.
736///
737/// If the base still holds this process directly, the write is refused (`EBUSY`) by cgroup v2's
738/// "no internal processes" rule; the recovery is to move this process into its own leaf and try
739/// once more. That relocation stays entirely inside the delegated subtree.
740#[cfg(target_os = "linux")]
741fn enable_controllers(base: &Path) -> Result<(), String> {
742    let subtree_control = base.join("cgroup.subtree_control");
743    let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
744
745    // `io` is requested opportunistically: a host that delegates only the three required
746    // controllers still gets a fully-enforced scope, just without `io.max`.
747    let available = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
748    let mut wanted: Vec<&str> = REQUIRED_CONTROLLERS.to_vec();
749    if available.split_whitespace().any(|c| c == "io") {
750        wanted.push("io");
751    }
752
753    let missing = missing_controllers(&current, &wanted);
754    if missing.is_empty() {
755        return Ok(());
756    }
757    let directive = missing
758        .iter()
759        .map(|controller| format!("+{controller}"))
760        .collect::<Vec<_>>()
761        .join(" ");
762
763    if std::fs::write(&subtree_control, format!("{directive}\n")).is_ok() {
764        return Ok(());
765    }
766
767    move_self_to_supervisor_leaf(base)?;
768
769    // Stepping aside vacates *this* process, and cgroup v2 refuses the write while the base holds
770    // any task at all. When several murmur processes share a base — a test binary and the `mur`
771    // children it spawns, or two capsules launched together — each is independently discovering
772    // it must step aside, so the first write after our own move can still hit `EBUSY` on peers
773    // that have not moved yet. Retrying briefly lets the group finish draining; a peer that wins
774    // the race and enables the controllers first is just as good, which is why the loop re-reads
775    // `subtree_control` rather than only retrying the write.
776    //
777    // Bounded, because a task that never leaves is a genuine misconfiguration and has to surface
778    // as one rather than hang the launch.
779    let deadline = std::time::Instant::now() + Duration::from_secs(2);
780    // Left uninitialised: every path that reaches the deadline check has just gone through the
781    // write's `Err` arm, so the fallback below only ever describes a real failure.
782    let mut last_error;
783    loop {
784        let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
785        if missing_controllers(&current, &wanted).is_empty() {
786            return Ok(());
787        }
788        match std::fs::write(&subtree_control, format!("{directive}\n")) {
789            Ok(()) => return Ok(()),
790            Err(error) => last_error = Some(error),
791        }
792        if std::time::Instant::now() >= deadline {
793            break;
794        }
795        std::thread::sleep(Duration::from_millis(20));
796    }
797
798    Err(format!(
799        "could not enable controllers [{}] in {} ({}); the unit `mur` runs under needs \
800         `Delegate=yes` for memory, pids, cpu and io",
801        missing.join(", "),
802        subtree_control.display(),
803        last_error.map_or_else(|| "still occupied".to_string(), |error| error.to_string()),
804    ))
805}
806
807/// Walk up out of any `murmur-supervisor` leaves the path ends in.
808///
809/// The leaf is where a murmur process steps aside so controllers can be enabled on the delegated
810/// base. Every process it then spawns *inherits that leaf as its own cgroup* — so a `mur`
811/// subprocess reading `/proc/self/cgroup` would take the leaf for its delegated base and step
812/// aside again inside it, one level deeper per generation, until the innermost leaf has no
813/// controllers delegated to it at all and every launch below that point is refused.
814///
815/// Ascending here makes every generation agree on the same real base, which is also what lets
816/// them share one scope parent rather than fragmenting the delegation.
817#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
818fn ascend_out_of_supervisor_leaves(mut base: PathBuf) -> PathBuf {
819    while base.file_name().and_then(|name| name.to_str()) == Some(SUPERVISOR_LEAF) {
820        base.pop();
821    }
822    base
823}
824
825/// Move this process into `<base>/murmur-supervisor`, creating it if needed.
826#[cfg(target_os = "linux")]
827fn move_self_to_supervisor_leaf(base: &Path) -> Result<(), String> {
828    let leaf = base.join(SUPERVISOR_LEAF);
829    if let Err(error) = std::fs::create_dir(&leaf) {
830        if error.kind() != std::io::ErrorKind::AlreadyExists {
831            return Err(format!(
832                "could not create {} to vacate the delegated cgroup: {error}",
833                leaf.display()
834            ));
835        }
836    }
837    std::fs::write(
838        leaf.join("cgroup.procs"),
839        format!("{}\n", std::process::id()),
840    )
841    .map_err(|error| {
842        format!(
843            "could not move this process into {} to vacate the delegated cgroup: {error}",
844            leaf.display()
845        )
846    })
847}
848
849/// `open(path, O_WRONLY | O_CLOEXEC)`.
850///
851/// `CLOEXEC` matters twice over: the descriptor is written from `pre_exec` (still open at that
852/// point) and must not survive into the exec'd binary, where it would hand a capsule the ability
853/// to move arbitrary pids into its own cgroup.
854#[cfg(target_os = "linux")]
855#[allow(unsafe_code)]
856fn open_write_only(path: &Path) -> std::io::Result<std::os::fd::OwnedFd> {
857    use std::os::unix::fs::OpenOptionsExt;
858
859    std::fs::OpenOptions::new()
860        .write(true)
861        .custom_flags(libc::O_CLOEXEC)
862        .open(path)
863        .map(std::os::fd::OwnedFd::from)
864}
865
866/// Whether this host can hand the runtime a delegated cgroup v2 scope — the precondition
867/// [`prepare_scope`] fails closed on.
868///
869/// Test support, not a runtime code path: `mur` never asks this question, it asks systemd for a
870/// scope and reports [`crate::errors::RuntimeError::CgroupDelegationUnavailable`] when it cannot
871/// get one. Tests that launch a capsule tripping [`requires_process_bounding`] call this to skip
872/// visibly instead, because a containerised runner has no delegable subtree to give and the
873/// refusal there says nothing about the code under test. What CI therefore cannot cover is
874/// covered by hand: `docs/content/reference/resource-limits-manual-verification.md`.
875///
876/// Probed by actually creating a transient `--user` scope with `Delegate=yes`, since a
877/// `systemd-run` binary on `PATH` says nothing about a reachable user manager — and the user
878/// manager is the half a runner is missing. Off Linux there are no cgroups, this module is inert
879/// and nothing is ever refused, so the answer is `true`.
880pub fn cgroup_delegation_available() -> bool {
881    #[cfg(not(target_os = "linux"))]
882    {
883        true
884    }
885
886    #[cfg(target_os = "linux")]
887    {
888        // Answered through `delegated_base`, the same call `prepare_scope` makes, so the answer
889        // cannot disagree with what a launch does on this host. A separate `systemd-run` probe
890        // answers a different question and is wrong in both directions: a user manager hands out
891        // a scope on a host whose controllers `enable_controllers` cannot delegate, and the
892        // inherited cgroup carries delegated controllers on a host with no reachable session bus.
893        delegated_base().is_ok()
894    }
895}
896
897/// Whether a test that launches a subprocess-capable capsule must stand down on this host,
898/// printing the blocker when it must.
899///
900/// Both blockers are asked through the same calls a launch makes -- `delegated_base` for the
901/// cgroup scope, `detect_egress_namespace_blocker` for the network namespace -- so the gate and
902/// the launch cannot reach different conclusions about the host. A containerised CI runner
903/// typically clears the first and fails the second.
904pub fn skip_without_host_support(test_name: &str) -> bool {
905    if crate::network_namespace::skip_without_egress_namespace(test_name) {
906        return true;
907    }
908    if !cgroup_delegation_available() {
909        eprintln!(
910            "[SKIP-HOST] {test_name}: this host cannot delegate a cgroup v2 scope, so a capsule \
911             that can spawn native subprocesses refuses to launch with E-RUN-012 before anything \
912             this test observes happens -- see \
913             docs/content/reference/resource-limits-manual-verification.md"
914        );
915        return true;
916    }
917    false
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use crate::types::CapabilityPolicy;
924
925    #[test]
926    fn scope_is_required_by_any_route_to_a_native_subprocess() {
927        let shell = CapabilityPolicy {
928            shell_allow: vec!["bash".to_string()],
929            ..CapabilityPolicy::default()
930        };
931        let spawn = CapabilityPolicy {
932            spawn_allow: vec!["child".to_string()],
933            ..CapabilityPolicy::default()
934        };
935        let bare = CapabilityPolicy::default();
936
937        assert!(requires_process_bounding(&shell, false));
938        assert!(requires_process_bounding(&spawn, false));
939        assert!(
940            requires_process_bounding(&bare, true),
941            "a native artifact alone is enough"
942        );
943        assert!(
944            !requires_process_bounding(&bare, false),
945            "a WASM-only capsule has no process tree to bound and must not be blocked"
946        );
947    }
948
949    /// A capsule with no subprocess capability never needs delegation — on any platform, and
950    /// notably including Linux, where a blanket requirement would be a disproportionate
951    /// regression for WASM-only capsules.
952    #[test]
953    fn prepare_scope_returns_none_when_bounding_is_not_required() {
954        let temp = tempfile::tempdir().unwrap();
955        let scope = prepare_scope(
956            false,
957            &HostResourceLimits::default(),
958            "ses_test",
959            temp.path(),
960        )
961        .expect("a capsule with no subprocess capability must never be refused");
962        assert!(scope.is_none());
963    }
964
965    /// macOS can never have a cgroup, so a required scope there must be a silent `None` rather
966    /// than a launch-blocking error.
967    #[cfg(not(target_os = "linux"))]
968    #[test]
969    fn prepare_scope_is_never_an_error_off_linux() {
970        let temp = tempfile::tempdir().unwrap();
971        let scope = prepare_scope(
972            true,
973            &HostResourceLimits::default(),
974            "ses_test",
975            temp.path(),
976        )
977        .expect("a non-Linux host must not refuse to launch for a missing cgroup");
978        assert!(scope.is_none());
979    }
980
981    /// The point of asking systemd for a scope: a Linux host with a working user session hands
982    /// out a bounded scope no matter what this process's *inherited* cgroup looked like — an
983    /// interactive shell's cgroup full of co-resident processes, or the one `cargo test` keeps
984    /// itself resident in while running this very binary.
985    #[cfg(target_os = "linux")]
986    #[test]
987    fn a_required_scope_is_created_whatever_cgroup_this_process_inherited() {
988        if skip_without_host_support(
989            "a_required_scope_is_created_whatever_cgroup_this_process_inherited",
990        ) {
991            return;
992        }
993        let temp = tempfile::tempdir().unwrap();
994        let scope = prepare_scope(
995            true,
996            &HostResourceLimits::default(),
997            "ses_selftest",
998            temp.path(),
999        )
1000        .expect("a Linux host with a systemd user session must be able to bound a subprocess tree")
1001        .expect("a required scope on Linux is never `None`");
1002
1003        for file in ["memory.max", "pids.max", "cpu.max"] {
1004            let contents = std::fs::read_to_string(scope.path().join(file))
1005                .unwrap_or_else(|error| panic!("{file} unreadable in the scope: {error}"));
1006            assert!(!contents.trim().is_empty(), "{file} was left unset");
1007            assert_ne!(contents.trim(), "max", "{file} carries no ceiling");
1008        }
1009    }
1010
1011    /// The unit name systemd is asked for stays inside the `murmur-*` convention operators
1012    /// already use to find this runtime's cgroup directories, and stays unique per request.
1013    #[cfg(target_os = "linux")]
1014    #[test]
1015    fn transient_scope_unit_names_are_murmur_prefixed_and_unique() {
1016        let first = transient_scope_unit_name();
1017        let second = transient_scope_unit_name();
1018        assert!(first.starts_with(&format!("murmur-{}-", std::process::id())));
1019        assert!(first.ends_with(".scope"));
1020        assert_ne!(first, second);
1021    }
1022
1023    #[test]
1024    fn unified_cgroup_path_is_read_from_the_zero_hierarchy_line() {
1025        let v2_only = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope\n";
1026        assert_eq!(
1027            parse_unified_cgroup_path(v2_only).as_deref(),
1028            Some("/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope")
1029        );
1030
1031        let hybrid = "12:pids:/user.slice\n11:memory:/user.slice\n0::/user.slice/session.scope\n";
1032        assert_eq!(
1033            parse_unified_cgroup_path(hybrid).as_deref(),
1034            Some("/user.slice/session.scope")
1035        );
1036
1037        let v1_only = "12:pids:/user.slice\n11:memory:/user.slice\n";
1038        assert_eq!(parse_unified_cgroup_path(v1_only), None);
1039    }
1040
1041    #[test]
1042    fn cgroup2_mount_falls_back_to_the_conventional_path() {
1043        let mounts = "cgroup2 /custom/cgroup cgroup2 rw,nsdelegate 0 0\n";
1044        assert_eq!(parse_cgroup2_mount(mounts), "/custom/cgroup");
1045        assert_eq!(
1046            parse_cgroup2_mount("proc /proc proc rw 0 0\n"),
1047            "/sys/fs/cgroup"
1048        );
1049    }
1050
1051    #[test]
1052    fn missing_controllers_reports_only_what_is_absent() {
1053        assert_eq!(
1054            missing_controllers("cpu memory", REQUIRED_CONTROLLERS),
1055            vec!["pids".to_string()]
1056        );
1057        assert!(missing_controllers("cpu io memory pids", REQUIRED_CONTROLLERS).is_empty());
1058        assert_eq!(
1059            missing_controllers("", REQUIRED_CONTROLLERS),
1060            vec!["memory".to_string(), "pids".to_string(), "cpu".to_string()]
1061        );
1062    }
1063
1064    #[test]
1065    fn event_counters_parse_the_named_key_only() {
1066        let memory_events = "low 0\nhigh 0\nmax 12\noom 3\noom_kill 2\n";
1067        assert_eq!(parse_event_counter(memory_events, "oom_kill"), 2);
1068        assert_eq!(parse_event_counter(memory_events, "max"), 12);
1069        assert_eq!(parse_event_counter("max 7\n", "max"), 7);
1070        assert_eq!(parse_event_counter("", "oom_kill"), 0);
1071        assert_eq!(parse_event_counter("oom_kill\n", "oom_kill"), 0);
1072    }
1073
1074    /// Attribution keys on the delta, not the absolute counter: a session-cumulative total would
1075    /// mis-attribute every later call once any earlier one had been OOM-killed.
1076    #[test]
1077    fn attribution_uses_the_delta_across_one_call() {
1078        let before = CgroupEventCounters {
1079            oom_kill: 1,
1080            pids_max: 4,
1081        };
1082        assert_eq!(before.attribution_since(before), None);
1083        assert_eq!(
1084            CgroupEventCounters {
1085                oom_kill: 2,
1086                pids_max: 4
1087            }
1088            .attribution_since(before),
1089            Some("cgroup_memory_bytes")
1090        );
1091        assert_eq!(
1092            CgroupEventCounters {
1093                oom_kill: 1,
1094                pids_max: 5
1095            }
1096            .attribution_since(before),
1097            Some("cgroup_pids_max")
1098        );
1099    }
1100
1101    #[test]
1102    fn scope_dir_name_is_sanitized_and_never_empty() {
1103        assert!(scope_dir_name("ses_abc123").starts_with("murmur-ses_abc123-"));
1104        assert!(scope_dir_name("../escape").starts_with("murmur-.._escape-"));
1105        assert!(!scope_dir_name("../escape").contains('/'));
1106        assert!(scope_dir_name("").starts_with("murmur-"));
1107    }
1108
1109    /// A subprocess inherits its parent's cgroup, so a `mur` spawned by a runtime that already
1110    /// stepped aside starts life *inside* the supervisor leaf. Reading that as its delegated base
1111    /// would nest another leaf inside it, one per generation, until nothing is delegated.
1112    #[test]
1113    fn delegated_base_ascends_out_of_inherited_supervisor_leaves() {
1114        let base = PathBuf::from("/sys/fs/cgroup/user.slice/app.slice/mur.scope");
1115
1116        assert_eq!(ascend_out_of_supervisor_leaves(base.clone()), base);
1117        assert_eq!(
1118            ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF)),
1119            base
1120        );
1121        assert_eq!(
1122            ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF).join(SUPERVISOR_LEAF)),
1123            base,
1124            "a base already nested by an earlier build must still resolve to the real root"
1125        );
1126    }
1127
1128    /// Two scopes must never share a directory even when handed the same id: `Drop` removes the
1129    /// scope by path, so a collision would have one session delete the cgroup still bounding
1130    /// another's subprocess tree.
1131    #[test]
1132    fn scope_dir_name_is_unique_per_call_for_one_id() {
1133        let first = scope_dir_name("p");
1134        let second = scope_dir_name("p");
1135        assert_ne!(
1136            first, second,
1137            "same plan id must not reuse a scope directory"
1138        );
1139    }
1140
1141    #[test]
1142    fn pid_is_formatted_without_allocating() {
1143        let mut buf = [0u8; 24];
1144        let len = format_pid(4321, &mut buf);
1145        assert_eq!(&buf[..len], b"4321\n");
1146
1147        let len = format_pid(7, &mut buf);
1148        assert_eq!(&buf[..len], b"7\n");
1149    }
1150
1151    #[test]
1152    fn device_numbers_use_the_wide_glibc_encoding() {
1153        // 8:0 (`/dev/sda`) encodes as 0x800 under the 12+20 bit layout.
1154        assert_eq!(device_major_minor(0x800), (8, 0));
1155        // 259:3 (`nvme0n1p3`) needs the wide major field, which the legacy 8+8 layout truncates.
1156        assert_eq!(device_major_minor((259 << 8) | 3), (259, 3));
1157    }
1158}