Skip to main content

a3s_box_runtime/rootfs/
provider.rs

1//! Rootfs provider — stages a rootfs and finalizes its VMM transport.
2//!
3//! Portable providers:
4//! - `CopyProvider` — full recursive copy (portable fallback)
5//! - `OverlayProvider` — Linux overlayfs mount (near-instant, CoW)
6//!
7//! macOS defaults to a guest-native block artifact and keeps a case-sensitive
8//! APFS provider for explicit legacy and migration compatibility. The finalizer
9//! boundary keeps OCI preparation independent of the selected VMM transport.
10
11use std::path::{Path, PathBuf};
12
13use a3s_box_core::error::{BoxError, Result};
14use a3s_box_core::vmm::RootfsSource;
15
16#[cfg(target_os = "macos")]
17pub(crate) use super::apfs::CaseSensitiveApfsProvider;
18#[cfg(target_os = "macos")]
19use super::guest_native_ext4::GuestNativeExt4Provider;
20
21/// Lifecycle constraints that a provider must honor before handing the
22/// rootfs to the VMM.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct RootfsFinalizeOptions {
25    pub disk_mib: u32,
26    pub persistent: bool,
27    pub snapshot: bool,
28    pub artifact_cache: Option<RootfsArtifactCacheOptions>,
29}
30
31/// Constraints for reopening an already guest-owned rootfs generation.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct RootfsResumeOptions {
34    pub disk_mib: u32,
35    pub persistent: bool,
36    pub snapshot: bool,
37}
38
39/// Resource bounds supplied while preparing a writable rootfs generation.
40///
41/// The optional byte limit is intentionally carried separately from the
42/// logical VM disk size. A provider may only accept it when it can enforce a
43/// real writable-layer quota; the default implementation fails closed.
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub struct RootfsPrepareOptions {
46    pub writable_layer_bytes: Option<u64>,
47}
48
49/// A validated rootfs generation that no longer has a host directory view.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ResumedRootfs {
52    pub source: RootfsSource,
53    pub guest_init_exec: String,
54}
55
56/// Exact identity and resource bounds for an immutable provider artifact.
57///
58/// This deliberately excludes the mutable image reference: the resolved OCI
59/// manifest digest is the content authority. The guest-init digest is separate
60/// because A3S installs that runtime binary after OCI extraction.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct RootfsArtifactCacheOptions {
63    pub directory: PathBuf,
64    pub oci_manifest_digest: String,
65    pub platform: String,
66    pub guest_init_sha256: String,
67    pub max_entries: usize,
68    pub max_allocated_bytes: u64,
69}
70
71/// Verified OCI inputs for a provider that can publish a bootable artifact
72/// without first materializing a host-visible rootfs directory.
73pub struct RootfsOciPrepareOptions<'a> {
74    pub image: &'a crate::oci::OciImage,
75    pub guest_init: &'a Path,
76    pub guest_init_sha256: &'a str,
77    pub platform: &'a str,
78    pub disk_mib: u32,
79    pub persistent: bool,
80    pub snapshot: bool,
81    pub artifact_cache: Option<RootfsArtifactCacheOptions>,
82}
83
84/// Abstracts how a rootfs directory is prepared for a box from a cached lower layer.
85pub trait RootfsProvider: Send + Sync {
86    /// Reopen a durable guest-owned generation without reconstructing a host
87    /// staging tree. Directory providers return `None`.
88    fn resume_for_boot(
89        &self,
90        box_dir: &Path,
91        options: RootfsResumeOptions,
92    ) -> Result<Option<ResumedRootfs>> {
93        let _ = (box_dir, options);
94        Ok(None)
95    }
96
97    /// Build a rootfs artifact directly from verified OCI layers. Providers
98    /// that require a directory staging view return `None` and retain the
99    /// compatibility preparation flow.
100    fn prepare_oci_for_boot(
101        &self,
102        box_dir: &Path,
103        options: RootfsOciPrepareOptions<'_>,
104    ) -> Result<Option<ResumedRootfs>> {
105        let _ = (box_dir, options);
106        Ok(None)
107    }
108
109    /// Prepare a rootfs at `box_dir` from the cached read-only layer at `cache_dir`.
110    ///
111    /// The returned directory is the host-side staging view. Runtime code may
112    /// still inspect and update it until [`Self::finalize_for_boot`] is called.
113    fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf>;
114
115    /// Prepare a rootfs while applying provider-specific writable-layer
116    /// bounds. Providers that do not implement the bound reject it instead of
117    /// silently falling back to an unbounded directory.
118    fn prepare_with_options(
119        &self,
120        box_dir: &Path,
121        cache_dir: &Path,
122        options: RootfsPrepareOptions,
123    ) -> Result<PathBuf> {
124        if options.writable_layer_bytes.is_some() {
125            return Err(BoxError::ConfigError(format!(
126                "rootfs provider {} cannot enforce a writable-layer byte quota",
127                self.name()
128            )));
129        }
130        self.prepare(box_dir, cache_dir)
131    }
132
133    /// Prepare an empty writable rootfs for an OCI cache miss.
134    fn prepare_empty(&self, box_dir: &Path) -> Result<PathBuf> {
135        let rootfs = box_dir.join("rootfs");
136        std::fs::create_dir_all(&rootfs).map_err(|error| {
137            BoxError::BuildError(format!(
138                "Failed to create rootfs {}: {error}",
139                rootfs.display()
140            ))
141        })?;
142        Ok(rootfs)
143    }
144
145    /// Prepare an empty rootfs while applying provider-specific bounds.
146    fn prepare_empty_with_options(
147        &self,
148        box_dir: &Path,
149        options: RootfsPrepareOptions,
150    ) -> Result<PathBuf> {
151        if options.writable_layer_bytes.is_some() {
152            return Err(BoxError::ConfigError(format!(
153                "rootfs provider {} cannot enforce a writable-layer byte quota",
154                self.name()
155            )));
156        }
157        self.prepare_empty(box_dir)
158    }
159
160    /// Finalize the staged tree and choose the root filesystem transport.
161    ///
162    /// This is called exactly after the last host-side rootfs mutation and
163    /// before the VMM starts. Directory providers keep the existing virtio-fs
164    /// behavior. A guest-native provider can atomically publish a raw ext4
165    /// artifact here, detach any temporary host staging mount, and return an
166    /// [`RootfsSource::Ext4Disk`].
167    ///
168    /// `disk_mib` is the configured logical capacity, not a request to eagerly
169    /// allocate every byte on the host.
170    fn finalize_for_boot(
171        &self,
172        box_dir: &Path,
173        staged_rootfs: &Path,
174        options: RootfsFinalizeOptions,
175    ) -> Result<RootfsSource> {
176        let _ = (box_dir, options);
177        super::ensure_directory_transport_is_lossless(staged_rootfs)?;
178        Ok(RootfsSource::directory(staged_rootfs))
179    }
180
181    /// Cleanup after box stops.
182    ///
183    /// When `persistent` is true, the writable layer (overlay upper dir or copy
184    /// rootfs) is preserved on disk so changes survive the next start.
185    /// When false, the writable layer is wiped for a clean slate.
186    fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()>;
187
188    /// Whether a failed boot must retain the provider's rootfs generations.
189    ///
190    /// Most providers can discard a partially prepared first boot. A provider
191    /// performing an in-place migration must keep both the rollback source and
192    /// the atomically published target until the migration is verified.
193    fn preserve_on_boot_failure(&self, box_dir: &Path) -> bool {
194        let _ = box_dir;
195        false
196    }
197
198    /// Record that a guest-owned rootfs completed a verified clean stop.
199    ///
200    /// The default is a no-op. Migration providers use this hook to advance a
201    /// durable transaction only after the runtime has observed the guest's
202    /// read-only handoff acknowledgement.
203    fn record_clean_stop(&self, box_dir: &Path) -> Result<()> {
204        let _ = box_dir;
205        Ok(())
206    }
207
208    /// Human-readable name for logging.
209    fn name(&self) -> &'static str;
210
211    /// Whether this provider can consume the immutable artifact cache contract.
212    fn supports_artifact_cache(&self) -> bool {
213        false
214    }
215
216    /// Whether layout preparation should offer verified OCI inputs before
217    /// allocating a directory staging transport.
218    fn supports_direct_oci_assembly(&self) -> bool {
219        false
220    }
221
222    /// Whether guest-init, rather than the host staging view, owns terminal
223    /// metadata invalidation for this provider's supported lifecycle modes.
224    fn guest_owns_terminal_fencing(&self) -> bool {
225        false
226    }
227
228    /// Whether guest-init must capture the pristine diff baseline because the
229    /// finalized rootfs has no host-visible directory.
230    fn guest_owns_diff_baseline(&self) -> bool {
231        false
232    }
233}
234
235/// Full recursive copy provider — works on all platforms.
236///
237/// This is the original behavior: copies the entire cached rootfs into
238/// `box_dir/rootfs/`. Safe but slow for large images.
239pub struct CopyProvider;
240
241impl RootfsProvider for CopyProvider {
242    fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
243        let rootfs = box_dir.join("rootfs");
244        // Reuse existing rootfs when persistent and already populated
245        if rootfs.exists() {
246            tracing::info!(path = %rootfs.display(), "Reusing persistent rootfs");
247            return Ok(rootfs);
248        }
249        crate::cache::layer_cache::copy_dir_recursive(cache_dir, &rootfs)?;
250        Ok(rootfs)
251    }
252
253    fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()> {
254        if persistent {
255            tracing::info!("Persistent box: keeping rootfs on disk");
256            return Ok(());
257        }
258        let rootfs = box_dir.join("rootfs");
259        if rootfs.exists() {
260            std::fs::remove_dir_all(&rootfs).map_err(|e| {
261                BoxError::BuildError(format!(
262                    "Failed to remove rootfs {}: {}",
263                    rootfs.display(),
264                    e
265                ))
266            })?;
267        }
268        Ok(())
269    }
270
271    fn name(&self) -> &'static str {
272        "copy"
273    }
274}
275
276/// Overlayfs provider — near-instant CoW mounts (Linux only).
277///
278/// Layout:
279/// ```text
280/// cache_dir/           ← lower (read-only, shared across boxes)
281/// box_dir/upper/       ← upper (per-box writes)
282/// box_dir/work/        ← overlayfs workdir
283/// box_dir/merged/      ← merged view → RootfsSource::Directory
284/// ```
285pub struct OverlayProvider;
286
287impl OverlayProvider {
288    fn lower_dir(box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
289        let rootfs = box_dir.join("rootfs");
290        match std::fs::read_dir(&rootfs) {
291            Ok(mut entries) => {
292                if entries.next().is_some() {
293                    // A cache miss builds the first generation directly in `rootfs`.
294                    // Once that generation has run, the cache will usually be warm.
295                    // Keep the original writable tree as the overlay lower instead
296                    // of switching the next generation to the immutable image cache;
297                    // otherwise persistent guest writes silently disappear on restart.
298                    Ok(rootfs)
299                } else {
300                    Ok(cache_dir.to_path_buf())
301                }
302            }
303            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
304                Ok(cache_dir.to_path_buf())
305            }
306            Err(error) => Err(BoxError::BuildError(format!(
307                "Failed to inspect existing rootfs {}: {error}",
308                rootfs.display()
309            ))),
310        }
311    }
312}
313
314impl RootfsProvider for OverlayProvider {
315    fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
316        self.prepare_with_options(box_dir, cache_dir, RootfsPrepareOptions::default())
317    }
318
319    fn prepare_with_options(
320        &self,
321        box_dir: &Path,
322        cache_dir: &Path,
323        options: RootfsPrepareOptions,
324    ) -> Result<PathBuf> {
325        let lower = Self::lower_dir(box_dir, cache_dir)?;
326        let (upper, work) = if let Some(bytes) = options.writable_layer_bytes {
327            let layer = super::overlay::prepare_bounded_writable_layer(box_dir, bytes)?;
328            // OverlayFS requires the upperdir and workdir paths to resolve
329            // through the same mount (not merely the same superblock). The
330            // bounded layer keeps historical bind-mounted aliases at
331            // `box_dir/upper` and `box_dir/work`, but those aliases are two
332            // distinct mounts and are rejected by the kernel with EINVAL.
333            // Pass the source directories below the single quota tmpfs
334            // instead; the aliases remain available to lifecycle and
335            // inspection code.
336            (layer.mount.join("upper"), layer.mount.join("work"))
337        } else {
338            (box_dir.join("upper"), box_dir.join("work"))
339        };
340        let merged = box_dir.join("merged");
341
342        for dir in [&upper, &work, &merged] {
343            std::fs::create_dir_all(dir).map_err(|e| {
344                BoxError::BuildError(format!(
345                    "Failed to create overlay dir {}: {}",
346                    dir.display(),
347                    e
348                ))
349            })?;
350        }
351
352        // Idempotent: a restart re-runs prepare(); without this guard each call
353        // stacks another overlay on `merged` (the leaked double/triple mounts).
354        if super::is_mountpoint(&merged) {
355            tracing::debug!(merged = %merged.display(), "Overlay already mounted; reusing");
356            return Ok(merged);
357        }
358
359        super::overlay::overlay_mount(&lower, &upper, &work, &merged)?;
360
361        tracing::info!(
362            lower = %lower.display(),
363            merged = %merged.display(),
364            "Overlay mount ready"
365        );
366
367        Ok(merged)
368    }
369
370    fn prepare_empty_with_options(
371        &self,
372        box_dir: &Path,
373        options: RootfsPrepareOptions,
374    ) -> Result<PathBuf> {
375        if options.writable_layer_bytes.is_none() {
376            return self.prepare_empty(box_dir);
377        }
378
379        // This path is useful to callers that want a bounded empty rootfs.
380        // OCI image extraction deliberately uses an unbounded staging tree and
381        // mounts the quota only after the immutable image has been built (see
382        // VmManager::prepare_layout), so image bytes do not consume the
383        // workload's writable-layer allowance.
384        let rootfs = box_dir.join("rootfs");
385        ensure_empty_rootfs_directory(&rootfs)?;
386        self.prepare_with_options(box_dir, &rootfs, options)
387    }
388
389    fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()> {
390        let merged = box_dir.join("merged");
391
392        let bounded = box_dir
393            .join(super::overlay::WRITABLE_LAYER_MARKER_NAME)
394            .exists()
395            || super::overlay::is_mountpoint(
396                &box_dir.join(super::overlay::WRITABLE_LAYER_DIR_NAME),
397            )
398            || super::overlay::is_mountpoint(&box_dir.join("upper"))
399            || super::overlay::is_mountpoint(&box_dir.join("work"));
400
401        if bounded {
402            if persistent {
403                // Release the overlay view before removing its host directory,
404                // but retain the quota tmpfs and aliases as the durable
405                // writable generation for the next start.
406                super::unmount_box_overlay_for_reuse(&merged)?;
407                if merged.exists() && !super::overlay::is_mountpoint(&merged) {
408                    if let Err(error) = std::fs::remove_dir_all(&merged) {
409                        tracing::warn!(path = %merged.display(), %error, "Failed to remove bounded overlay view");
410                    }
411                }
412                super::overlay::cleanup_bounded_writable_layer(box_dir, true)?;
413                tracing::info!("Persistent box: keeping bounded writable-layer tmpfs and aliases");
414                return Ok(());
415            }
416
417            super::unmount_box_overlay(&merged);
418            super::overlay::cleanup_bounded_writable_layer(box_dir, false)?;
419            for dir_name in &[
420                "rootfs",
421                "merged",
422                "upper",
423                "work",
424                super::overlay::WRITABLE_LAYER_DIR_NAME,
425            ] {
426                let dir = box_dir.join(dir_name);
427                if dir.exists() && !super::overlay::is_mountpoint(&dir) {
428                    if let Err(error) = std::fs::remove_dir_all(&dir) {
429                        tracing::warn!(path = %dir.display(), %error, "Failed to remove bounded overlay directory");
430                    }
431                }
432            }
433            return Ok(());
434        }
435
436        if persistent {
437            // A retained upper is about to become the next generation's
438            // writable layer. Fully release every old mount before reuse;
439            // lazy detach can keep an old namespace writer alive and make the
440            // replacement generation observe stale rootfs state.
441            super::unmount_box_overlay_for_reuse(&merged)?;
442            // Keep both possible persistent generations: a cache-miss generation
443            // lives in `rootfs`, while later overlay writes live in `upper`.
444            // The next prepare mounts their union again.
445            tracing::info!("Persistent box: keeping rootfs and overlay upper on disk");
446            for dir_name in &["merged", "work"] {
447                let dir = box_dir.join(dir_name);
448                if dir.exists() {
449                    if let Err(e) = std::fs::remove_dir_all(&dir) {
450                        tracing::warn!(path = %dir.display(), error = %e, "Failed to remove overlay dir");
451                    }
452                }
453            }
454            return Ok(());
455        }
456
457        // A discarded rootfs can use bounded lazy unmount cleanup. It will
458        // never be mounted as a replacement generation.
459        super::unmount_box_overlay(&merged);
460
461        for dir_name in &["rootfs", "upper", "work", "merged"] {
462            let dir = box_dir.join(dir_name);
463            if dir.exists() {
464                if let Err(e) = std::fs::remove_dir_all(&dir) {
465                    tracing::warn!(
466                        path = %dir.display(),
467                        error = %e,
468                        "Failed to remove overlay dir"
469                    );
470                }
471            }
472        }
473
474        Ok(())
475    }
476
477    fn name(&self) -> &'static str {
478        "overlay"
479    }
480}
481
482fn ensure_empty_rootfs_directory(rootfs: &Path) -> Result<()> {
483    match std::fs::symlink_metadata(rootfs) {
484        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
485            Err(BoxError::StateError(format!(
486                "Rootfs staging path {} is not a directory",
487                rootfs.display()
488            )))
489        }
490        Ok(_) => Ok(()),
491        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
492            std::fs::create_dir_all(rootfs).map_err(|error| {
493                BoxError::BuildError(format!(
494                    "Failed to create rootfs {}: {error}",
495                    rootfs.display()
496                ))
497            })
498        }
499        Err(error) => Err(BoxError::BuildError(format!(
500            "Failed to inspect rootfs {}: {error}",
501            rootfs.display()
502        ))),
503    }
504}
505
506#[cfg(target_os = "macos")]
507const LEGACY_APFS_ROOTFS_ENV: &str = "A3S_BOX_MACOS_LEGACY_APFS_ROOTFS";
508
509#[cfg(target_os = "macos")]
510fn environment_flag(name: &str) -> bool {
511    std::env::var(name).is_ok_and(|value| {
512        matches!(
513            value.trim().to_ascii_lowercase().as_str(),
514            "1" | "true" | "yes"
515        )
516    })
517}
518
519#[cfg(target_os = "macos")]
520fn macos_provider(box_dir: Option<&Path>, legacy_apfs_requested: bool) -> Box<dyn RootfsProvider> {
521    if let Some(box_dir) = box_dir {
522        for (path, state) in [
523            (
524                GuestNativeExt4Provider::artifact_directory(box_dir),
525                "retained raw rootfs",
526            ),
527            (
528                GuestNativeExt4Provider::migration_path(box_dir),
529                "rootfs migration transaction",
530            ),
531        ] {
532            match std::fs::symlink_metadata(&path) {
533                Ok(_) => {
534                    tracing::info!(
535                        path = %path.display(),
536                        %state,
537                        "Selecting guest-native provider for durable rootfs state"
538                    );
539                    return Box::new(GuestNativeExt4Provider);
540                }
541                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
542                Err(error) => {
543                    tracing::warn!(
544                        path = %path.display(),
545                        %error,
546                        %state,
547                        "Cannot inspect durable rootfs state; selecting its provider to fail closed"
548                    );
549                    return Box::new(GuestNativeExt4Provider);
550                }
551            }
552        }
553    }
554
555    if legacy_apfs_requested {
556        tracing::warn!(
557            environment = LEGACY_APFS_ROOTFS_ENV,
558            "Using explicitly requested legacy APFS rootfs compatibility provider"
559        );
560        return Box::new(CaseSensitiveApfsProvider);
561    }
562
563    tracing::info!("Using guest-native ext4 rootfs provider");
564    Box::new(GuestNativeExt4Provider)
565}
566
567/// Auto-detect the best available rootfs provider for a new boot.
568pub fn default_provider() -> Box<dyn RootfsProvider> {
569    default_provider_for_boot(false)
570}
571
572/// Select a provider for a new boot with explicit snapshot requirements.
573pub(crate) fn default_provider_for_boot(snapshot_requested: bool) -> Box<dyn RootfsProvider> {
574    #[cfg(target_os = "macos")]
575    {
576        // VMM memory snapshots are capability-validated before layout side
577        // effects. They must never silently select a mounting rootfs transport
578        // on an unsupported host.
579        let _ = snapshot_requested;
580        macos_provider(None, environment_flag(LEGACY_APFS_ROOTFS_ENV))
581    }
582
583    #[cfg(not(target_os = "macos"))]
584    {
585        let _ = snapshot_requested;
586        if super::overlay::is_overlay_supported() {
587            tracing::info!("Using overlayfs rootfs provider");
588            return Box::new(OverlayProvider);
589        }
590
591        tracing::info!("Overlayfs not available, using copy provider");
592        Box::new(CopyProvider)
593    }
594}
595
596/// Select the provider for an existing box generation.
597///
598/// A raw disk is durable box state, so its provider identity must not depend on
599/// compatibility settings supplied to a later `start` invocation.
600pub fn default_provider_for_box(box_dir: &Path) -> Box<dyn RootfsProvider> {
601    default_provider_for_box_boot(box_dir, false)
602}
603
604/// Select a provider for an existing box with explicit snapshot requirements.
605pub(crate) fn default_provider_for_box_boot(
606    box_dir: &Path,
607    snapshot_requested: bool,
608) -> Box<dyn RootfsProvider> {
609    #[cfg(target_os = "macos")]
610    {
611        let _ = snapshot_requested;
612        macos_provider(Some(box_dir), environment_flag(LEGACY_APFS_ROOTFS_ENV))
613    }
614
615    #[cfg(not(target_os = "macos"))]
616    {
617        let _ = box_dir;
618        default_provider_for_boot(snapshot_requested)
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use tempfile::TempDir;
626
627    fn make_sample_rootfs(dir: &Path) {
628        std::fs::create_dir_all(dir.join("etc")).unwrap();
629        std::fs::create_dir_all(dir.join("bin")).unwrap();
630        std::fs::write(dir.join("etc/hostname"), "testbox").unwrap();
631        std::fs::write(dir.join("bin/hello"), "#!/bin/sh\necho hi").unwrap();
632    }
633
634    #[test]
635    fn test_copy_provider_prepare() {
636        let tmp = TempDir::new().unwrap();
637        let cache_dir = tmp.path().join("cache");
638        let box_dir = tmp.path().join("box");
639        std::fs::create_dir_all(&cache_dir).unwrap();
640        std::fs::create_dir_all(&box_dir).unwrap();
641        make_sample_rootfs(&cache_dir);
642
643        let provider = CopyProvider;
644        let rootfs = provider.prepare(&box_dir, &cache_dir).unwrap();
645
646        assert_eq!(rootfs, box_dir.join("rootfs"));
647        assert!(rootfs.join("etc/hostname").exists());
648        assert_eq!(
649            std::fs::read_to_string(rootfs.join("etc/hostname")).unwrap(),
650            "testbox"
651        );
652        assert!(rootfs.join("bin/hello").exists());
653    }
654
655    #[test]
656    fn copy_provider_finalizes_to_directory_transport() {
657        let tmp = TempDir::new().unwrap();
658        let box_dir = tmp.path().join("box");
659        let rootfs = box_dir.join("rootfs");
660
661        let source = CopyProvider
662            .finalize_for_boot(
663                &box_dir,
664                &rootfs,
665                RootfsFinalizeOptions {
666                    disk_mib: 4096,
667                    persistent: false,
668                    snapshot: false,
669                    artifact_cache: None,
670                },
671            )
672            .unwrap();
673
674        assert_eq!(source, RootfsSource::directory(rootfs));
675    }
676
677    #[cfg(target_os = "macos")]
678    #[test]
679    fn directory_provider_rejects_translated_guest_names() {
680        use a3s_box_core::rootfs_metadata::{
681            RootfsEntryKind, RootfsMetadataEntry, RootfsMetadataManifest,
682            IMAGE_ROOTFS_METADATA_PATH, ROOTFS_METADATA_SCHEMA,
683        };
684        use base64::Engine as _;
685
686        let tmp = TempDir::new().unwrap();
687        let box_dir = tmp.path().join("box");
688        let rootfs = box_dir.join("rootfs");
689        std::fs::create_dir_all(&rootfs).unwrap();
690        let manifest = RootfsMetadataManifest {
691            schema: ROOTFS_METADATA_SCHEMA.to_string(),
692            entries: vec![RootfsMetadataEntry {
693                path_base64: base64::engine::general_purpose::STANDARD.encode(b"./name-\xff"),
694                kind: RootfsEntryKind::Regular,
695                mode: 0o644,
696                uid: 0,
697                gid: 0,
698                mtime: 0,
699                size: 0,
700                link_target_base64: None,
701            }],
702        };
703        std::fs::write(
704            rootfs.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')),
705            serde_json::to_vec(&manifest).unwrap(),
706        )
707        .unwrap();
708
709        let error = CopyProvider
710            .finalize_for_boot(
711                &box_dir,
712                &rootfs,
713                RootfsFinalizeOptions {
714                    disk_mib: 4096,
715                    persistent: false,
716                    snapshot: false,
717                    artifact_cache: None,
718                },
719            )
720            .unwrap_err()
721            .to_string();
722        assert!(error.contains("cannot be exposed losslessly"));
723        assert!(error.contains("default guest-native rootfs"));
724    }
725
726    #[test]
727    fn test_copy_provider_prepare_reuses_existing_rootfs_without_overwriting() {
728        let tmp = TempDir::new().unwrap();
729        let cache_dir = tmp.path().join("cache");
730        let box_dir = tmp.path().join("box");
731        let rootfs = box_dir.join("rootfs");
732        std::fs::create_dir_all(&cache_dir).unwrap();
733        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
734        make_sample_rootfs(&cache_dir);
735        std::fs::write(rootfs.join("etc/hostname"), "persistent-host").unwrap();
736
737        let provider = CopyProvider;
738        let prepared = provider.prepare(&box_dir, &cache_dir).unwrap();
739
740        assert_eq!(prepared, rootfs);
741        assert_eq!(
742            std::fs::read_to_string(prepared.join("etc/hostname")).unwrap(),
743            "persistent-host"
744        );
745        assert!(
746            !prepared.join("bin/hello").exists(),
747            "existing persistent rootfs must not be overwritten from cache"
748        );
749    }
750
751    #[test]
752    fn test_copy_provider_cleanup() {
753        let tmp = TempDir::new().unwrap();
754        let cache_dir = tmp.path().join("cache");
755        let box_dir = tmp.path().join("box");
756        std::fs::create_dir_all(&cache_dir).unwrap();
757        std::fs::create_dir_all(&box_dir).unwrap();
758        make_sample_rootfs(&cache_dir);
759
760        let provider = CopyProvider;
761        let rootfs = provider.prepare(&box_dir, &cache_dir).unwrap();
762        assert!(rootfs.exists());
763
764        provider.cleanup(&box_dir, false).unwrap();
765        assert!(!rootfs.exists());
766    }
767
768    #[test]
769    fn test_copy_provider_cleanup_persistent_keeps_rootfs() {
770        let tmp = TempDir::new().unwrap();
771        let box_dir = tmp.path().join("box");
772        let rootfs = box_dir.join("rootfs");
773        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
774        std::fs::write(rootfs.join("etc/hostname"), "kept").unwrap();
775
776        CopyProvider.cleanup(&box_dir, true).unwrap();
777
778        assert_eq!(
779            std::fs::read_to_string(rootfs.join("etc/hostname")).unwrap(),
780            "kept"
781        );
782    }
783
784    #[test]
785    fn test_copy_provider_cleanup_nonexistent() {
786        let tmp = TempDir::new().unwrap();
787        let provider = CopyProvider;
788        // Should not error on missing dir
789        provider.cleanup(tmp.path(), false).unwrap();
790    }
791
792    #[test]
793    fn test_copy_provider_name() {
794        assert_eq!(CopyProvider.name(), "copy");
795    }
796
797    #[test]
798    fn test_overlay_provider_name() {
799        assert_eq!(OverlayProvider.name(), "overlay");
800    }
801
802    #[test]
803    fn test_overlay_provider_uses_populated_rootfs_as_persistent_lower() {
804        let tmp = TempDir::new().unwrap();
805        let cache_dir = tmp.path().join("cache");
806        let box_dir = tmp.path().join("box");
807        let rootfs = box_dir.join("rootfs");
808        std::fs::create_dir_all(&cache_dir).unwrap();
809        std::fs::create_dir_all(&rootfs).unwrap();
810        std::fs::write(rootfs.join("restart-proof"), "generation-one").unwrap();
811
812        assert_eq!(
813            OverlayProvider::lower_dir(&box_dir, &cache_dir).unwrap(),
814            rootfs
815        );
816    }
817
818    #[test]
819    fn test_overlay_provider_ignores_empty_rootfs_as_lower() {
820        let tmp = TempDir::new().unwrap();
821        let cache_dir = tmp.path().join("cache");
822        let box_dir = tmp.path().join("box");
823        std::fs::create_dir_all(&cache_dir).unwrap();
824        std::fs::create_dir_all(box_dir.join("rootfs")).unwrap();
825
826        assert_eq!(
827            OverlayProvider::lower_dir(&box_dir, &cache_dir).unwrap(),
828            cache_dir
829        );
830    }
831
832    #[test]
833    fn test_overlay_provider_cleanup_persistent_keeps_rootfs_and_upper() {
834        let tmp = TempDir::new().unwrap();
835        let box_dir = tmp.path().join("box");
836        for dir in ["rootfs", "upper", "work", "merged"] {
837            std::fs::create_dir_all(box_dir.join(dir)).unwrap();
838        }
839        std::fs::write(box_dir.join("rootfs/restart-proof"), "generation-one").unwrap();
840        std::fs::write(box_dir.join("upper/data.txt"), "state").unwrap();
841        std::fs::write(box_dir.join("work/scratch.txt"), "work").unwrap();
842        std::fs::write(box_dir.join("merged/view.txt"), "merged").unwrap();
843
844        OverlayProvider.cleanup(&box_dir, true).unwrap();
845
846        assert_eq!(
847            std::fs::read_to_string(box_dir.join("upper/data.txt")).unwrap(),
848            "state"
849        );
850        assert_eq!(
851            std::fs::read_to_string(box_dir.join("rootfs/restart-proof")).unwrap(),
852            "generation-one"
853        );
854        assert!(!box_dir.join("work").exists());
855        assert!(!box_dir.join("merged").exists());
856    }
857
858    #[test]
859    fn test_overlay_provider_cleanup_nonpersistent_removes_all_overlay_dirs() {
860        let tmp = TempDir::new().unwrap();
861        let box_dir = tmp.path().join("box");
862        for dir in ["rootfs", "upper", "work", "merged"] {
863            std::fs::create_dir_all(box_dir.join(dir)).unwrap();
864            std::fs::write(box_dir.join(dir).join("file.txt"), "data").unwrap();
865        }
866
867        OverlayProvider.cleanup(&box_dir, false).unwrap();
868
869        assert!(!box_dir.join("rootfs").exists());
870        assert!(!box_dir.join("upper").exists());
871        assert!(!box_dir.join("work").exists());
872        assert!(!box_dir.join("merged").exists());
873    }
874
875    #[test]
876    fn test_default_provider_returns_something() {
877        let provider = default_provider();
878        // On any platform, we should get a provider
879        assert!(!provider.name().is_empty());
880    }
881
882    #[cfg(target_os = "macos")]
883    #[test]
884    fn macos_non_snapshot_default_is_guest_native() {
885        assert_eq!(macos_provider(None, false).name(), "guest-native-ext4");
886    }
887
888    #[cfg(target_os = "macos")]
889    #[test]
890    fn macos_snapshot_request_cannot_select_a_mounting_transport() {
891        assert_eq!(default_provider_for_boot(true).name(), "guest-native-ext4");
892    }
893
894    #[cfg(target_os = "macos")]
895    #[test]
896    fn macos_explicit_legacy_request_uses_apfs_compatibility() {
897        assert_eq!(macos_provider(None, true).name(), "case-sensitive-apfs");
898    }
899
900    #[cfg(target_os = "macos")]
901    #[test]
902    fn macos_durable_raw_state_cannot_be_downgraded() {
903        let tmp = TempDir::new().unwrap();
904        std::fs::create_dir_all(tmp.path().join("rootfs-ext4-v1")).unwrap();
905
906        assert_eq!(
907            macos_provider(Some(tmp.path()), true).name(),
908            "guest-native-ext4"
909        );
910    }
911
912    #[cfg(target_os = "macos")]
913    #[test]
914    fn case_sensitive_apfs_provider_preserves_distinct_names() {
915        use std::os::unix::fs::MetadataExt;
916
917        let tmp = TempDir::new().unwrap();
918        let box_dir = tmp.path().join("box");
919        let provider = CaseSensitiveApfsProvider;
920        let rootfs = provider.prepare_empty(&box_dir).unwrap();
921        std::fs::write(rootfs.join("Foo"), "upper").unwrap();
922        std::fs::write(rootfs.join("foo"), "lower").unwrap();
923
924        assert_eq!(
925            std::fs::read_to_string(rootfs.join("Foo")).unwrap(),
926            "upper"
927        );
928        assert_eq!(
929            std::fs::read_to_string(rootfs.join("foo")).unwrap(),
930            "lower"
931        );
932        assert_ne!(
933            std::fs::metadata(rootfs.join("Foo")).unwrap().ino(),
934            std::fs::metadata(rootfs.join("foo")).unwrap().ino()
935        );
936
937        provider.cleanup(&box_dir, false).unwrap();
938        assert!(!box_dir.join(CaseSensitiveApfsProvider::IMAGE_NAME).exists());
939    }
940
941    #[cfg(target_os = "macos")]
942    #[test]
943    fn guest_native_ext4_handoff_detaches_apfs_before_boot() {
944        let tmp = TempDir::new().unwrap();
945        let box_dir = tmp.path().join("box");
946        let provider = GuestNativeExt4Provider;
947        let staged = provider.prepare_empty(&box_dir).unwrap();
948        std::fs::create_dir_all(staged.join("etc")).unwrap();
949        std::fs::write(staged.join("etc/hostname"), "guest-native").unwrap();
950        let mountpoint = staged.parent().unwrap().to_path_buf();
951        assert!(super::super::is_mountpoint(&mountpoint));
952
953        let source = provider
954            .finalize_for_boot(
955                &box_dir,
956                &staged,
957                RootfsFinalizeOptions {
958                    disk_mib: 32,
959                    persistent: false,
960                    snapshot: false,
961                    artifact_cache: None,
962                },
963            )
964            .unwrap();
965        let RootfsSource::Ext4Disk { path, read_only } = source else {
966            panic!("guest-native provider returned a directory rootfs")
967        };
968        assert!(path.is_file());
969        assert!(!read_only);
970        assert!(
971            !super::super::is_mountpoint(&mountpoint),
972            "APFS staging mount must be gone before VMM handoff"
973        );
974
975        provider.cleanup(&box_dir, false).unwrap();
976        assert!(!box_dir.join(CaseSensitiveApfsProvider::IMAGE_NAME).exists());
977        assert!(!GuestNativeExt4Provider::artifact_directory(&box_dir).exists());
978    }
979
980    #[cfg(target_os = "macos")]
981    #[test]
982    fn guest_native_ext4_cache_never_shares_its_writable_disk() {
983        use sha2::{Digest, Sha256};
984        use std::io::{Read, Seek, SeekFrom, Write};
985
986        fn digest(path: &Path) -> Vec<u8> {
987            let mut file = std::fs::File::open(path).unwrap();
988            let mut hasher = Sha256::new();
989            let mut buffer = vec![0u8; 1024 * 1024];
990            loop {
991                let read = file.read(&mut buffer).unwrap();
992                if read == 0 {
993                    return hasher.finalize().to_vec();
994                }
995                hasher.update(&buffer[..read]);
996            }
997        }
998
999        let tmp = TempDir::new().unwrap();
1000        let box_dir = tmp.path().join("box");
1001        let cache_dir = tmp.path().join("cache");
1002        let provider = GuestNativeExt4Provider;
1003        let staged = provider.prepare_empty(&box_dir).unwrap();
1004        std::fs::create_dir_all(staged.join("etc")).unwrap();
1005        std::fs::write(staged.join("etc/hostname"), "cached-base").unwrap();
1006
1007        let source = provider
1008            .finalize_for_boot(
1009                &box_dir,
1010                &staged,
1011                RootfsFinalizeOptions {
1012                    disk_mib: 16,
1013                    persistent: false,
1014                    snapshot: false,
1015                    artifact_cache: Some(RootfsArtifactCacheOptions {
1016                        directory: cache_dir.clone(),
1017                        oci_manifest_digest: format!("sha256:{}", "11".repeat(32)),
1018                        platform: "linux/arm64".to_string(),
1019                        guest_init_sha256: format!("sha256:{}", "22".repeat(32)),
1020                        max_entries: 2,
1021                        max_allocated_bytes: u64::MAX,
1022                    }),
1023                },
1024            )
1025            .unwrap();
1026        let RootfsSource::Ext4Disk { path, .. } = source else {
1027            panic!("guest-native provider returned a directory rootfs")
1028        };
1029        assert!(path.starts_with(&box_dir));
1030
1031        let cache_entry = std::fs::read_dir(&cache_dir)
1032            .unwrap()
1033            .filter_map(|entry| entry.ok())
1034            .find(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
1035            .unwrap();
1036        let cached_disk = cache_entry.path().join("artifact/rootfs.ext4");
1037        let cached_before = digest(&cached_disk);
1038        let mut private = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1039        private.seek(SeekFrom::Start(4 * 1024 * 1024)).unwrap();
1040        private.write_all(b"private-generation").unwrap();
1041        private.sync_all().unwrap();
1042        assert_eq!(digest(&cached_disk), cached_before);
1043
1044        provider.cleanup(&box_dir, false).unwrap();
1045        assert!(!path.exists());
1046        assert!(cached_disk.exists());
1047    }
1048
1049    #[cfg(target_os = "macos")]
1050    #[test]
1051    fn guest_native_ext4_persistent_generation_resumes_without_apfs() {
1052        let tmp = TempDir::new().unwrap();
1053        let box_dir = tmp.path().join("box");
1054        let provider = GuestNativeExt4Provider;
1055        let staged = provider.prepare_empty(&box_dir).unwrap();
1056        std::fs::create_dir_all(staged.join("sbin")).unwrap();
1057        std::fs::write(staged.join("sbin/init"), b"guest-init").unwrap();
1058
1059        let first = provider
1060            .finalize_for_boot(
1061                &box_dir,
1062                &staged,
1063                RootfsFinalizeOptions {
1064                    disk_mib: 16,
1065                    persistent: true,
1066                    snapshot: false,
1067                    artifact_cache: None,
1068                },
1069            )
1070            .unwrap();
1071        assert!(!box_dir.join("rootfs-apfs-v2.sparseimage").exists());
1072        provider.cleanup(&box_dir, true).unwrap();
1073
1074        let resumed = provider
1075            .resume_for_boot(
1076                &box_dir,
1077                RootfsResumeOptions {
1078                    disk_mib: 16,
1079                    persistent: true,
1080                    snapshot: false,
1081                },
1082            )
1083            .unwrap()
1084            .unwrap();
1085        assert_eq!(resumed.source, first);
1086        assert_eq!(resumed.guest_init_exec, "/sbin/init");
1087
1088        provider.cleanup(&box_dir, false).unwrap();
1089        let RootfsSource::Ext4Disk { path, .. } = first else {
1090            panic!("guest-native provider returned a directory rootfs")
1091        };
1092        assert!(!path.exists());
1093    }
1094
1095    #[cfg(target_os = "macos")]
1096    #[test]
1097    fn guest_native_ext4_still_rejects_snapshot_generations() {
1098        let tmp = TempDir::new().unwrap();
1099        let error = GuestNativeExt4Provider
1100            .finalize_for_boot(
1101                tmp.path(),
1102                tmp.path(),
1103                RootfsFinalizeOptions {
1104                    disk_mib: 32,
1105                    persistent: false,
1106                    snapshot: true,
1107                    artifact_cache: None,
1108                },
1109            )
1110            .unwrap_err()
1111            .to_string();
1112
1113        assert!(error.contains("snapshot"), "{error}");
1114    }
1115
1116    #[cfg(target_os = "macos")]
1117    #[test]
1118    fn retained_raw_generation_selects_guest_native_provider() {
1119        let tmp = TempDir::new().unwrap();
1120        std::fs::create_dir_all(tmp.path().join("rootfs-ext4-v1")).unwrap();
1121
1122        assert_eq!(
1123            default_provider_for_box(tmp.path()).name(),
1124            "guest-native-ext4"
1125        );
1126    }
1127
1128    #[cfg(target_os = "macos")]
1129    #[test]
1130    fn retained_migration_transaction_selects_guest_native_provider() {
1131        let tmp = TempDir::new().unwrap();
1132        std::fs::write(tmp.path().join("rootfs-migration-v1.json"), b"incomplete").unwrap();
1133
1134        assert_eq!(
1135            default_provider_for_box(tmp.path()).name(),
1136            "guest-native-ext4"
1137        );
1138    }
1139
1140    #[cfg(target_os = "linux")]
1141    #[test]
1142    fn test_overlay_provider_prepare_and_cleanup() {
1143        if !super::super::overlay::is_overlay_supported() {
1144            // Skip if overlay not available (e.g., in container without privileges)
1145            return;
1146        }
1147
1148        let tmp = TempDir::new().unwrap();
1149        let cache_dir = tmp.path().join("cache");
1150        let box_dir = tmp.path().join("box");
1151        std::fs::create_dir_all(&cache_dir).unwrap();
1152        std::fs::create_dir_all(&box_dir).unwrap();
1153        make_sample_rootfs(&cache_dir);
1154
1155        let provider = OverlayProvider;
1156        let merged = provider.prepare(&box_dir, &cache_dir).unwrap();
1157
1158        assert_eq!(merged, box_dir.join("merged"));
1159        assert!(merged.join("etc/hostname").exists());
1160        assert_eq!(
1161            std::fs::read_to_string(merged.join("etc/hostname")).unwrap(),
1162            "testbox"
1163        );
1164
1165        // Write to merged — should go to upper
1166        std::fs::write(merged.join("etc/newfile"), "overlay write").unwrap();
1167        assert!(box_dir.join("upper/etc/newfile").exists());
1168
1169        provider.cleanup(&box_dir, false).unwrap();
1170        assert!(!box_dir.join("merged").exists());
1171        assert!(!box_dir.join("upper").exists());
1172        assert!(!box_dir.join("work").exists());
1173    }
1174
1175    #[cfg(target_os = "linux")]
1176    #[test]
1177    fn test_overlay_provider_persistent_cleanup_remounts_retained_upper() {
1178        if !super::super::overlay::is_overlay_supported() {
1179            return;
1180        }
1181
1182        let tmp = TempDir::new().unwrap();
1183        let cache_dir = tmp.path().join("cache");
1184        let box_dir = tmp.path().join("box");
1185        std::fs::create_dir_all(&cache_dir).unwrap();
1186        std::fs::create_dir_all(&box_dir).unwrap();
1187        make_sample_rootfs(&cache_dir);
1188
1189        let provider = OverlayProvider;
1190        let first = provider.prepare(&box_dir, &cache_dir).unwrap();
1191        std::fs::write(first.join("restart-proof"), "generation-one").unwrap();
1192
1193        provider.cleanup(&box_dir, true).unwrap();
1194        assert_eq!(
1195            std::fs::read_to_string(box_dir.join("upper/restart-proof")).unwrap(),
1196            "generation-one"
1197        );
1198
1199        let second = provider.prepare(&box_dir, &cache_dir).unwrap();
1200        assert_eq!(
1201            std::fs::read_to_string(second.join("restart-proof")).unwrap(),
1202            "generation-one"
1203        );
1204
1205        provider.cleanup(&box_dir, false).unwrap();
1206    }
1207
1208    #[cfg(target_os = "linux")]
1209    #[test]
1210    fn bounded_overlay_enforces_the_declared_writable_layer_size() {
1211        if !super::super::overlay::writable_layer_quota_supported() {
1212            return;
1213        }
1214
1215        let tmp = TempDir::new().unwrap();
1216        let cache_dir = tmp.path().join("cache");
1217        let box_dir = tmp.path().join("box");
1218        std::fs::create_dir_all(&cache_dir).unwrap();
1219        std::fs::create_dir_all(&box_dir).unwrap();
1220        make_sample_rootfs(&cache_dir);
1221
1222        let provider = OverlayProvider;
1223        let quota = 2 * 1024 * 1024;
1224        let merged = provider
1225            .prepare_with_options(
1226                &box_dir,
1227                &cache_dir,
1228                RootfsPrepareOptions {
1229                    writable_layer_bytes: Some(quota),
1230                },
1231            )
1232            .unwrap();
1233
1234        let payload = vec![0x5a_u8; 512 * 1024];
1235        let mut rejected = false;
1236        for index in 0..16 {
1237            let path = merged.join(format!("quota-{index}"));
1238            match std::fs::write(path, &payload) {
1239                Ok(()) => {}
1240                Err(error)
1241                    if matches!(
1242                        error.kind(),
1243                        std::io::ErrorKind::WriteZero
1244                            | std::io::ErrorKind::StorageFull
1245                            | std::io::ErrorKind::Other
1246                    ) =>
1247                {
1248                    rejected = true;
1249                    break;
1250                }
1251                Err(error) => panic!("unexpected bounded overlay write error: {error}"),
1252            }
1253        }
1254        assert!(rejected, "writes exceeded the declared tmpfs quota");
1255
1256        provider.cleanup(&box_dir, false).unwrap();
1257        assert!(!box_dir
1258            .join(super::super::overlay::WRITABLE_LAYER_DIR_NAME)
1259            .exists());
1260    }
1261}