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