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, PathBuf};
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/// Invalidate the last clean-shutdown metadata generation before launching a
97/// box, retaining it at the one-shot replay path used by guest-init.
98///
99/// Overlay providers can expose the same entry through `merged` and `upper`.
100/// Staging is idempotent when the canonical marker is already absent: an
101/// existing replay marker is retained so a boot that failed before guest replay
102/// can be retried safely.
103pub fn stage_box_terminal_rootfs_metadata(box_dir: &Path) -> a3s_box_core::error::Result<()> {
104    let attached = attach_persistent_rootfs(box_dir)?;
105    let mut roots = Vec::<PathBuf>::new();
106    if let Some(rootfs) = attached.as_ref() {
107        roots.push(rootfs.path().to_path_buf());
108    }
109    roots.extend([
110        box_dir.join("rootfs"),
111        box_dir.join("upper"),
112        box_dir.join("merged"),
113    ]);
114    roots.sort();
115    roots.dedup();
116
117    let mut existing_roots = Vec::new();
118    for root in roots {
119        match std::fs::symlink_metadata(&root) {
120            Ok(_) => existing_roots.push(root),
121            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
122            Err(error) => return Err(error.into()),
123        }
124    }
125    stage_metadata_roots(&existing_roots)?;
126    Ok(())
127}
128
129fn stage_metadata_roots(roots: &[PathBuf]) -> std::io::Result<()> {
130    for root in roots {
131        a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(root)?;
132    }
133    Ok(())
134}
135
136/// Unmount a box's overlayfs `merged` view — best-effort and idempotent.
137///
138/// Box teardown must release this mount BEFORE removing the box dir, or
139/// `remove_dir_all` deletes *into* the live mount and fails with "Stale file
140/// handle", leaking the mount. A restart re-mounts without unmounting first, so
141/// the overlay can be stacked (mounted 2–3×); unmount in a bounded loop until
142/// `merged` is no longer a mountpoint. No-op if it was never mounted.
143pub fn unmount_box_overlay(merged: &Path) {
144    for _ in 0..8 {
145        if !is_mountpoint(merged) {
146            break;
147        }
148        if overlay::overlay_unmount(merged).is_err() {
149            break;
150        }
151    }
152}
153
154/// True if `path` is a mountpoint (its device id differs from its parent's).
155#[cfg(unix)]
156pub(crate) fn is_mountpoint(path: &Path) -> bool {
157    use std::os::unix::fs::MetadataExt;
158    match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
159        (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
160        _ => false,
161    }
162}
163
164#[cfg(not(unix))]
165pub(crate) fn is_mountpoint(_path: &Path) -> bool {
166    false
167}
168
169/// Unmount a platform-specific writable rootfs mount.
170pub fn unmount_box_rootfs(rootfs: &Path) {
171    #[cfg(target_os = "macos")]
172    {
173        // The case-sensitive provider returns `<mount>/.a3s-rootfs`, keeping
174        // APFS-created volume metadata outside the Linux tree. Accept either
175        // that data path or the mountpoint itself at cleanup call sites.
176        let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
177            rootfs.parent().unwrap_or(rootfs)
178        } else {
179            rootfs
180        };
181        if !is_mountpoint(mountpoint) {
182            return;
183        }
184        match std::process::Command::new("hdiutil")
185            .arg("detach")
186            .arg("-quiet")
187            .arg(mountpoint)
188            .status()
189        {
190            Ok(status) if status.success() => {}
191            Ok(status) => tracing::warn!(
192                path = %mountpoint.display(),
193                ?status,
194                "Failed to detach case-sensitive rootfs image"
195            ),
196            Err(error) => tracing::warn!(
197                path = %mountpoint.display(),
198                %error,
199                "Failed to run hdiutil detach"
200            ),
201        }
202    }
203
204    #[cfg(not(target_os = "macos"))]
205    let _ = rootfs;
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn persisted_exit_code_supports_each_rootfs_provider_layout() {
214        for (relative, expected) in [
215            ("upper/.a3s_exit_code", 17),
216            ("rootfs/.a3s_exit_code", 23),
217            ("rootfs/.a3s-rootfs/.a3s_exit_code", 29),
218        ] {
219            let temp = tempfile::tempdir().unwrap();
220            let path = temp.path().join(relative);
221            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
222            std::fs::write(path, format!("{expected}\n")).unwrap();
223
224            assert_eq!(read_persisted_exit_code(temp.path()), Some(expected));
225        }
226    }
227
228    #[test]
229    fn persisted_exit_code_ignores_missing_or_invalid_files() {
230        let temp = tempfile::tempdir().unwrap();
231        assert_eq!(read_persisted_exit_code(temp.path()), None);
232
233        let path = temp.path().join("rootfs/.a3s_exit_code");
234        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
235        std::fs::write(path, "not-an-exit-code").unwrap();
236        assert_eq!(read_persisted_exit_code(temp.path()), None);
237    }
238
239    #[test]
240    fn missing_path_is_not_mountpoint() {
241        let temp = tempfile::tempdir().unwrap();
242        let missing = temp.path().join("missing");
243
244        assert!(!is_mountpoint(&missing));
245    }
246
247    #[test]
248    fn unmount_overlay_noops_for_non_mountpoint() {
249        let temp = tempfile::tempdir().unwrap();
250        let merged = temp.path().join("merged");
251        std::fs::create_dir(&merged).unwrap();
252
253        unmount_box_overlay(&merged);
254
255        assert!(merged.exists());
256    }
257
258    #[test]
259    fn staging_is_idempotent_until_guest_replay_succeeds() {
260        let root = tempfile::tempdir().unwrap();
261        let terminal = root
262            .path()
263            .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/'));
264        let previous = root.path().join(
265            a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'),
266        );
267        std::fs::write(&terminal, b"clean generation").unwrap();
268
269        stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
270        stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
271
272        assert!(!terminal.exists());
273        assert_eq!(std::fs::read(previous).unwrap(), b"clean generation");
274    }
275
276    #[test]
277    fn staging_one_candidate_never_discards_an_alias_replay() {
278        let directory = tempfile::tempdir().unwrap();
279        let merged = directory.path().join("merged");
280        let upper = directory.path().join("upper");
281        std::fs::create_dir_all(&merged).unwrap();
282        std::fs::create_dir_all(&upper).unwrap();
283        let terminal_name =
284            a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/');
285        let previous_name =
286            a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/');
287        std::fs::write(merged.join(terminal_name), b"clean generation").unwrap();
288        // Models the view through `upper` immediately after the same overlay
289        // entry was renamed through `merged`.
290        std::fs::write(upper.join(previous_name), b"clean generation").unwrap();
291
292        stage_metadata_roots(&[merged.clone(), upper.clone()]).unwrap();
293
294        assert!(merged.join(previous_name).is_file());
295        assert!(upper.join(previous_name).is_file());
296    }
297}