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(¤t) {
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(¤t, &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(¤t, &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}