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, PartialEq, Eq)]
41pub struct ResumedRootfs {
42 pub source: RootfsSource,
43 pub guest_init_exec: String,
44}
45
46#[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
61pub 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
74pub trait RootfsProvider: Send + Sync {
76 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 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 fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf>;
104
105 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 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 fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()>;
144
145 fn preserve_on_boot_failure(&self, box_dir: &Path) -> bool {
151 let _ = box_dir;
152 false
153 }
154
155 fn record_clean_stop(&self, box_dir: &Path) -> Result<()> {
161 let _ = box_dir;
162 Ok(())
163 }
164
165 fn name(&self) -> &'static str;
167
168 fn supports_artifact_cache(&self) -> bool {
170 false
171 }
172
173 fn supports_direct_oci_assembly(&self) -> bool {
176 false
177 }
178
179 fn guest_owns_terminal_fencing(&self) -> bool {
182 false
183 }
184
185 fn guest_owns_diff_baseline(&self) -> bool {
188 false
189 }
190}
191
192pub 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 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
233pub 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 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 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 super::unmount_box_overlay_for_reuse(&merged)?;
315 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 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
416pub fn default_provider() -> Box<dyn RootfsProvider> {
418 default_provider_for_boot(false)
419}
420
421pub(crate) fn default_provider_for_boot(snapshot_requested: bool) -> Box<dyn RootfsProvider> {
423 #[cfg(target_os = "macos")]
424 {
425 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
445pub fn default_provider_for_box(box_dir: &Path) -> Box<dyn RootfsProvider> {
450 default_provider_for_box_boot(box_dir, false)
451}
452
453pub(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 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 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 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 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}