Skip to main content

a3s_box_runtime/rootfs/
mod.rs

1//! Guest rootfs management module.
2//!
3//! This module handles preparation and management of guest rootfs for MicroVM instances.
4//! The rootfs contains the minimal filesystem required to boot the guest agent.
5//!
6//! Two rootfs providers are available:
7//! - `CopyProvider` — full recursive copy (works everywhere)
8//! - `OverlayProvider` — Linux overlayfs mount (near-instant CoW)
9
10mod builder;
11mod layout;
12pub(crate) mod overlay;
13mod provider;
14
15pub use builder::RootfsBuilder;
16pub use layout::{GuestLayout, GUEST_WORKDIR};
17pub use provider::{default_provider, CopyProvider, OverlayProvider, RootfsProvider};
18
19use std::path::Path;
20
21/// A temporarily attached persistent rootfs.
22///
23/// Dropping this guard detaches only mounts created by
24/// [`attach_persistent_rootfs`]. An already mounted rootfs is left untouched.
25pub struct AttachedRootfs {
26    path: std::path::PathBuf,
27    detach_on_drop: bool,
28}
29
30impl AttachedRootfs {
31    pub fn path(&self) -> &Path {
32        &self.path
33    }
34}
35
36impl Drop for AttachedRootfs {
37    fn drop(&mut self) {
38        if self.detach_on_drop {
39            unmount_box_rootfs(&self.path);
40        }
41    }
42}
43
44/// Attach an existing platform-backed persistent rootfs for offline access.
45///
46/// Returns `None` when the box has no platform-specific backing image. This
47/// never creates a new image, so callers cannot accidentally commit an empty
48/// filesystem when a backing image is missing.
49pub fn attach_persistent_rootfs(
50    box_dir: &Path,
51) -> a3s_box_core::error::Result<Option<AttachedRootfs>> {
52    #[cfg(target_os = "macos")]
53    {
54        let image = box_dir.join("rootfs-apfs-v2.sparseimage");
55        if !image.is_file() {
56            return Ok(None);
57        }
58        let rootfs = box_dir.join("rootfs");
59        let was_mounted = is_mountpoint(&rootfs);
60        let path = provider::CaseSensitiveApfsProvider.prepare_empty(box_dir)?;
61        Ok(Some(AttachedRootfs {
62            path,
63            detach_on_drop: !was_mounted,
64        }))
65    }
66
67    #[cfg(not(target_os = "macos"))]
68    {
69        let _ = box_dir;
70        Ok(None)
71    }
72}
73
74/// Unmount a box's overlayfs `merged` view — best-effort and idempotent.
75///
76/// Box teardown must release this mount BEFORE removing the box dir, or
77/// `remove_dir_all` deletes *into* the live mount and fails with "Stale file
78/// handle", leaking the mount. A restart re-mounts without unmounting first, so
79/// the overlay can be stacked (mounted 2–3×); unmount in a bounded loop until
80/// `merged` is no longer a mountpoint. No-op if it was never mounted.
81pub fn unmount_box_overlay(merged: &Path) {
82    for _ in 0..8 {
83        if !is_mountpoint(merged) {
84            break;
85        }
86        if overlay::overlay_unmount(merged).is_err() {
87            break;
88        }
89    }
90}
91
92/// True if `path` is a mountpoint (its device id differs from its parent's).
93#[cfg(unix)]
94pub(crate) fn is_mountpoint(path: &Path) -> bool {
95    use std::os::unix::fs::MetadataExt;
96    match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
97        (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
98        _ => false,
99    }
100}
101
102#[cfg(not(unix))]
103pub(crate) fn is_mountpoint(_path: &Path) -> bool {
104    false
105}
106
107/// Unmount a platform-specific writable rootfs mount.
108pub fn unmount_box_rootfs(rootfs: &Path) {
109    #[cfg(target_os = "macos")]
110    {
111        // The case-sensitive provider returns `<mount>/.a3s-rootfs`, keeping
112        // APFS-created volume metadata outside the Linux tree. Accept either
113        // that data path or the mountpoint itself at cleanup call sites.
114        let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
115            rootfs.parent().unwrap_or(rootfs)
116        } else {
117            rootfs
118        };
119        if !is_mountpoint(mountpoint) {
120            return;
121        }
122        match std::process::Command::new("hdiutil")
123            .arg("detach")
124            .arg("-quiet")
125            .arg(mountpoint)
126            .status()
127        {
128            Ok(status) if status.success() => {}
129            Ok(status) => tracing::warn!(
130                path = %mountpoint.display(),
131                ?status,
132                "Failed to detach case-sensitive rootfs image"
133            ),
134            Err(error) => tracing::warn!(
135                path = %mountpoint.display(),
136                %error,
137                "Failed to run hdiutil detach"
138            ),
139        }
140    }
141
142    #[cfg(not(target_os = "macos"))]
143    let _ = rootfs;
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn missing_path_is_not_mountpoint() {
152        let temp = tempfile::tempdir().unwrap();
153        let missing = temp.path().join("missing");
154
155        assert!(!is_mountpoint(&missing));
156    }
157
158    #[test]
159    fn unmount_overlay_noops_for_non_mountpoint() {
160        let temp = tempfile::tempdir().unwrap();
161        let merged = temp.path().join("merged");
162        std::fs::create_dir(&merged).unwrap();
163
164        unmount_box_overlay(&merged);
165
166        assert!(merged.exists());
167    }
168}