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/// Read the exit code persisted by guest-init from the active writable rootfs.
22///
23/// Rootfs providers expose `/.a3s_exit_code` at different host paths: the
24/// overlay upper directory on Linux, the copied rootfs fallback, or the private
25/// data directory inside the case-sensitive APFS mount on macOS.
26pub fn read_persisted_exit_code(box_dir: &Path) -> Option<i32> {
27    let candidates = [
28        box_dir.join("upper").join(".a3s_exit_code"),
29        box_dir
30            .join("rootfs")
31            .join(".a3s-rootfs")
32            .join(".a3s_exit_code"),
33        box_dir.join("rootfs").join(".a3s_exit_code"),
34    ];
35
36    candidates.into_iter().find_map(|path| {
37        std::fs::read_to_string(path)
38            .ok()
39            .and_then(|contents| contents.trim().parse::<i32>().ok())
40    })
41}
42
43/// A temporarily attached persistent rootfs.
44///
45/// Dropping this guard detaches only mounts created by
46/// [`attach_persistent_rootfs`]. An already mounted rootfs is left untouched.
47pub struct AttachedRootfs {
48    path: std::path::PathBuf,
49    detach_on_drop: bool,
50}
51
52impl AttachedRootfs {
53    pub fn path(&self) -> &Path {
54        &self.path
55    }
56}
57
58impl Drop for AttachedRootfs {
59    fn drop(&mut self) {
60        if self.detach_on_drop {
61            unmount_box_rootfs(&self.path);
62        }
63    }
64}
65
66/// Attach an existing platform-backed persistent rootfs for offline access.
67///
68/// Returns `None` when the box has no platform-specific backing image. This
69/// never creates a new image, so callers cannot accidentally commit an empty
70/// filesystem when a backing image is missing.
71pub fn attach_persistent_rootfs(
72    box_dir: &Path,
73) -> a3s_box_core::error::Result<Option<AttachedRootfs>> {
74    #[cfg(target_os = "macos")]
75    {
76        let image = box_dir.join("rootfs-apfs-v2.sparseimage");
77        if !image.is_file() {
78            return Ok(None);
79        }
80        let rootfs = box_dir.join("rootfs");
81        let was_mounted = is_mountpoint(&rootfs);
82        let path = provider::CaseSensitiveApfsProvider.prepare_empty(box_dir)?;
83        Ok(Some(AttachedRootfs {
84            path,
85            detach_on_drop: !was_mounted,
86        }))
87    }
88
89    #[cfg(not(target_os = "macos"))]
90    {
91        let _ = box_dir;
92        Ok(None)
93    }
94}
95
96/// Unmount a box's overlayfs `merged` view — best-effort and idempotent.
97///
98/// Box teardown must release this mount BEFORE removing the box dir, or
99/// `remove_dir_all` deletes *into* the live mount and fails with "Stale file
100/// handle", leaking the mount. A restart re-mounts without unmounting first, so
101/// the overlay can be stacked (mounted 2–3×); unmount in a bounded loop until
102/// `merged` is no longer a mountpoint. No-op if it was never mounted.
103pub fn unmount_box_overlay(merged: &Path) {
104    for _ in 0..8 {
105        if !is_mountpoint(merged) {
106            break;
107        }
108        if overlay::overlay_unmount(merged).is_err() {
109            break;
110        }
111    }
112}
113
114/// True if `path` is a mountpoint (its device id differs from its parent's).
115#[cfg(unix)]
116pub(crate) fn is_mountpoint(path: &Path) -> bool {
117    use std::os::unix::fs::MetadataExt;
118    match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
119        (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
120        _ => false,
121    }
122}
123
124#[cfg(not(unix))]
125pub(crate) fn is_mountpoint(_path: &Path) -> bool {
126    false
127}
128
129/// Unmount a platform-specific writable rootfs mount.
130pub fn unmount_box_rootfs(rootfs: &Path) {
131    #[cfg(target_os = "macos")]
132    {
133        // The case-sensitive provider returns `<mount>/.a3s-rootfs`, keeping
134        // APFS-created volume metadata outside the Linux tree. Accept either
135        // that data path or the mountpoint itself at cleanup call sites.
136        let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
137            rootfs.parent().unwrap_or(rootfs)
138        } else {
139            rootfs
140        };
141        if !is_mountpoint(mountpoint) {
142            return;
143        }
144        match std::process::Command::new("hdiutil")
145            .arg("detach")
146            .arg("-quiet")
147            .arg(mountpoint)
148            .status()
149        {
150            Ok(status) if status.success() => {}
151            Ok(status) => tracing::warn!(
152                path = %mountpoint.display(),
153                ?status,
154                "Failed to detach case-sensitive rootfs image"
155            ),
156            Err(error) => tracing::warn!(
157                path = %mountpoint.display(),
158                %error,
159                "Failed to run hdiutil detach"
160            ),
161        }
162    }
163
164    #[cfg(not(target_os = "macos"))]
165    let _ = rootfs;
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn persisted_exit_code_supports_each_rootfs_provider_layout() {
174        for (relative, expected) in [
175            ("upper/.a3s_exit_code", 17),
176            ("rootfs/.a3s_exit_code", 23),
177            ("rootfs/.a3s-rootfs/.a3s_exit_code", 29),
178        ] {
179            let temp = tempfile::tempdir().unwrap();
180            let path = temp.path().join(relative);
181            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
182            std::fs::write(path, format!("{expected}\n")).unwrap();
183
184            assert_eq!(read_persisted_exit_code(temp.path()), Some(expected));
185        }
186    }
187
188    #[test]
189    fn persisted_exit_code_ignores_missing_or_invalid_files() {
190        let temp = tempfile::tempdir().unwrap();
191        assert_eq!(read_persisted_exit_code(temp.path()), None);
192
193        let path = temp.path().join("rootfs/.a3s_exit_code");
194        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
195        std::fs::write(path, "not-an-exit-code").unwrap();
196        assert_eq!(read_persisted_exit_code(temp.path()), None);
197    }
198
199    #[test]
200    fn missing_path_is_not_mountpoint() {
201        let temp = tempfile::tempdir().unwrap();
202        let missing = temp.path().join("missing");
203
204        assert!(!is_mountpoint(&missing));
205    }
206
207    #[test]
208    fn unmount_overlay_noops_for_non_mountpoint() {
209        let temp = tempfile::tempdir().unwrap();
210        let merged = temp.path().join("merged");
211        std::fs::create_dir(&merged).unwrap();
212
213        unmount_box_overlay(&merged);
214
215        assert!(merged.exists());
216    }
217}