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