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//! Rootfs staging providers are selected by host capability:
7//! - `CopyProvider` — full recursive copy (works everywhere)
8//! - `OverlayProvider` — Linux overlayfs mount (near-instant CoW)
9//! - guest-native ext4 on macOS, with case-sensitive APFS for compatibility
10//!
11//! A provider finalizes the staging tree into the directory or guest-native
12//! block source handed to the VMM.
13
14#[cfg(target_os = "macos")]
15mod apfs;
16mod baseline;
17mod builder;
18#[cfg(unix)]
19mod ext4;
20#[cfg(any(target_os = "macos", all(unix, test)))]
21mod ext4_artifact;
22#[cfg(any(target_os = "macos", all(unix, test)))]
23mod ext4_cache;
24#[cfg(target_os = "macos")]
25mod guest_native_ext4;
26#[cfg(target_os = "macos")]
27mod guest_native_migration;
28mod layout;
29#[cfg(any(target_os = "macos", all(unix, test)))]
30mod oci_ext4;
31pub(crate) mod overlay;
32mod provider;
33mod staging_path;
34
35pub use baseline::{
36    create_diff_baseline_if_absent, guest_diff_baseline_required, publish_guest_diff_baseline,
37    walk_rootfs, RootfsFileInfo, DIFF_BASELINE_FILE,
38};
39pub use builder::RootfsBuilder;
40#[cfg(unix)]
41pub use ext4::{
42    publish_ext4_artifact, Ext4Artifact, Ext4ArtifactManifest, Ext4ArtifactOptions,
43    EXT4_ARTIFACT_SCHEMA, EXT4_BUILDER_ID,
44};
45#[cfg(target_os = "macos")]
46pub(crate) use ext4_cache::{Ext4ArtifactCache, Ext4CacheIdentity};
47#[cfg(target_os = "macos")]
48pub use guest_native_ext4::GuestNativeExt4Provider;
49pub use layout::{GuestLayout, GUEST_WORKDIR};
50pub use provider::{
51    default_provider, default_provider_for_box, CopyProvider, OverlayProvider, ResumedRootfs,
52    RootfsArtifactCacheOptions, RootfsFinalizeOptions, RootfsOciPrepareOptions, RootfsProvider,
53    RootfsResumeOptions,
54};
55pub(crate) use provider::{default_provider_for_boot, default_provider_for_box_boot};
56pub(crate) use staging_path::{
57    ensure_directory_transport_is_lossless, host_staging_path, logical_path_for_staged_child,
58    staging_path_map,
59};
60
61use std::io::Read;
62use std::path::{Path, PathBuf};
63
64use a3s_box_core::error::{BoxError, Result};
65use a3s_box_core::guest_exec::{
66    GuestTerminalStatus, GUEST_TERMINAL_STATUS_FILE_NAME, MAX_GUEST_TERMINAL_STATUS_BYTES,
67};
68
69enum TerminalStatusRead {
70    Absent,
71    PendingOrInvalid,
72    Complete(GuestTerminalStatus),
73}
74
75fn read_guest_terminal_status(box_dir: &Path) -> TerminalStatusRead {
76    let path = box_dir
77        .join("runtime-control")
78        .join(GUEST_TERMINAL_STATUS_FILE_NAME);
79    let metadata = match std::fs::symlink_metadata(&path) {
80        Ok(metadata) => metadata,
81        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
82            return TerminalStatusRead::Absent;
83        }
84        Err(_) => return TerminalStatusRead::PendingOrInvalid,
85    };
86    if !metadata.is_file()
87        || metadata.file_type().is_symlink()
88        || metadata.len() > MAX_GUEST_TERMINAL_STATUS_BYTES as u64
89    {
90        return TerminalStatusRead::PendingOrInvalid;
91    }
92
93    let mut options = std::fs::OpenOptions::new();
94    options.read(true);
95    #[cfg(unix)]
96    {
97        use std::os::unix::fs::OpenOptionsExt;
98        options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
99    }
100    let Ok(file) = options.open(&path) else {
101        return TerminalStatusRead::PendingOrInvalid;
102    };
103    let mut bytes = Vec::with_capacity(metadata.len() as usize);
104    if file
105        .take(MAX_GUEST_TERMINAL_STATUS_BYTES as u64 + 1)
106        .read_to_end(&mut bytes)
107        .is_err()
108        || bytes.is_empty()
109        || bytes.len() > MAX_GUEST_TERMINAL_STATUS_BYTES
110    {
111        return TerminalStatusRead::PendingOrInvalid;
112    }
113    let Ok(status) = serde_json::from_slice::<GuestTerminalStatus>(&bytes) else {
114        return TerminalStatusRead::PendingOrInvalid;
115    };
116    if status.validate().is_err() {
117        return TerminalStatusRead::PendingOrInvalid;
118    }
119    TerminalStatusRead::Complete(status)
120}
121
122/// Return whether the current guest generation completed a clean block-root
123/// handoff after publishing its terminal workload status.
124pub(crate) fn guest_rootfs_handoff_complete(box_dir: &Path) -> bool {
125    matches!(
126        read_guest_terminal_status(box_dir),
127        TerminalStatusRead::Complete(GuestTerminalStatus {
128            rootfs_quiesced: true,
129            ..
130        })
131    )
132}
133
134/// Read the exit code persisted by guest-init.
135///
136/// New MicroVMs publish through the private terminal-control sidecar. Legacy
137/// providers expose `/.a3s_exit_code` at the overlay upper directory, copied
138/// rootfs, or case-sensitive APFS data directory.
139pub fn read_persisted_exit_code(box_dir: &Path) -> Option<i32> {
140    resolve_workload_exit_code(box_dir, None)
141}
142
143/// Resolve a workload exit code without treating a clean provider shutdown as
144/// proof that the guest workload succeeded.
145///
146/// Once the private terminal channel is staged, an empty or invalid status
147/// means the guest never published a result. A nonzero provider status remains
148/// useful crash evidence, but a provider zero is not substituted for missing
149/// guest state.
150pub fn resolve_workload_exit_code(box_dir: &Path, provider_exit_code: Option<i32>) -> Option<i32> {
151    match read_guest_terminal_status(box_dir) {
152        TerminalStatusRead::Complete(status) => return Some(status.exit_code),
153        // A staged-but-empty terminal file belongs to the current generation.
154        // Never fall back to a stale rootfs marker or a successful shim status.
155        TerminalStatusRead::PendingOrInvalid => {
156            return provider_exit_code.filter(|exit_code| *exit_code != 0);
157        }
158        TerminalStatusRead::Absent => {}
159    }
160
161    let candidates = [
162        box_dir
163            .join("upper")
164            .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
165        box_dir
166            .join("rootfs")
167            .join(".a3s-rootfs")
168            .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
169        box_dir
170            .join("rootfs")
171            .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
172    ];
173
174    candidates
175        .into_iter()
176        .find_map(|path| {
177            std::fs::read_to_string(path)
178                .ok()
179                .and_then(|contents| contents.trim().parse::<i32>().ok())
180        })
181        .or(provider_exit_code)
182}
183
184/// A temporarily attached persistent rootfs.
185///
186/// Dropping this guard detaches only mounts created by
187/// [`attach_persistent_rootfs`]. An already mounted rootfs is left untouched.
188pub struct AttachedRootfs {
189    path: std::path::PathBuf,
190    detach_on_drop: bool,
191}
192
193/// Return whether a box has a retained guest-native raw rootfs generation.
194///
195/// Any directory entry at the versioned artifact path counts. Validation is
196/// performed by the provider before boot; detection must still fail closed for
197/// malformed generations instead of falling back to a host directory.
198pub fn guest_native_ext4_generation_exists(box_dir: &Path) -> Result<bool> {
199    let path = box_dir.join("rootfs-ext4-v1");
200    match std::fs::symlink_metadata(&path) {
201        Ok(_) => Ok(true),
202        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
203        Err(error) => Err(BoxError::BuildError(format!(
204            "Failed to inspect guest-native rootfs generation {}: {error}",
205            path.display()
206        ))),
207    }
208}
209
210/// Return the logical disk capacity owned by a retained guest-native rootfs.
211///
212/// CLI state predates block-root capacities and therefore cannot be the source
213/// of truth for a restored raw snapshot. Reconstructing a boot must use the
214/// validated artifact's own immutable geometry instead of silently applying a
215/// process default that may disagree with it.
216pub fn guest_native_ext4_disk_mib(box_dir: &Path) -> Result<Option<u32>> {
217    if !guest_native_ext4_generation_exists(box_dir)? {
218        return Ok(None);
219    }
220    #[cfg(target_os = "macos")]
221    {
222        let directory = GuestNativeExt4Provider::artifact_directory(box_dir);
223        let (artifact, _) = ext4_artifact::open_ext4_artifact_for_resume(&directory)?;
224        const MIB: u64 = 1024 * 1024;
225        if artifact.manifest.capacity_bytes % MIB != 0 {
226            return Err(BoxError::StateError(format!(
227                "Guest-native rootfs capacity is not MiB-aligned at {}",
228                artifact.disk.display()
229            )));
230        }
231        let disk_mib = u32::try_from(artifact.manifest.capacity_bytes / MIB).map_err(|_| {
232            BoxError::StateError(format!(
233                "Guest-native rootfs capacity exceeds the Box configuration range at {}",
234                artifact.disk.display()
235            ))
236        })?;
237        Ok(Some(disk_mib))
238    }
239    #[cfg(not(target_os = "macos"))]
240    {
241        Err(BoxError::StateError(format!(
242            "Guest-native rootfs state is unsupported on this host: {}",
243            box_dir.join("rootfs-ext4-v1").display()
244        )))
245    }
246}
247
248/// Open a clean guest-native generation for an immutable filesystem snapshot.
249///
250/// A snapshot is observational state: it must never replay a guest journal or
251/// capture a disk whose final writes are still ambiguous. Normal writable boot
252/// owns recovery; callers can retry after one successful start and clean stop.
253#[cfg(target_os = "macos")]
254pub(crate) fn open_clean_guest_native_ext4_artifact(
255    artifact_directory: &Path,
256) -> Result<Ext4Artifact> {
257    let (artifact, validation) = ext4_artifact::open_ext4_artifact_for_resume(artifact_directory)?;
258    if validation == ext4::Ext4ResumeValidation::JournalRecoveryRequired {
259        return Err(BoxError::StateError(format!(
260            "Guest-native rootfs at {} needs ext4 journal recovery; start the box and stop it cleanly before creating or restoring a filesystem snapshot",
261            artifact.disk.display()
262        )));
263    }
264    Ok(artifact)
265}
266
267/// Clone one clean raw-ext4 generation into a private atomically published
268/// artifact directory. The source is never attached or opened writable.
269#[cfg(target_os = "macos")]
270pub(crate) fn clone_clean_guest_native_ext4_artifact(
271    artifact_directory: &Path,
272    destination: &Path,
273) -> Result<Ext4Artifact> {
274    let source = open_clean_guest_native_ext4_artifact(artifact_directory)?;
275    let cloned = ext4_cache::clone_artifact(&source, destination)?;
276    let validated = match open_clean_guest_native_ext4_artifact(destination) {
277        Ok(validated) => validated,
278        Err(error) => {
279            let _ = std::fs::remove_dir_all(destination);
280            return Err(error);
281        }
282    };
283    if cloned != validated || source.manifest != validated.manifest {
284        let _ = std::fs::remove_dir_all(destination);
285        return Err(BoxError::StateError(format!(
286            "Cloned guest-native rootfs identity changed at {}",
287            destination.display()
288        )));
289    }
290    Ok(validated)
291}
292
293#[cfg(target_os = "macos")]
294pub(crate) fn guest_native_ext4_sparse_digest(artifact: &Ext4Artifact) -> Result<String> {
295    ext4_cache::sparse_sha256(&artifact.disk, artifact.manifest.capacity_bytes)
296}
297
298#[cfg(target_os = "macos")]
299pub(crate) fn guest_native_ext4_allocated_bytes(artifact_directory: &Path) -> Result<u64> {
300    ext4_cache::allocated_bytes(artifact_directory)
301}
302
303/// Resolve a clean guest-native generation for the trusted read-only
304/// maintenance VM.
305///
306/// A crashed filesystem is intentionally rejected here. The observation path
307/// attaches its disk read-only and mounts ext4 with `noload`, so journal replay
308/// belongs to a normal writable boot followed by a verified clean stop.
309#[cfg(target_os = "macos")]
310pub(crate) fn guest_native_ext4_maintenance_disk(box_dir: &Path) -> Result<PathBuf> {
311    let directory = GuestNativeExt4Provider::artifact_directory(box_dir);
312    let (artifact, validation) = ext4_artifact::open_ext4_artifact_for_resume(&directory)?;
313    if validation == ext4::Ext4ResumeValidation::JournalRecoveryRequired {
314        return Err(BoxError::StateError(format!(
315            "Guest-native rootfs at {} needs ext4 journal recovery; start the box and stop it cleanly before offline diff, export, or commit",
316            artifact.disk.display()
317        )));
318    }
319    Ok(artifact.disk)
320}
321
322impl AttachedRootfs {
323    pub fn path(&self) -> &Path {
324        &self.path
325    }
326}
327
328impl Drop for AttachedRootfs {
329    fn drop(&mut self) {
330        if self.detach_on_drop {
331            unmount_box_rootfs(&self.path);
332        }
333    }
334}
335
336/// Attach an existing platform-backed persistent rootfs for offline access.
337///
338/// Returns `None` when the box has no platform-specific backing image. This
339/// never creates a new image, so callers cannot accidentally commit an empty
340/// filesystem when a backing image is missing.
341pub fn attach_persistent_rootfs(
342    box_dir: &Path,
343) -> a3s_box_core::error::Result<Option<AttachedRootfs>> {
344    if guest_native_ext4_generation_exists(box_dir)? {
345        return Err(BoxError::StateError(
346            "Guest-native rootfs generations have no host directory attachment; use the trusted maintenance archive path for stopped access"
347                .to_string(),
348        ));
349    }
350
351    #[cfg(target_os = "macos")]
352    {
353        let image = box_dir.join("rootfs-apfs-v2.sparseimage");
354        if !image.is_file() {
355            return Ok(None);
356        }
357        let rootfs = box_dir.join("rootfs");
358        let was_mounted = is_mountpoint(&rootfs);
359        let path = provider::CaseSensitiveApfsProvider.prepare_empty(box_dir)?;
360        Ok(Some(AttachedRootfs {
361            path,
362            detach_on_drop: !was_mounted,
363        }))
364    }
365
366    #[cfg(not(target_os = "macos"))]
367    {
368        let _ = box_dir;
369        Ok(None)
370    }
371}
372
373/// Invalidate the last clean-shutdown metadata generation before launching a
374/// box, retaining it at the one-shot replay path used by guest-init.
375///
376/// Overlay providers can expose the same entry through `merged` and `upper`.
377/// Staging is idempotent when the canonical marker is already absent: an
378/// existing replay marker is retained so a boot that failed before guest replay
379/// can be retried safely.
380pub fn stage_box_terminal_rootfs_metadata(box_dir: &Path) -> a3s_box_core::error::Result<()> {
381    if guest_native_ext4_generation_exists(box_dir)? {
382        // The raw disk is not host-mounted. Guest-init invalidates and consumes
383        // the prior terminal generation before it starts any workload process.
384        return Ok(());
385    }
386    let attached = attach_persistent_rootfs(box_dir)?;
387    let mut roots = Vec::<PathBuf>::new();
388    if let Some(rootfs) = attached.as_ref() {
389        roots.push(rootfs.path().to_path_buf());
390    }
391    roots.extend([
392        box_dir.join("rootfs"),
393        box_dir.join("upper"),
394        box_dir.join("merged"),
395    ]);
396    roots.sort();
397    roots.dedup();
398
399    let mut existing_roots = Vec::new();
400    for root in roots {
401        match std::fs::symlink_metadata(&root) {
402            Ok(_) => existing_roots.push(root),
403            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
404            Err(error) => return Err(error.into()),
405        }
406    }
407    stage_metadata_roots(&existing_roots)?;
408    Ok(())
409}
410
411fn stage_metadata_roots(roots: &[PathBuf]) -> std::io::Result<()> {
412    for root in roots {
413        a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(root)?;
414    }
415    Ok(())
416}
417
418/// Unmount a box's overlayfs `merged` view — best-effort and idempotent.
419///
420/// Box teardown must release this mount BEFORE removing the box dir, or
421/// `remove_dir_all` deletes *into* the live mount and fails with "Stale file
422/// handle", leaking the mount. A restart re-mounts without unmounting first, so
423/// the overlay can be stacked (mounted 2–3×); unmount in a bounded loop until
424/// `merged` is no longer a mountpoint. No-op if it was never mounted.
425pub fn unmount_box_overlay(merged: &Path) {
426    for _ in 0..8 {
427        if !is_mountpoint(merged) {
428            break;
429        }
430        if overlay::overlay_unmount(merged).is_err() {
431            break;
432        }
433    }
434}
435
436/// Fully unmount a box overlay before its writable layer is reused.
437///
438/// Unlike [`unmount_box_overlay`], this path never falls back to lazy detach:
439/// callers must not start another overlay writer until every stacked mount has
440/// been synchronously released.
441pub(crate) fn unmount_box_overlay_for_reuse(merged: &Path) -> a3s_box_core::error::Result<()> {
442    for _ in 0..8 {
443        if !is_mountpoint(merged) {
444            return Ok(());
445        }
446        overlay::overlay_unmount_for_reuse(merged)?;
447    }
448
449    if is_mountpoint(merged) {
450        return Err(a3s_box_core::error::BoxError::BuildError(format!(
451            "Overlay at {} remained mounted after synchronous cleanup",
452            merged.display()
453        )));
454    }
455    Ok(())
456}
457
458/// True if `path` is a mountpoint (its device id differs from its parent's).
459#[cfg(unix)]
460pub(crate) fn is_mountpoint(path: &Path) -> bool {
461    use std::os::unix::fs::MetadataExt;
462    match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
463        (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
464        _ => false,
465    }
466}
467
468#[cfg(not(unix))]
469pub(crate) fn is_mountpoint(_path: &Path) -> bool {
470    false
471}
472
473/// Unmount a platform-specific writable rootfs mount.
474pub fn unmount_box_rootfs(rootfs: &Path) {
475    #[cfg(target_os = "macos")]
476    {
477        // The case-sensitive provider returns `<mount>/.a3s-rootfs`, keeping
478        // APFS-created volume metadata outside the Linux tree. Accept either
479        // that data path or the mountpoint itself at cleanup call sites.
480        let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
481            rootfs.parent().unwrap_or(rootfs)
482        } else {
483            rootfs
484        };
485        if !is_mountpoint(mountpoint) {
486            return;
487        }
488        match std::process::Command::new("hdiutil")
489            .arg("detach")
490            .arg("-quiet")
491            .arg(mountpoint)
492            .status()
493        {
494            Ok(status) if status.success() => {}
495            Ok(status) => tracing::warn!(
496                path = %mountpoint.display(),
497                ?status,
498                "Failed to detach case-sensitive rootfs image"
499            ),
500            Err(error) => tracing::warn!(
501                path = %mountpoint.display(),
502                %error,
503                "Failed to run hdiutil detach"
504            ),
505        }
506    }
507
508    #[cfg(not(target_os = "macos"))]
509    let _ = rootfs;
510}
511
512/// Synchronously detach a macOS staging filesystem before a block artifact is
513/// handed to the guest. Unlike teardown cleanup, ownership handoff is not
514/// best-effort: a remaining host mount violates the guest-native invariant and
515/// aborts the boot.
516#[cfg(target_os = "macos")]
517pub(crate) fn unmount_box_rootfs_for_handoff(rootfs: &Path) -> a3s_box_core::error::Result<()> {
518    let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
519        rootfs.parent().unwrap_or(rootfs)
520    } else {
521        rootfs
522    };
523    if !is_mountpoint(mountpoint) {
524        return Err(a3s_box_core::error::BoxError::BuildError(format!(
525            "Expected a mounted rootfs staging filesystem at {}",
526            mountpoint.display()
527        )));
528    }
529    let status = std::process::Command::new("hdiutil")
530        .arg("detach")
531        .arg("-quiet")
532        .arg(mountpoint)
533        .status()
534        .map_err(|error| {
535            a3s_box_core::error::BoxError::BuildError(format!(
536                "Failed to run hdiutil detach for {}: {error}",
537                mountpoint.display()
538            ))
539        })?;
540    if !status.success() || is_mountpoint(mountpoint) {
541        return Err(a3s_box_core::error::BoxError::BuildError(format!(
542            "Rootfs staging filesystem remained attached at {} after handoff",
543            mountpoint.display()
544        )));
545    }
546    Ok(())
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn persisted_exit_code_supports_each_rootfs_provider_layout() {
555        for (relative, expected) in [
556            ("upper/.a3s_exit_code", 17),
557            ("rootfs/.a3s_exit_code", 23),
558            ("rootfs/.a3s-rootfs/.a3s_exit_code", 29),
559        ] {
560            let temp = tempfile::tempdir().unwrap();
561            let path = temp.path().join(relative);
562            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
563            std::fs::write(path, format!("{expected}\n")).unwrap();
564
565            assert_eq!(read_persisted_exit_code(temp.path()), Some(expected));
566        }
567    }
568
569    #[test]
570    fn persisted_exit_code_ignores_missing_or_invalid_files() {
571        let temp = tempfile::tempdir().unwrap();
572        assert_eq!(read_persisted_exit_code(temp.path()), None);
573
574        let path = temp.path().join("rootfs/.a3s_exit_code");
575        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
576        std::fs::write(path, "not-an-exit-code").unwrap();
577        assert_eq!(read_persisted_exit_code(temp.path()), None);
578    }
579
580    #[test]
581    fn terminal_status_is_preferred_over_legacy_rootfs_marker() {
582        let temp = tempfile::tempdir().unwrap();
583        let terminal = temp
584            .path()
585            .join("runtime-control")
586            .join(GUEST_TERMINAL_STATUS_FILE_NAME);
587        std::fs::create_dir_all(terminal.parent().unwrap()).unwrap();
588        std::fs::write(
589            &terminal,
590            serde_json::to_vec(&GuestTerminalStatus::new(31)).unwrap(),
591        )
592        .unwrap();
593        let legacy = temp.path().join("rootfs/.a3s_exit_code");
594        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
595        std::fs::write(legacy, "7").unwrap();
596
597        assert_eq!(read_persisted_exit_code(temp.path()), Some(31));
598    }
599
600    #[test]
601    fn rootfs_handoff_requires_an_explicit_guest_quiescence_ack() {
602        let temp = tempfile::tempdir().unwrap();
603        let terminal = temp
604            .path()
605            .join("runtime-control")
606            .join(GUEST_TERMINAL_STATUS_FILE_NAME);
607        std::fs::create_dir_all(terminal.parent().unwrap()).unwrap();
608
609        std::fs::write(
610            &terminal,
611            serde_json::to_vec(&GuestTerminalStatus::new(0)).unwrap(),
612        )
613        .unwrap();
614        assert!(!guest_rootfs_handoff_complete(temp.path()));
615
616        std::fs::write(
617            &terminal,
618            serde_json::to_vec(&GuestTerminalStatus::new(0).with_rootfs_quiesced()).unwrap(),
619        )
620        .unwrap();
621        assert!(guest_rootfs_handoff_complete(temp.path()));
622    }
623
624    #[test]
625    fn pending_terminal_status_blocks_stale_rootfs_fallback() {
626        let temp = tempfile::tempdir().unwrap();
627        let terminal = temp
628            .path()
629            .join("runtime-control")
630            .join(GUEST_TERMINAL_STATUS_FILE_NAME);
631        std::fs::create_dir_all(terminal.parent().unwrap()).unwrap();
632        std::fs::write(terminal, []).unwrap();
633        let legacy = temp.path().join("rootfs/.a3s_exit_code");
634        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
635        std::fs::write(legacy, "0").unwrap();
636
637        assert_eq!(read_persisted_exit_code(temp.path()), None);
638        assert_eq!(resolve_workload_exit_code(temp.path(), Some(0)), None);
639        assert_eq!(resolve_workload_exit_code(temp.path(), Some(9)), Some(9));
640    }
641
642    #[test]
643    fn missing_path_is_not_mountpoint() {
644        let temp = tempfile::tempdir().unwrap();
645        let missing = temp.path().join("missing");
646
647        assert!(!is_mountpoint(&missing));
648    }
649
650    #[test]
651    fn unmount_overlay_noops_for_non_mountpoint() {
652        let temp = tempfile::tempdir().unwrap();
653        let merged = temp.path().join("merged");
654        std::fs::create_dir(&merged).unwrap();
655
656        unmount_box_overlay(&merged);
657
658        assert!(merged.exists());
659    }
660
661    #[test]
662    fn staging_is_idempotent_until_guest_replay_succeeds() {
663        let root = tempfile::tempdir().unwrap();
664        let terminal = root
665            .path()
666            .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/'));
667        let previous = root.path().join(
668            a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'),
669        );
670        std::fs::write(&terminal, b"clean generation").unwrap();
671
672        stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
673        stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
674
675        assert!(!terminal.exists());
676        assert_eq!(std::fs::read(previous).unwrap(), b"clean generation");
677    }
678
679    #[test]
680    fn staging_one_candidate_never_discards_an_alias_replay() {
681        let directory = tempfile::tempdir().unwrap();
682        let merged = directory.path().join("merged");
683        let upper = directory.path().join("upper");
684        std::fs::create_dir_all(&merged).unwrap();
685        std::fs::create_dir_all(&upper).unwrap();
686        let terminal_name =
687            a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/');
688        let previous_name =
689            a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/');
690        std::fs::write(merged.join(terminal_name), b"clean generation").unwrap();
691        // Models the view through `upper` immediately after the same overlay
692        // entry was renamed through `merged`.
693        std::fs::write(upper.join(previous_name), b"clean generation").unwrap();
694
695        stage_metadata_roots(&[merged.clone(), upper.clone()]).unwrap();
696
697        assert!(merged.join(previous_name).is_file());
698        assert!(upper.join(previous_name).is_file());
699    }
700
701    #[test]
702    fn staging_box_roots_clears_every_previous_exit_status() {
703        let directory = tempfile::tempdir().unwrap();
704        let box_dir = directory.path().join("box");
705        for provider_root in ["rootfs", "upper", "merged"] {
706            let root = box_dir.join(provider_root);
707            std::fs::create_dir_all(&root).unwrap();
708            std::fs::write(
709                root.join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
710                b"17\n",
711            )
712            .unwrap();
713        }
714
715        stage_box_terminal_rootfs_metadata(&box_dir).unwrap();
716
717        assert_eq!(read_persisted_exit_code(&box_dir), None);
718        for provider_root in ["rootfs", "upper", "merged"] {
719            assert!(!box_dir
720                .join(provider_root)
721                .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/'))
722                .exists());
723        }
724    }
725
726    #[test]
727    fn raw_generation_keeps_terminal_fencing_inside_guest() {
728        let directory = tempfile::tempdir().unwrap();
729        let box_dir = directory.path().join("box");
730        let artifact = box_dir.join("rootfs-ext4-v1");
731        let rootfs = box_dir.join("rootfs");
732        std::fs::create_dir_all(&artifact).unwrap();
733        std::fs::create_dir_all(&rootfs).unwrap();
734        let terminal = rootfs
735            .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/'));
736        std::fs::write(&terminal, b"guest-owned").unwrap();
737
738        assert!(guest_native_ext4_generation_exists(&box_dir).unwrap());
739        stage_box_terminal_rootfs_metadata(&box_dir).unwrap();
740        assert_eq!(std::fs::read(&terminal).unwrap(), b"guest-owned");
741        assert!(attach_persistent_rootfs(&box_dir).is_err());
742    }
743}