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 baseline;
11mod builder;
12mod layout;
13pub(crate) mod overlay;
14mod provider;
15
16pub use baseline::{
17    create_diff_baseline_if_absent, walk_rootfs, RootfsFileInfo, DIFF_BASELINE_FILE,
18};
19pub use builder::RootfsBuilder;
20pub use layout::{GuestLayout, GUEST_WORKDIR};
21pub use provider::{default_provider, CopyProvider, OverlayProvider, RootfsProvider};
22
23use std::path::{Path, PathBuf};
24
25/// Read the exit code persisted by guest-init from the active writable rootfs.
26///
27/// Rootfs providers expose `/.a3s_exit_code` at different host paths: the
28/// overlay upper directory on Linux, the copied rootfs fallback, or the private
29/// data directory inside the case-sensitive APFS mount on macOS.
30pub fn read_persisted_exit_code(box_dir: &Path) -> Option<i32> {
31    let candidates = [
32        box_dir
33            .join("upper")
34            .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
35        box_dir
36            .join("rootfs")
37            .join(".a3s-rootfs")
38            .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
39        box_dir
40            .join("rootfs")
41            .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
42    ];
43
44    candidates.into_iter().find_map(|path| {
45        std::fs::read_to_string(path)
46            .ok()
47            .and_then(|contents| contents.trim().parse::<i32>().ok())
48    })
49}
50
51/// A temporarily attached persistent rootfs.
52///
53/// Dropping this guard detaches only mounts created by
54/// [`attach_persistent_rootfs`]. An already mounted rootfs is left untouched.
55pub struct AttachedRootfs {
56    path: std::path::PathBuf,
57    detach_on_drop: bool,
58}
59
60impl AttachedRootfs {
61    pub fn path(&self) -> &Path {
62        &self.path
63    }
64}
65
66impl Drop for AttachedRootfs {
67    fn drop(&mut self) {
68        if self.detach_on_drop {
69            unmount_box_rootfs(&self.path);
70        }
71    }
72}
73
74/// Attach an existing platform-backed persistent rootfs for offline access.
75///
76/// Returns `None` when the box has no platform-specific backing image. This
77/// never creates a new image, so callers cannot accidentally commit an empty
78/// filesystem when a backing image is missing.
79pub fn attach_persistent_rootfs(
80    box_dir: &Path,
81) -> a3s_box_core::error::Result<Option<AttachedRootfs>> {
82    #[cfg(target_os = "macos")]
83    {
84        let image = box_dir.join("rootfs-apfs-v2.sparseimage");
85        if !image.is_file() {
86            return Ok(None);
87        }
88        let rootfs = box_dir.join("rootfs");
89        let was_mounted = is_mountpoint(&rootfs);
90        let path = provider::CaseSensitiveApfsProvider.prepare_empty(box_dir)?;
91        Ok(Some(AttachedRootfs {
92            path,
93            detach_on_drop: !was_mounted,
94        }))
95    }
96
97    #[cfg(not(target_os = "macos"))]
98    {
99        let _ = box_dir;
100        Ok(None)
101    }
102}
103
104/// Invalidate the last clean-shutdown metadata generation before launching a
105/// box, retaining it at the one-shot replay path used by guest-init.
106///
107/// Overlay providers can expose the same entry through `merged` and `upper`.
108/// Staging is idempotent when the canonical marker is already absent: an
109/// existing replay marker is retained so a boot that failed before guest replay
110/// can be retried safely.
111pub fn stage_box_terminal_rootfs_metadata(box_dir: &Path) -> a3s_box_core::error::Result<()> {
112    let attached = attach_persistent_rootfs(box_dir)?;
113    let mut roots = Vec::<PathBuf>::new();
114    if let Some(rootfs) = attached.as_ref() {
115        roots.push(rootfs.path().to_path_buf());
116    }
117    roots.extend([
118        box_dir.join("rootfs"),
119        box_dir.join("upper"),
120        box_dir.join("merged"),
121    ]);
122    roots.sort();
123    roots.dedup();
124
125    let mut existing_roots = Vec::new();
126    for root in roots {
127        match std::fs::symlink_metadata(&root) {
128            Ok(_) => existing_roots.push(root),
129            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
130            Err(error) => return Err(error.into()),
131        }
132    }
133    stage_metadata_roots(&existing_roots)?;
134    Ok(())
135}
136
137fn stage_metadata_roots(roots: &[PathBuf]) -> std::io::Result<()> {
138    for root in roots {
139        a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(root)?;
140    }
141    Ok(())
142}
143
144/// Unmount a box's overlayfs `merged` view — best-effort and idempotent.
145///
146/// Box teardown must release this mount BEFORE removing the box dir, or
147/// `remove_dir_all` deletes *into* the live mount and fails with "Stale file
148/// handle", leaking the mount. A restart re-mounts without unmounting first, so
149/// the overlay can be stacked (mounted 2–3×); unmount in a bounded loop until
150/// `merged` is no longer a mountpoint. No-op if it was never mounted.
151pub fn unmount_box_overlay(merged: &Path) {
152    for _ in 0..8 {
153        if !is_mountpoint(merged) {
154            break;
155        }
156        if overlay::overlay_unmount(merged).is_err() {
157            break;
158        }
159    }
160}
161
162/// Fully unmount a box overlay before its writable layer is reused.
163///
164/// Unlike [`unmount_box_overlay`], this path never falls back to lazy detach:
165/// callers must not start another overlay writer until every stacked mount has
166/// been synchronously released.
167pub(crate) fn unmount_box_overlay_for_reuse(merged: &Path) -> a3s_box_core::error::Result<()> {
168    for _ in 0..8 {
169        if !is_mountpoint(merged) {
170            return Ok(());
171        }
172        overlay::overlay_unmount_for_reuse(merged)?;
173    }
174
175    if is_mountpoint(merged) {
176        return Err(a3s_box_core::error::BoxError::BuildError(format!(
177            "Overlay at {} remained mounted after synchronous cleanup",
178            merged.display()
179        )));
180    }
181    Ok(())
182}
183
184/// True if `path` is a mountpoint (its device id differs from its parent's).
185#[cfg(unix)]
186pub(crate) fn is_mountpoint(path: &Path) -> bool {
187    use std::os::unix::fs::MetadataExt;
188    match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
189        (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
190        _ => false,
191    }
192}
193
194#[cfg(not(unix))]
195pub(crate) fn is_mountpoint(_path: &Path) -> bool {
196    false
197}
198
199/// Unmount a platform-specific writable rootfs mount.
200pub fn unmount_box_rootfs(rootfs: &Path) {
201    #[cfg(target_os = "macos")]
202    {
203        // The case-sensitive provider returns `<mount>/.a3s-rootfs`, keeping
204        // APFS-created volume metadata outside the Linux tree. Accept either
205        // that data path or the mountpoint itself at cleanup call sites.
206        let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
207            rootfs.parent().unwrap_or(rootfs)
208        } else {
209            rootfs
210        };
211        if !is_mountpoint(mountpoint) {
212            return;
213        }
214        match std::process::Command::new("hdiutil")
215            .arg("detach")
216            .arg("-quiet")
217            .arg(mountpoint)
218            .status()
219        {
220            Ok(status) if status.success() => {}
221            Ok(status) => tracing::warn!(
222                path = %mountpoint.display(),
223                ?status,
224                "Failed to detach case-sensitive rootfs image"
225            ),
226            Err(error) => tracing::warn!(
227                path = %mountpoint.display(),
228                %error,
229                "Failed to run hdiutil detach"
230            ),
231        }
232    }
233
234    #[cfg(not(target_os = "macos"))]
235    let _ = rootfs;
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn persisted_exit_code_supports_each_rootfs_provider_layout() {
244        for (relative, expected) in [
245            ("upper/.a3s_exit_code", 17),
246            ("rootfs/.a3s_exit_code", 23),
247            ("rootfs/.a3s-rootfs/.a3s_exit_code", 29),
248        ] {
249            let temp = tempfile::tempdir().unwrap();
250            let path = temp.path().join(relative);
251            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
252            std::fs::write(path, format!("{expected}\n")).unwrap();
253
254            assert_eq!(read_persisted_exit_code(temp.path()), Some(expected));
255        }
256    }
257
258    #[test]
259    fn persisted_exit_code_ignores_missing_or_invalid_files() {
260        let temp = tempfile::tempdir().unwrap();
261        assert_eq!(read_persisted_exit_code(temp.path()), None);
262
263        let path = temp.path().join("rootfs/.a3s_exit_code");
264        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
265        std::fs::write(path, "not-an-exit-code").unwrap();
266        assert_eq!(read_persisted_exit_code(temp.path()), None);
267    }
268
269    #[test]
270    fn missing_path_is_not_mountpoint() {
271        let temp = tempfile::tempdir().unwrap();
272        let missing = temp.path().join("missing");
273
274        assert!(!is_mountpoint(&missing));
275    }
276
277    #[test]
278    fn unmount_overlay_noops_for_non_mountpoint() {
279        let temp = tempfile::tempdir().unwrap();
280        let merged = temp.path().join("merged");
281        std::fs::create_dir(&merged).unwrap();
282
283        unmount_box_overlay(&merged);
284
285        assert!(merged.exists());
286    }
287
288    #[test]
289    fn staging_is_idempotent_until_guest_replay_succeeds() {
290        let root = tempfile::tempdir().unwrap();
291        let terminal = root
292            .path()
293            .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/'));
294        let previous = root.path().join(
295            a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'),
296        );
297        std::fs::write(&terminal, b"clean generation").unwrap();
298
299        stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
300        stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
301
302        assert!(!terminal.exists());
303        assert_eq!(std::fs::read(previous).unwrap(), b"clean generation");
304    }
305
306    #[test]
307    fn staging_one_candidate_never_discards_an_alias_replay() {
308        let directory = tempfile::tempdir().unwrap();
309        let merged = directory.path().join("merged");
310        let upper = directory.path().join("upper");
311        std::fs::create_dir_all(&merged).unwrap();
312        std::fs::create_dir_all(&upper).unwrap();
313        let terminal_name =
314            a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/');
315        let previous_name =
316            a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/');
317        std::fs::write(merged.join(terminal_name), b"clean generation").unwrap();
318        // Models the view through `upper` immediately after the same overlay
319        // entry was renamed through `merged`.
320        std::fs::write(upper.join(previous_name), b"clean generation").unwrap();
321
322        stage_metadata_roots(&[merged.clone(), upper.clone()]).unwrap();
323
324        assert!(merged.join(previous_name).is_file());
325        assert!(upper.join(previous_name).is_file());
326    }
327
328    #[test]
329    fn staging_box_roots_clears_every_previous_exit_status() {
330        let directory = tempfile::tempdir().unwrap();
331        let box_dir = directory.path().join("box");
332        for provider_root in ["rootfs", "upper", "merged"] {
333            let root = box_dir.join(provider_root);
334            std::fs::create_dir_all(&root).unwrap();
335            std::fs::write(
336                root.join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
337                b"17\n",
338            )
339            .unwrap();
340        }
341
342        stage_box_terminal_rootfs_metadata(&box_dir).unwrap();
343
344        assert_eq!(read_persisted_exit_code(&box_dir), None);
345        for provider_root in ["rootfs", "upper", "merged"] {
346            assert!(!box_dir
347                .join(provider_root)
348                .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/'))
349                .exists());
350        }
351    }
352}