zlayer_agent/capability.rs
1//! Daemon capability survey.
2//!
3//! Probes the runtime environment of the zlayer daemon (root vs. non-root,
4//! host vs. nested in a container, cgroup v2 path, `CAP_NET_ADMIN`, presence
5//! of `/dev/net/tun`, and writability of the cgroup root) and derives a coarse
6//! [`DaemonMode`] from those signals.
7//!
8//! All probes are intentionally cheap and non-destructive — a handful of
9//! syscalls, no allocations of kernel resources (no TUN interfaces, no cgroup
10//! writes). The struct is safe to construct multiple times.
11//!
12//! Non-Linux targets report a fixed degraded survey since the kernel features
13//! these probes target are Linux-only.
14
15use std::sync::OnceLock;
16
17use serde::{Deserialize, Serialize};
18// The TUN / CAP_NET_ADMIN probes live in zlayer-overlay so both this daemon and
19// the (lightweight) edge client can share one implementation without the edge
20// client depending on this heavyweight crate. See
21// `zlayer_overlay::capability` for the single source of truth.
22use zlayer_overlay::capability::{probe_has_cap_net_admin, probe_tun_device_available};
23
24/// Coarse classification of the daemon's effective execution environment.
25///
26/// Derived from the boolean fields on [`DaemonCapabilities`].
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum DaemonMode {
30 /// Host-level execution: all caps, can write cgroup root, can create overlay.
31 Full,
32 /// Inside a container: scoped to a sub-cgroup; some caps may be present.
33 NestedAdaptive,
34 /// Missing privileges required for any meaningful container creation.
35 Degraded,
36}
37
38/// Snapshot of the daemon's effective capabilities and execution environment.
39///
40/// Construct via [`DaemonCapabilities::probe`]. Cheap to call repeatedly.
41///
42/// The struct intentionally exposes independent capability bits as separate
43/// booleans rather than collapsing them into an enum — each bit corresponds to
44/// an orthogonal kernel feature (cgroup write, `CAP_NET_ADMIN`, TUN access,
45/// root-ness) and downstream code wants to inspect them independently when
46/// deciding what to gate.
47#[allow(clippy::struct_excessive_bools)]
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct DaemonCapabilities {
50 /// `true` if the process is running as uid 0.
51 pub is_root: bool,
52 /// `true` if the process appears to be inside a container (non-root cgroup
53 /// v2 path).
54 pub is_nested: bool,
55 /// The cgroup v2 path of the current process, if any (e.g.
56 /// `/system.slice/zlayer.service`). `None` on the cgroup root, on
57 /// cgroup-v1-only hosts, on non-Linux, or on read errors.
58 pub cgroup_parent: Option<String>,
59 /// `true` if the cgroup root's `cgroup.subtree_control` has the
60 /// owner-write bit set. Coarse, non-destructive signal — does not
61 /// guarantee an actual write will succeed.
62 pub can_write_cgroup_root: bool,
63 /// `true` if `CAP_NET_ADMIN` is present in the process's *effective* set
64 /// (Linux only).
65 pub has_cap_net_admin: bool,
66 /// `true` if `/dev/net/tun` can be opened r/w in non-blocking mode without
67 /// EACCES/EPERM/ENOENT/ENXIO. The fd is dropped immediately.
68 pub tun_device_available: bool,
69 /// `true` if the daemon can build container rootfs as an overlayfs mount
70 /// (shared read-only lowerdirs + per-container upperdir) instead of a full
71 /// per-container copy of every layer. Requires ALL of: root or
72 /// `CAP_SYS_ADMIN`, `overlay` listed in `/proc/filesystems`, and a probe
73 /// overlay mount in a temp dir that succeeds and immediately unmounts.
74 /// Computed once at startup (the result cannot change for a running daemon)
75 /// and cached with the rest of the survey.
76 pub overlayfs_rootfs_available: bool,
77 /// `true` if the daemon can build a container rootfs as a ROOTLESS
78 /// `fuse-overlayfs` mount — the userspace overlay backend that needs neither
79 /// `CAP_SYS_ADMIN` nor `CAP_MKNOD`. It still gives the shared-layer dedup of
80 /// the kernel path (shared read-only lowerdirs + a per-container upperdir),
81 /// but represents whiteouts the rootless way fuse-overlayfs reads: a plain
82 /// `.wh.<name>` regular file and the `user.overlay.opaque` xattr (both
83 /// settable without privilege) instead of `0:0` char devices /
84 /// `trusted.overlay.opaque`. Requires ALL of: the `fuse-overlayfs` binary on
85 /// `PATH`, `/dev/fuse` openable, and a probe rootless mount that succeeds and
86 /// immediately unmounts. Independent of root / `CAP_SYS_ADMIN` — this is the
87 /// fallback the daemon uses when [`Self::overlayfs_rootfs_available`] is
88 /// false. Computed once at startup and cached.
89 pub fuse_overlayfs_rootfs_available: bool,
90 /// Coarse classification derived from the above fields.
91 pub effective_mode: DaemonMode,
92}
93
94/// Process-wide memoised capability survey. Seeded by the first call to
95/// [`DaemonCapabilities::get`] or [`DaemonCapabilities::seed`].
96static CAPS: OnceLock<DaemonCapabilities> = OnceLock::new();
97
98impl DaemonCapabilities {
99 /// Returns the process-wide capability snapshot, probing on first call.
100 ///
101 /// Subsequent calls return the same memoised instance — capabilities of a
102 /// running daemon do not change at runtime, so re-probing would be wasted
103 /// syscalls and could create the illusion that the daemon's behaviour can
104 /// shift mid-flight.
105 pub fn get() -> &'static Self {
106 CAPS.get_or_init(Self::probe)
107 }
108
109 /// Eagerly seed the memoised survey with an explicit probe result.
110 ///
111 /// Useful at daemon startup to force the probe to happen at a known point
112 /// (so the banner log appears in the expected place). Returns the stored
113 /// instance — if the cache was already seeded, the existing value wins
114 /// and the passed-in `caps` is dropped (probe is pure, so this is fine).
115 ///
116 /// # Panics
117 ///
118 /// In practice this never panics — `OnceLock::set` either stores the
119 /// value or rejects it because the cell is already filled, and in both
120 /// cases the subsequent `get()` returns `Some`. The `expect` exists only
121 /// to satisfy the type system.
122 pub fn seed(caps: Self) -> &'static Self {
123 let _ = CAPS.set(caps);
124 CAPS.get()
125 .expect("CAPS is filled after set or was already filled")
126 }
127
128 /// Probe the running daemon's effective capabilities.
129 ///
130 /// Cheap — a handful of syscalls and no resource allocation. Prefer
131 /// [`DaemonCapabilities::get`] when you want the process-wide memoised
132 /// value; call this directly only when you intentionally want a fresh
133 /// snapshot (e.g. tests).
134 #[must_use]
135 pub fn probe() -> Self {
136 let is_root = zlayer_paths::is_root();
137 let cgroup_parent = current_cgroup_v2_path();
138 let is_nested = cgroup_parent.is_some();
139 let can_write_cgroup_root = probe_can_write_cgroup_root();
140 let has_cap_net_admin = probe_has_cap_net_admin();
141 let tun_device_available = probe_tun_device_available();
142 let overlayfs_rootfs_available = probe_overlayfs_rootfs_available(is_root);
143 let fuse_overlayfs_rootfs_available = probe_fuse_overlayfs_rootfs_available();
144
145 let effective_mode =
146 if !is_nested && can_write_cgroup_root && has_cap_net_admin && tun_device_available {
147 DaemonMode::Full
148 } else if can_write_cgroup_root || cgroup_parent.is_some() {
149 DaemonMode::NestedAdaptive
150 } else {
151 DaemonMode::Degraded
152 };
153
154 Self {
155 is_root,
156 is_nested,
157 cgroup_parent,
158 can_write_cgroup_root,
159 has_cap_net_admin,
160 tun_device_available,
161 overlayfs_rootfs_available,
162 fuse_overlayfs_rootfs_available,
163 effective_mode,
164 }
165 }
166}
167
168/// Decide whether capability state forces a fallback from overlay to host
169/// networking. Pure and side-effect-free so it can be unit-tested without the
170/// host's real capability state.
171///
172/// Returns `Some(reason)` when overlay networking cannot work and the daemon
173/// must fall back to host networking (or hard-error if the operator passed
174/// `--require-overlay`); returns `None` when overlay is viable.
175///
176/// Call this ONLY when the operator did not already request host networking —
177/// an explicit `--host-network` is a deliberate choice, not a degraded state.
178#[must_use]
179pub fn capability_overlay_fallback(
180 has_cap_net_admin: bool,
181 tun_device_available: bool,
182) -> Option<String> {
183 match (has_cap_net_admin, tun_device_available) {
184 (true, true) => None,
185 (false, false) => Some(
186 "CAP_NET_ADMIN is not in the daemon's effective set and /dev/net/tun is not available"
187 .to_string(),
188 ),
189 (false, true) => Some("CAP_NET_ADMIN is not in the daemon's effective set".to_string()),
190 (true, false) => Some("/dev/net/tun is not available".to_string()),
191 }
192}
193
194/// Decide whether the daemon can run the overlay in fully rootless mode: the
195/// overlay daemon wraps itself in its own user+network namespace (holding
196/// `CAP_NET_ADMIN` over its OWN netns only) and uses pasta for egress, instead of
197/// requiring host root or a setcap'd binary.
198///
199/// Rootless overlay is viable only when ALL hold:
200/// - NOT already root (a root daemon should use the normal root overlay path),
201/// - the process does NOT already hold effective `CAP_NET_ADMIN` (if it does, the
202/// setcap/root overlay path is simpler and gives host-level networking),
203/// - `/dev/net/tun` is openable (boringtun needs it for the TUN device), and
204/// - the `pasta` (passt) egress helper is available on the host.
205///
206/// Pure and side-effect-free so it can be unit-tested without namespaces.
207#[must_use]
208#[allow(clippy::fn_params_excessive_bools)] // parallel capability probe flags, intentionally flat
209pub fn can_rootless_overlay(
210 is_root: bool,
211 has_cap_net_admin: bool,
212 tun_device_available: bool,
213 pasta_available: bool,
214) -> bool {
215 !is_root && !has_cap_net_admin && tun_device_available && pasta_available
216}
217
218/// Pure parser for the contents of `/proc/self/cgroup`.
219///
220/// Finds the cgroup-v2 line (prefix `0::`) and returns the path suffix with
221/// surrounding whitespace trimmed. Returns `None` when:
222/// - the input has no `0::` line (cgroup-v1-only host), or
223/// - the v2 path is exactly `/` (host root — bare-metal, no enclosing cgroup), or
224/// - the input is empty.
225#[cfg(target_os = "linux")]
226fn parse_cgroup_v2_line(content: &str) -> Option<String> {
227 for line in content.lines() {
228 if let Some(rest) = line.strip_prefix("0::") {
229 let trimmed = rest.trim();
230 if trimmed.is_empty() || trimmed == "/" {
231 return None;
232 }
233 return Some(trimmed.to_string());
234 }
235 }
236 None
237}
238
239/// Returns the current process's cgroup-v2 path, if any.
240///
241/// On Linux reads `/proc/self/cgroup` and delegates to `parse_cgroup_v2_line`.
242/// On non-Linux always returns `None`. Returns `None` on any read error or
243/// when the process is at the cgroup-v2 root (bare-metal case).
244#[cfg(target_os = "linux")]
245#[must_use]
246pub fn current_cgroup_v2_path() -> Option<String> {
247 let content = std::fs::read_to_string("/proc/self/cgroup").ok()?;
248 parse_cgroup_v2_line(&content)
249}
250
251#[cfg(not(target_os = "linux"))]
252#[must_use]
253pub fn current_cgroup_v2_path() -> Option<String> {
254 None
255}
256
257/// Pure path computation: given a cgroup-v2 scope reported by
258/// `/proc/self/cgroup`, return the sibling `<scope>/containers` parent that
259/// should be used for new container cgroups.
260///
261/// If `scope` already ends with `/init` (the daemon has already been migrated
262/// into the `init` leaf by a previous call), the `/init` suffix is stripped
263/// and the result anchored at the real scope. This makes
264/// [`ensure_daemon_leaf_and_container_parent`] idempotent.
265#[cfg(target_os = "linux")]
266fn compute_target_parent(scope: &str) -> String {
267 let base = scope.strip_suffix("/init").unwrap_or(scope);
268 let base = base.trim_end_matches('/');
269 format!("{base}/containers")
270}
271
272/// Migrate the current daemon process into a `<scope>/init` sub-cgroup and
273/// return the sibling `<scope>/containers` path as the parent for future
274/// container cgroups. Idempotent — safe to call multiple times.
275///
276/// Returns `None` on non-Linux, when `/proc/self/cgroup` can't be parsed,
277/// when `/sys/fs/cgroup` is read-only, or when the mkdir/PID-write fails.
278/// Callers should fall back to the raw `current_cgroup_v2_path()` value in
279/// those cases (the auto-detect path will surface the underlying error).
280#[cfg(target_os = "linux")]
281#[must_use]
282pub fn ensure_daemon_leaf_and_container_parent() -> Option<String> {
283 let scope = current_cgroup_v2_path()?;
284 let containers = compute_target_parent(&scope);
285 // Idempotency: if we're already in `<base>/init`, just return the sibling.
286 if scope.ends_with("/init") {
287 let containers_fs = format!("/sys/fs/cgroup{containers}");
288 match std::fs::create_dir_all(&containers_fs) {
289 Ok(()) => {}
290 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
291 Err(_) => return None,
292 }
293 return Some(containers);
294 }
295
296 let scope = scope.trim_end_matches('/').to_string();
297 let mount = "/sys/fs/cgroup";
298 let init_dir = format!("{mount}{scope}/init");
299
300 match std::fs::create_dir_all(&init_dir) {
301 Ok(()) => {}
302 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
303 Err(_) => return None,
304 }
305
306 let pid_path = format!("{init_dir}/cgroup.procs");
307 let pid_str = format!("{}", std::process::id());
308 if std::fs::write(&pid_path, &pid_str).is_err() {
309 // Already migrated? Re-check /proc/self/cgroup before giving up.
310 let now = current_cgroup_v2_path()?;
311 if now != format!("{scope}/init") {
312 return None;
313 }
314 }
315
316 // Verify the migration actually moved us into <scope>/init.
317 let after = current_cgroup_v2_path()?;
318 if after != format!("{scope}/init") {
319 return None;
320 }
321
322 let containers_dir = format!("{mount}{containers}");
323 match std::fs::create_dir_all(&containers_dir) {
324 Ok(()) => {}
325 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
326 Err(_) => return None,
327 }
328
329 Some(containers)
330}
331
332#[cfg(not(target_os = "linux"))]
333#[must_use]
334pub fn ensure_daemon_leaf_and_container_parent() -> Option<String> {
335 None
336}
337
338/// Top-level cgroup-v2 node (relative to the cgroup-v2 mount) under which a
339/// writable root host daemon roots container cgroups. Kept deliberately
340/// OUTSIDE the daemon's own systemd unit cgroup (`/system.slice/zlayer.service`)
341/// so that containers which survive a daemon stop (`KillMode=process`) never
342/// turn the unit's cgroup into a populated inner node. A populated inner node
343/// makes systemd's re-fork of the daemon fail with `EBUSY`
344/// (`Failed to spawn executor: Device or resource busy` / `Result: resources`),
345/// wedging the restart loop until the orphans happen to die. Mirrors how
346/// Docker/containerd root containers under their own top-level hierarchy
347/// (`/sys/fs/cgroup/docker/...`) rather than under their service unit.
348///
349/// The name has no `.slice`/`.scope` suffix so systemd treats it as foreign
350/// and never tries to reconcile or prune it.
351#[cfg(target_os = "linux")]
352const HOST_CONTAINER_ROOT: &str = "/zlayer";
353
354/// Controllers delegated down the host container hierarchy so libcontainer can
355/// apply cpu/memory/pids/io limits on the leaf container cgroup. Only those
356/// actually available at each level (per `cgroup.controllers`) are enabled, so
357/// a host missing a controller degrades gracefully instead of erroring.
358#[cfg(target_os = "linux")]
359const HOST_CGROUP_CONTROLLERS: &[&str] = &["cpu", "cpuset", "io", "memory", "pids"];
360
361/// Pure path computation: the host-mode container parent, `<root>/containers`,
362/// relative to the cgroup-v2 mount.
363#[cfg(target_os = "linux")]
364#[must_use]
365fn compute_host_container_parent() -> String {
366 format!("{HOST_CONTAINER_ROOT}/containers")
367}
368
369/// Enable every wanted controller that is actually available at `dir`
370/// (a `/sys/fs/cgroup/...` path) by writing `+<ctrl>` tokens to its
371/// `cgroup.subtree_control`. Best-effort: filtering to available controllers
372/// avoids the `EINVAL` a single unavailable token would cause, and any write
373/// error is ignored (libcontainer will surface a real failure later if a
374/// required controller is genuinely missing).
375#[cfg(target_os = "linux")]
376fn enable_available_controllers(dir: &str) {
377 let available =
378 std::fs::read_to_string(format!("{dir}/cgroup.controllers")).unwrap_or_default();
379 let tokens: Vec<String> = HOST_CGROUP_CONTROLLERS
380 .iter()
381 .filter(|c| available.split_whitespace().any(|a| a == **c))
382 .map(|c| format!("+{c}"))
383 .collect();
384 if tokens.is_empty() {
385 return;
386 }
387 let _ = std::fs::write(format!("{dir}/cgroup.subtree_control"), tokens.join(" "));
388}
389
390/// Ensure the top-level host container hierarchy exists and has controllers
391/// delegated, returning the container parent path (`/zlayer/containers`,
392/// relative to the cgroup-v2 mount) for libcontainer's `cgroupsPath`.
393///
394/// Only meaningful when the daemon can write the cgroup-v2 root (root host
395/// daemon — `DaemonCapabilities::can_write_cgroup_root`). Returns `None` on
396/// non-Linux, or when the mkdir fails (e.g. a read-only `/sys/fs/cgroup`),
397/// in which case callers fall back to in-scope placement.
398///
399/// Unlike [`ensure_daemon_leaf_and_container_parent`], this does NOT migrate
400/// the daemon PID: with containers rooted outside the unit cgroup, the unit
401/// cgroup stays a clean leaf that systemd can always re-attach to on restart,
402/// so no `init` leaf split is needed.
403#[cfg(target_os = "linux")]
404#[must_use]
405pub fn ensure_host_container_parent() -> Option<String> {
406 let mount = "/sys/fs/cgroup";
407 let containers = compute_host_container_parent();
408 let root_fs = format!("{mount}{HOST_CONTAINER_ROOT}");
409 let containers_fs = format!("{mount}{containers}");
410
411 for dir in [&root_fs, &containers_fs] {
412 match std::fs::create_dir_all(dir) {
413 Ok(()) => {}
414 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
415 Err(_) => return None,
416 }
417 }
418
419 // Delegate controllers down both levels so libcontainer can set limits on
420 // the leaf `<root>/containers/<id>` cgroup it creates.
421 enable_available_controllers(&root_fs);
422 enable_available_controllers(&containers_fs);
423
424 Some(containers)
425}
426
427#[cfg(not(target_os = "linux"))]
428#[must_use]
429pub fn ensure_host_container_parent() -> Option<String> {
430 None
431}
432
433/// Depth-first remove a cgroup-v2 directory tree rooted at `dir`.
434///
435/// A cgroup-v2 parent cannot be `rmdir`'d while it still has child cgroups, so
436/// child directories are removed first (post-order). Best-effort throughout:
437/// a `NotFound` is treated as success (idempotent), and any other error is
438/// logged at `warn!` but does not abort the recursion — reaping as many leaves
439/// as possible is better than bailing on the first `EBUSY`.
440#[cfg(target_os = "linux")]
441fn remove_cgroup_tree(dir: &std::path::Path) {
442 // Best-effort: evacuate any survivors before attempting rmdir. `cgroup.kill`
443 // (kernel >= 5.14) SIGKILLs the whole subtree atomically; ignore failure on
444 // older kernels or when the file is absent.
445 let _ = std::fs::write(dir.join("cgroup.kill"), "1");
446
447 match std::fs::read_dir(dir) {
448 Ok(entries) => {
449 for entry in entries.flatten() {
450 let path = entry.path();
451 if entry.file_type().is_ok_and(|t| t.is_dir()) {
452 // Child cgroup: recurse first — a v2 parent can't be
453 // rmdir'd while children exist.
454 remove_cgroup_tree(&path);
455 } else {
456 // On real cgroupfs the control files (cgroup.procs, etc.)
457 // are removed implicitly by rmdir, so this is normally a
458 // NotFound no-op; on a plain filesystem (and in tests) it
459 // unlinks the leftover so the dir can be removed.
460 let _ = std::fs::remove_file(&path);
461 }
462 }
463 }
464 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
465 Err(e) => {
466 tracing::warn!(cgroup = %dir.display(), error = %e, "cgroup read_dir failed");
467 }
468 }
469
470 match std::fs::remove_dir(dir) {
471 Ok(()) => {}
472 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
473 Err(e) => {
474 tracing::warn!(cgroup = %dir.display(), error = %e, "cgroup rmdir failed");
475 }
476 }
477}
478
479/// Reap the host-mode leaf cgroup for `container_id` under `base`
480/// (`<base>/zlayer/containers/<container_id>`), depth-first.
481///
482/// Split out from [`remove_host_container_cgroup`] so the recursion can be
483/// exercised by a unit test against a temp directory instead of the real
484/// `/sys/fs/cgroup` mount. Idempotent: a missing leaf is a no-op.
485#[cfg(target_os = "linux")]
486fn remove_host_container_cgroup_at(base: &str, container_id: &str) {
487 let leaf = std::path::PathBuf::from(base).join(format!("zlayer/containers/{container_id}"));
488 if !leaf.exists() {
489 return;
490 }
491 remove_cgroup_tree(&leaf);
492}
493
494/// Best-effort removal of the host-mode container cgroup at
495/// `/sys/fs/cgroup/zlayer/containers/<container_id>`.
496///
497/// libcontainer's `delete()` normally reaps the leaf cgroup, but
498/// systemd-cgroup races and cgroup-v2 unified hiccups can leave a stale, empty
499/// directory behind. Because the next `create_container` rebuilds the same
500/// `<root>/containers/<id>` path, that orphan trips libcontainer's `build()`
501/// with `could not delete` on restart/scale. This reaps it directly at the
502/// real path (the old `read_dir`-the-mount scan never matched two levels down).
503///
504/// Idempotent (`NotFound` is ignored) and best-effort (`EBUSY`/other errors are
505/// logged, not propagated). No-op on non-Linux.
506#[cfg(target_os = "linux")]
507pub fn remove_host_container_cgroup(container_id: &str) {
508 remove_host_container_cgroup_at("/sys/fs/cgroup", container_id);
509}
510
511#[cfg(not(target_os = "linux"))]
512pub fn remove_host_container_cgroup(_container_id: &str) {}
513
514#[cfg(target_os = "linux")]
515fn probe_can_write_cgroup_root() -> bool {
516 use std::ffi::CString;
517
518 let Ok(path) = CString::new("/sys/fs/cgroup/cgroup.subtree_control") else {
519 return false;
520 };
521 // SAFETY: access(2) is a read-only syscall that takes a pointer to a
522 // NUL-terminated C string. The kernel does not retain the pointer.
523 #[allow(unsafe_code)]
524 let rc = unsafe { libc::access(path.as_ptr(), libc::W_OK) };
525 rc == 0
526}
527
528#[cfg(not(target_os = "linux"))]
529fn probe_can_write_cgroup_root() -> bool {
530 false
531}
532
533/// `CAP_SYS_ADMIN` bit (21) in the Linux capability bitmask. Required to call
534/// `mount(2)` for the overlay probe.
535#[cfg(target_os = "linux")]
536const CAP_SYS_ADMIN_BIT: u64 = 1 << 21;
537
538/// `true` if `CAP_SYS_ADMIN` is in the process's *effective* set. Same
539/// `/proc/self/status` `CapEff:` parse as [`probe_has_cap_net_admin`] — the
540/// effective set, not the bounding set, is what `mount(2)` actually checks.
541#[cfg(target_os = "linux")]
542fn probe_has_cap_sys_admin() -> bool {
543 let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
544 return false;
545 };
546 for line in status.lines() {
547 if let Some(hex) = line.strip_prefix("CapEff:") {
548 if let Ok(eff) = u64::from_str_radix(hex.trim(), 16) {
549 return eff & CAP_SYS_ADMIN_BIT != 0;
550 }
551 return false;
552 }
553 }
554 false
555}
556
557/// Pure parser for `/proc/filesystems`: `true` if `overlay` is a registered
558/// filesystem. Each line is either `\t<fs>` or `nodev\t<fs>`; overlay is a
559/// `nodev` filesystem. Split out so the logic is unit-testable without the
560/// host's real `/proc`.
561#[cfg(target_os = "linux")]
562fn proc_filesystems_has_overlay(content: &str) -> bool {
563 content
564 .lines()
565 .any(|line| line.split_whitespace().next_back() == Some("overlay"))
566}
567
568/// Probe whether the daemon can use an overlayfs rootfs.
569///
570/// Returns `true` iff ALL hold:
571/// 1. the daemon is root OR holds effective `CAP_SYS_ADMIN` (needed for
572/// `mount(2)`),
573/// 2. `overlay` is registered in `/proc/filesystems`, and
574/// 3. a real probe overlay mount in a fresh temp dir succeeds (and is then
575/// immediately unmounted).
576///
577/// The mount probe is the authoritative signal — kernels can list `overlay`
578/// yet reject the mount (e.g. inside an unprivileged userns, or on a backing
579/// filesystem overlay won't accept). Doing a real mount+unmount once at startup
580/// is cheap and removes the guesswork.
581#[cfg(target_os = "linux")]
582fn probe_overlayfs_rootfs_available(is_root: bool) -> bool {
583 if !is_root && !probe_has_cap_sys_admin() {
584 return false;
585 }
586 let Ok(content) = std::fs::read_to_string("/proc/filesystems") else {
587 return false;
588 };
589 if !proc_filesystems_has_overlay(&content) {
590 return false;
591 }
592 probe_overlay_mount_roundtrip()
593}
594
595#[cfg(not(target_os = "linux"))]
596fn probe_overlayfs_rootfs_available(_is_root: bool) -> bool {
597 false
598}
599
600/// Attempt a throwaway overlay mount (lower+upper+work+merged, all under one
601/// temp dir) and immediately unmount it. Returns `true` only if both the mount
602/// and unmount succeed. Best-effort cleanup of the temp dir on every path.
603#[cfg(target_os = "linux")]
604fn probe_overlay_mount_roundtrip() -> bool {
605 use nix::mount::{mount, umount2, MntFlags, MsFlags};
606
607 let Ok(base) = tempfile::Builder::new()
608 .prefix("zlayer-ovl-probe-")
609 .tempdir()
610 else {
611 return false;
612 };
613 let lower = base.path().join("lower");
614 let upper = base.path().join("upper");
615 let work = base.path().join("work");
616 let merged = base.path().join("merged");
617 for d in [&lower, &upper, &work, &merged] {
618 if std::fs::create_dir_all(d).is_err() {
619 return false;
620 }
621 }
622
623 let opts = format!(
624 "lowerdir={},upperdir={},workdir={}",
625 lower.display(),
626 upper.display(),
627 work.display()
628 );
629
630 let mounted = mount(
631 Some("overlay"),
632 &merged,
633 Some("overlay"),
634 MsFlags::empty(),
635 Some(opts.as_str()),
636 )
637 .is_ok();
638
639 if !mounted {
640 return false;
641 }
642
643 // Unmount; lazy-detach as a fallback so the probe never leaves a mount
644 // behind even if the eager umount races. tempdir Drop then removes the
645 // tree. The probe is "available" only if we both mounted AND cleaned up.
646 umount2(&merged, MntFlags::empty()).is_ok() || umount2(&merged, MntFlags::MNT_DETACH).is_ok()
647}
648
649/// Locate an executable named `name` on `PATH`, returning its full path.
650///
651/// Pure helper (a `:`-split scan of the `PATH` env var) so the probe logic can
652/// be unit-tested by passing an explicit `path_var`. A candidate must exist and
653/// be a regular file or symlink; the executable bit is not checked here (the
654/// later spawn surfaces a non-executable file as a real error). Linux-only —
655/// the fuse path is Linux-only.
656#[cfg(target_os = "linux")]
657fn which_in(name: &str, path_var: &str) -> Option<std::path::PathBuf> {
658 if name.is_empty() {
659 return None;
660 }
661 for dir in path_var.split(':').filter(|d| !d.is_empty()) {
662 let candidate = std::path::Path::new(dir).join(name);
663 if candidate.exists() {
664 return Some(candidate);
665 }
666 }
667 None
668}
669
670/// `fuse-overlayfs` binary path, resolved from the process `PATH`. Linux-only.
671#[cfg(target_os = "linux")]
672fn fuse_overlayfs_binary() -> Option<std::path::PathBuf> {
673 let path_var = std::env::var("PATH").ok()?;
674 which_in("fuse-overlayfs", &path_var)
675}
676
677/// The fusermount helper to use for unmounting a `fuse-overlayfs` mount,
678/// preferring the FUSE3 `fusermount3` and falling back to `fusermount`.
679/// Returns the resolved binary path, or `None` if neither is on `PATH`.
680/// Linux-only.
681#[cfg(target_os = "linux")]
682#[must_use]
683pub fn fusermount_binary() -> Option<std::path::PathBuf> {
684 let path_var = std::env::var("PATH").ok()?;
685 which_in("fusermount3", &path_var).or_else(|| which_in("fusermount", &path_var))
686}
687
688#[cfg(not(target_os = "linux"))]
689#[must_use]
690pub fn fusermount_binary() -> Option<std::path::PathBuf> {
691 None
692}
693
694/// `true` if `/dev/fuse` can be opened read/write — the FUSE control device a
695/// userspace `fuse-overlayfs` daemon needs to back its mount. Opening it is
696/// benign and allocates no FUSE connection; the fd is dropped immediately. Any
697/// error (missing node, no perms, module not loaded) means rootless fuse-overlay
698/// is unusable. Linux-only.
699#[cfg(target_os = "linux")]
700fn probe_dev_fuse_available() -> bool {
701 std::fs::OpenOptions::new()
702 .read(true)
703 .write(true)
704 .open("/dev/fuse")
705 .is_ok()
706}
707
708/// Probe whether the daemon can use a ROOTLESS `fuse-overlayfs` rootfs.
709///
710/// Returns `true` iff ALL hold:
711/// 1. the `fuse-overlayfs` binary is on `PATH`,
712/// 2. `/dev/fuse` is openable r/w, and
713/// 3. a real probe `fuse-overlayfs` mount in a fresh temp dir succeeds (and is
714/// then immediately unmounted via `fusermount`).
715///
716/// Deliberately independent of root / `CAP_SYS_ADMIN`: this is the path that
717/// lets an unprivileged daemon still get shared-layer dedup. The mount probe is
718/// authoritative — a host can have the binary and `/dev/fuse` yet reject the
719/// mount (e.g. no `user_allow_other`, a hardened FUSE sysctl, or a sandbox), so
720/// a real mount+unmount once at startup removes the guesswork. Mirrors the
721/// kernel-overlay probe's "actually do it once" philosophy.
722#[cfg(target_os = "linux")]
723fn probe_fuse_overlayfs_rootfs_available() -> bool {
724 let Some(bin) = fuse_overlayfs_binary() else {
725 return false;
726 };
727 if !probe_dev_fuse_available() {
728 return false;
729 }
730 let Some(fusermount) = fusermount_binary() else {
731 return false;
732 };
733 probe_fuse_overlay_mount_roundtrip(&bin, &fusermount)
734}
735
736#[cfg(not(target_os = "linux"))]
737fn probe_fuse_overlayfs_rootfs_available() -> bool {
738 false
739}
740
741/// Attempt a throwaway rootless `fuse-overlayfs` mount and immediately unmount
742/// it. Returns `true` only if both the mount and the unmount succeed. The
743/// backing FUSE daemon self-daemonizes (reparenting to PID 1), so the spawned
744/// `fuse-overlayfs` process returns promptly and we wait on it. Best-effort
745/// cleanup of the temp dir on every path. Linux-only.
746#[cfg(target_os = "linux")]
747fn probe_fuse_overlay_mount_roundtrip(bin: &std::path::Path, fusermount: &std::path::Path) -> bool {
748 let Ok(base) = tempfile::Builder::new()
749 .prefix("zlayer-fuse-ovl-probe-")
750 .tempdir()
751 else {
752 return false;
753 };
754 let lower = base.path().join("lower");
755 let upper = base.path().join("upper");
756 let work = base.path().join("work");
757 let merged = base.path().join("merged");
758 for d in [&lower, &upper, &work, &merged] {
759 if std::fs::create_dir_all(d).is_err() {
760 return false;
761 }
762 }
763
764 let opts = format!(
765 "lowerdir={},upperdir={},workdir={}",
766 lower.display(),
767 upper.display(),
768 work.display()
769 );
770
771 let mounted = std::process::Command::new(bin)
772 .arg("-o")
773 .arg(&opts)
774 .arg(&merged)
775 .stdin(std::process::Stdio::null())
776 .stdout(std::process::Stdio::null())
777 .stderr(std::process::Stdio::null())
778 .status()
779 .is_ok_and(|s| s.success());
780
781 if !mounted {
782 return false;
783 }
784
785 // Unmount via fusermount; tempdir Drop then removes the tree. The probe is
786 // "available" only if we both mounted AND cleaned up.
787 std::process::Command::new(fusermount)
788 .arg("-u")
789 .arg(&merged)
790 .stdin(std::process::Stdio::null())
791 .stdout(std::process::Stdio::null())
792 .stderr(std::process::Stdio::null())
793 .status()
794 .is_ok_and(|s| s.success())
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 #[test]
802 fn probe_does_not_panic_and_is_nested_agrees_with_cgroup_parent() {
803 let caps = DaemonCapabilities::probe();
804 assert_eq!(caps.is_nested, caps.cgroup_parent.is_some());
805 }
806
807 /// Pure classifier reproducing the logic in `probe()`. Kept in the test
808 /// module so the table below can assert behaviour without depending on
809 /// the host's actual capability state.
810 #[allow(clippy::fn_params_excessive_bools)]
811 fn classify(
812 is_nested: bool,
813 can_write_cgroup_root: bool,
814 has_cap_net_admin: bool,
815 tun_device_available: bool,
816 cgroup_parent_is_some: bool,
817 ) -> DaemonMode {
818 if !is_nested && can_write_cgroup_root && has_cap_net_admin && tun_device_available {
819 DaemonMode::Full
820 } else if can_write_cgroup_root || cgroup_parent_is_some {
821 DaemonMode::NestedAdaptive
822 } else {
823 DaemonMode::Degraded
824 }
825 }
826
827 #[test]
828 fn effective_mode_full_requires_all_four_signals() {
829 // Full: every signal must be set the right way.
830 assert_eq!(
831 classify(false, true, true, true, false),
832 DaemonMode::Full,
833 "all four signals set should be Full"
834 );
835 // Drop any single signal and Full must no longer apply.
836 assert_ne!(classify(true, true, true, true, true), DaemonMode::Full);
837 assert_ne!(classify(false, false, true, true, false), DaemonMode::Full);
838 assert_ne!(classify(false, true, false, true, false), DaemonMode::Full);
839 assert_ne!(classify(false, true, true, false, false), DaemonMode::Full);
840 }
841
842 #[test]
843 fn effective_mode_nested_adaptive_when_writable_or_has_parent() {
844 // Writable root but missing other Full signals → NestedAdaptive.
845 assert_eq!(
846 classify(false, true, false, false, false),
847 DaemonMode::NestedAdaptive
848 );
849 // Nested under a parent cgroup, no other signals → NestedAdaptive.
850 assert_eq!(
851 classify(true, false, false, false, true),
852 DaemonMode::NestedAdaptive
853 );
854 }
855
856 #[test]
857 fn effective_mode_degraded_when_no_writable_path() {
858 // No root write, no parent, nothing usable.
859 assert_eq!(
860 classify(false, false, false, false, false),
861 DaemonMode::Degraded
862 );
863 // is_nested=true but no parent and no root write — still Degraded
864 // (the is_nested signal alone, without a resolved parent, does not
865 // give us a writable cgroup to anchor under).
866 assert_eq!(
867 classify(true, false, false, false, false),
868 DaemonMode::Degraded
869 );
870 }
871
872 #[test]
873 fn overlay_fallback_none_only_when_both_present() {
874 assert!(super::capability_overlay_fallback(true, true).is_none());
875 }
876
877 #[test]
878 fn overlay_fallback_reports_missing_cap_net_admin() {
879 let reason = super::capability_overlay_fallback(false, true).expect("should fall back");
880 assert!(reason.contains("CAP_NET_ADMIN"));
881 }
882
883 #[test]
884 fn overlay_fallback_reports_missing_tun() {
885 let reason = super::capability_overlay_fallback(true, false).expect("should fall back");
886 assert!(reason.contains("/dev/net/tun"));
887 }
888
889 #[test]
890 fn overlay_fallback_reports_both_missing() {
891 let reason = super::capability_overlay_fallback(false, false).expect("should fall back");
892 assert!(reason.contains("CAP_NET_ADMIN"));
893 assert!(reason.contains("/dev/net/tun"));
894 }
895
896 #[test]
897 fn rootless_overlay_requires_nonroot_no_cap_tun_and_pasta() {
898 // Happy path: non-root, no cap, tun present, pasta present.
899 assert!(super::can_rootless_overlay(false, false, true, true));
900 }
901
902 #[test]
903 fn rootless_overlay_rejected_when_root() {
904 assert!(!super::can_rootless_overlay(true, false, true, true));
905 }
906
907 #[test]
908 fn rootless_overlay_rejected_when_already_has_cap_net_admin() {
909 assert!(!super::can_rootless_overlay(false, true, true, true));
910 }
911
912 #[test]
913 fn rootless_overlay_rejected_without_tun() {
914 assert!(!super::can_rootless_overlay(false, false, false, true));
915 }
916
917 #[test]
918 fn rootless_overlay_rejected_without_pasta() {
919 assert!(!super::can_rootless_overlay(false, false, true, false));
920 }
921
922 #[test]
923 fn serializes_round_trip_via_serde_json() {
924 let caps = DaemonCapabilities::probe();
925 let json = serde_json::to_string(&caps).expect("serialize");
926 let parsed: DaemonCapabilities = serde_json::from_str(&json).expect("deserialize");
927 assert_eq!(parsed.is_root, caps.is_root);
928 assert_eq!(parsed.is_nested, caps.is_nested);
929 assert_eq!(parsed.cgroup_parent, caps.cgroup_parent);
930 assert_eq!(parsed.can_write_cgroup_root, caps.can_write_cgroup_root);
931 assert_eq!(parsed.has_cap_net_admin, caps.has_cap_net_admin);
932 assert_eq!(parsed.tun_device_available, caps.tun_device_available);
933 assert_eq!(
934 parsed.overlayfs_rootfs_available,
935 caps.overlayfs_rootfs_available
936 );
937 assert_eq!(
938 parsed.fuse_overlayfs_rootfs_available,
939 caps.fuse_overlayfs_rootfs_available
940 );
941 assert_eq!(parsed.effective_mode, caps.effective_mode);
942 }
943
944 #[cfg(target_os = "linux")]
945 mod fuse_probe {
946 use super::super::{fusermount_binary, which_in};
947
948 #[test]
949 fn which_in_finds_binary_in_first_matching_dir() {
950 // Build a temp dir holding a fake binary and confirm the PATH scan
951 // resolves it to the full path.
952 let tmp = tempfile::tempdir().unwrap();
953 let bin = tmp.path().join("fuse-overlayfs");
954 std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
955 let other = tempfile::tempdir().unwrap();
956 // PATH: a non-matching dir first, then the dir with the binary.
957 let path_var = format!("{}:{}", other.path().display(), tmp.path().display());
958 assert_eq!(
959 which_in("fuse-overlayfs", &path_var).as_deref(),
960 Some(bin.as_path())
961 );
962 }
963
964 #[test]
965 fn which_in_none_when_absent_and_ignores_empty_segments() {
966 let tmp = tempfile::tempdir().unwrap();
967 // Leading/trailing/empty colon segments must be skipped, not joined
968 // against (which would otherwise resolve `/fuse-overlayfs`).
969 let path_var = format!(":{}:", tmp.path().display());
970 assert!(which_in("fuse-overlayfs", &path_var).is_none());
971 // An empty name never resolves.
972 assert!(which_in("", &path_var).is_none());
973 }
974
975 #[test]
976 fn fusermount_binary_resolves_or_none_without_panic() {
977 // On this box one of fusermount3/fusermount is typically present,
978 // but we only assert the call is total (no panic) and, when it does
979 // resolve, that it points at an existing file.
980 if let Some(p) = fusermount_binary() {
981 assert!(
982 p.exists(),
983 "resolved fusermount must exist: {}",
984 p.display()
985 );
986 }
987 }
988 }
989
990 #[cfg(target_os = "linux")]
991 mod proc_filesystems {
992 use super::super::proc_filesystems_has_overlay;
993
994 #[test]
995 fn detects_overlay_as_nodev_filesystem() {
996 // Real-world shape: nodev entries are tab-indented with a "nodev"
997 // marker; overlay is one of them.
998 let content = "nodev\tsysfs\nnodev\ttmpfs\nnodev\toverlay\n\text4\n";
999 assert!(proc_filesystems_has_overlay(content));
1000 }
1001
1002 #[test]
1003 fn absent_when_overlay_not_listed() {
1004 let content = "nodev\tsysfs\nnodev\ttmpfs\n\text4\n\txfs\n";
1005 assert!(!proc_filesystems_has_overlay(content));
1006 }
1007
1008 #[test]
1009 fn does_not_match_substring_overlayfs() {
1010 // A different fs whose name merely contains "overlay" must not match
1011 // (we compare the whole final token, not a substring).
1012 let content = "nodev\toverlayfs2\n\text4\n";
1013 assert!(!proc_filesystems_has_overlay(content));
1014 }
1015
1016 #[test]
1017 fn empty_input_is_false() {
1018 assert!(!proc_filesystems_has_overlay(""));
1019 }
1020 }
1021
1022 /// The overlay probe must not panic and must be internally consistent:
1023 /// availability implies the daemon is root or holds `CAP_SYS_ADMIN`. The
1024 /// concrete bool depends on how the test runs (root vs not), so we only
1025 /// assert the implication, not a fixed value.
1026 #[cfg(target_os = "linux")]
1027 #[test]
1028 fn overlay_probe_consistent_with_privilege() {
1029 let caps = DaemonCapabilities::probe();
1030 if caps.overlayfs_rootfs_available {
1031 assert!(
1032 caps.is_root || super::probe_has_cap_sys_admin(),
1033 "overlay availability must imply root or CAP_SYS_ADMIN"
1034 );
1035 }
1036 }
1037
1038 #[test]
1039 fn daemon_mode_serde_uses_snake_case() {
1040 assert_eq!(
1041 serde_json::to_string(&DaemonMode::Full).unwrap(),
1042 "\"full\""
1043 );
1044 assert_eq!(
1045 serde_json::to_string(&DaemonMode::NestedAdaptive).unwrap(),
1046 "\"nested_adaptive\""
1047 );
1048 assert_eq!(
1049 serde_json::to_string(&DaemonMode::Degraded).unwrap(),
1050 "\"degraded\""
1051 );
1052 }
1053
1054 #[cfg(target_os = "linux")]
1055 mod target_parent {
1056 use super::super::compute_target_parent;
1057
1058 #[test]
1059 fn idempotent_when_already_under_init() {
1060 // Pre-fix path: scope is the systemd-run scope itself.
1061 assert_eq!(
1062 compute_target_parent(
1063 "/user.slice/user-1000.slice/user@1000.service/app.slice/run-p123.scope"
1064 ),
1065 "/user.slice/user-1000.slice/user@1000.service/app.slice/run-p123.scope/containers"
1066 );
1067 // Already migrated: scope ends with /init — strip and re-anchor.
1068 assert_eq!(
1069 compute_target_parent(
1070 "/user.slice/user-1000.slice/user@1000.service/app.slice/run-p123.scope/init"
1071 ),
1072 "/user.slice/user-1000.slice/user@1000.service/app.slice/run-p123.scope/containers"
1073 );
1074 // Trailing slash on either form is harmless.
1075 assert_eq!(compute_target_parent("/foo/bar/"), "/foo/bar/containers");
1076 assert_eq!(
1077 compute_target_parent("/foo/bar/init"),
1078 "/foo/bar/containers"
1079 );
1080 }
1081 }
1082
1083 #[cfg(target_os = "linux")]
1084 mod host_parent {
1085 use super::super::{compute_host_container_parent, HOST_CONTAINER_ROOT};
1086
1087 #[test]
1088 fn host_parent_is_top_level_and_outside_any_unit() {
1089 // Host-mode containers must live under a top-level node, NOT under
1090 // `/system.slice/zlayer.service/...`, so a KillMode=process
1091 // survivor can never wedge the unit's restart with EBUSY.
1092 assert_eq!(compute_host_container_parent(), "/zlayer/containers");
1093 assert!(compute_host_container_parent().starts_with(HOST_CONTAINER_ROOT));
1094 assert!(!compute_host_container_parent().contains("zlayer.service"));
1095 assert!(!compute_host_container_parent().contains(".slice"));
1096 }
1097 }
1098
1099 #[cfg(target_os = "linux")]
1100 mod host_cgroup_reap {
1101 use super::super::remove_host_container_cgroup_at;
1102 use std::fs;
1103
1104 // Reproduces the recreate bug: a previous instance left an empty
1105 // cgroup tree at `<root>/zlayer/containers/<id>` (with a nested child
1106 // cgroup, as a v2 leaf with delegated controllers can have). The reaper
1107 // must depth-first remove the children and then the leaf so the next
1108 // create_container starts from a clean slot.
1109 #[test]
1110 fn reaps_stale_empty_cgroup_tree_depth_first() {
1111 let base = tempfile::tempdir().expect("tempdir");
1112 let base_path = base.path().to_str().unwrap();
1113 let id = "zata-storage-rep-1";
1114
1115 let leaf = base.path().join(format!("zlayer/containers/{id}"));
1116 // A nested child cgroup dir — a v2 parent cannot be rmdir'd while
1117 // children exist, which is exactly what the recursion must handle.
1118 let child = leaf.join("child-scope");
1119 fs::create_dir_all(&child).expect("create nested cgroup tree");
1120 // Simulate cgroup-v2 control files present in the leaf.
1121 fs::write(leaf.join("cgroup.procs"), "").unwrap();
1122 fs::write(child.join("cgroup.procs"), "").unwrap();
1123 assert!(leaf.exists(), "precondition: stale leaf exists");
1124
1125 remove_host_container_cgroup_at(base_path, id);
1126
1127 assert!(
1128 !leaf.exists(),
1129 "stale cgroup leaf must be reaped (depth-first removal of children + leaf)"
1130 );
1131 // The `containers` parent itself is left intact (shared across ids).
1132 assert!(
1133 base.path().join("zlayer/containers").exists(),
1134 "shared containers parent must survive"
1135 );
1136 }
1137
1138 #[test]
1139 fn idempotent_when_leaf_absent() {
1140 let base = tempfile::tempdir().expect("tempdir");
1141 let base_path = base.path().to_str().unwrap();
1142 // No leaf created — must be a no-op, not a panic.
1143 remove_host_container_cgroup_at(base_path, "never-existed");
1144 }
1145 }
1146
1147 #[cfg(target_os = "linux")]
1148 mod cgroup_parser {
1149 use super::super::parse_cgroup_v2_line;
1150
1151 #[test]
1152 fn parse_cgroup_v2_root_returns_none() {
1153 assert_eq!(parse_cgroup_v2_line("0::/\n"), None);
1154 }
1155
1156 #[test]
1157 fn parse_cgroup_v2_path_returns_some() {
1158 assert_eq!(
1159 parse_cgroup_v2_line("0::/system.slice/forgejo-runner.service\n"),
1160 Some("/system.slice/forgejo-runner.service".to_string())
1161 );
1162 }
1163
1164 #[test]
1165 fn parse_cgroup_v2_hybrid_finds_v2_line() {
1166 let input = "12:devices:/user.slice\n11:memory:/user.slice\n0::/foo\n";
1167 assert_eq!(parse_cgroup_v2_line(input), Some("/foo".to_string()));
1168 }
1169
1170 #[test]
1171 fn parse_cgroup_v2_no_newline() {
1172 assert_eq!(parse_cgroup_v2_line("0::/bar"), Some("/bar".to_string()));
1173 }
1174
1175 #[test]
1176 fn parse_cgroup_v2_missing_returns_none() {
1177 assert_eq!(parse_cgroup_v2_line(""), None);
1178 }
1179 }
1180}