Skip to main content

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}