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