1use 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#[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#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub struct RootfsPrepareOptions {
46 pub writable_layer_bytes: Option<u64>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ResumedRootfs {
52 pub source: RootfsSource,
53 pub guest_init_exec: String,
54}
55
56#[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
71pub 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
84pub trait RootfsProvider: Send + Sync {
86 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 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 fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf>;
114
115 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 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 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 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 fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()>;
187
188 fn preserve_on_boot_failure(&self, box_dir: &Path) -> bool {
194 let _ = box_dir;
195 false
196 }
197
198 fn record_clean_stop(&self, box_dir: &Path) -> Result<()> {
204 let _ = box_dir;
205 Ok(())
206 }
207
208 fn name(&self) -> &'static str;
210
211 fn supports_artifact_cache(&self) -> bool {
213 false
214 }
215
216 fn supports_direct_oci_assembly(&self) -> bool {
219 false
220 }
221
222 fn guest_owns_terminal_fencing(&self) -> bool {
225 false
226 }
227
228 fn guest_owns_diff_baseline(&self) -> bool {
231 false
232 }
233}
234
235pub 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 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
276pub 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 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 (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 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 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 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 super::unmount_box_overlay_for_reuse(&merged)?;
442 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 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
567pub fn default_provider() -> Box<dyn RootfsProvider> {
569 default_provider_for_boot(false)
570}
571
572pub(crate) fn default_provider_for_boot(snapshot_requested: bool) -> Box<dyn RootfsProvider> {
574 #[cfg(target_os = "macos")]
575 {
576 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
596pub fn default_provider_for_box(box_dir: &Path) -> Box<dyn RootfsProvider> {
601 default_provider_for_box_boot(box_dir, false)
602}
603
604pub(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 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 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 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 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}