1use std::path::{Path, PathBuf};
8
9use a3s_box_core::error::{BoxError, Result};
10
11pub trait RootfsProvider: Send + Sync {
13 fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf>;
16
17 fn prepare_empty(&self, box_dir: &Path) -> Result<PathBuf> {
19 let rootfs = box_dir.join("rootfs");
20 std::fs::create_dir_all(&rootfs).map_err(|error| {
21 BoxError::BuildError(format!(
22 "Failed to create rootfs {}: {error}",
23 rootfs.display()
24 ))
25 })?;
26 Ok(rootfs)
27 }
28
29 fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()>;
35
36 fn name(&self) -> &'static str;
38}
39
40pub struct CopyProvider;
45
46impl RootfsProvider for CopyProvider {
47 fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
48 let rootfs = box_dir.join("rootfs");
49 if rootfs.exists() {
51 tracing::info!(path = %rootfs.display(), "Reusing persistent rootfs");
52 return Ok(rootfs);
53 }
54 crate::cache::layer_cache::copy_dir_recursive(cache_dir, &rootfs)?;
55 Ok(rootfs)
56 }
57
58 fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()> {
59 if persistent {
60 tracing::info!("Persistent box: keeping rootfs on disk");
61 return Ok(());
62 }
63 let rootfs = box_dir.join("rootfs");
64 if rootfs.exists() {
65 std::fs::remove_dir_all(&rootfs).map_err(|e| {
66 BoxError::BuildError(format!(
67 "Failed to remove rootfs {}: {}",
68 rootfs.display(),
69 e
70 ))
71 })?;
72 }
73 Ok(())
74 }
75
76 fn name(&self) -> &'static str {
77 "copy"
78 }
79}
80
81#[cfg(target_os = "macos")]
88pub struct CaseSensitiveApfsProvider;
89
90#[cfg(target_os = "macos")]
91impl CaseSensitiveApfsProvider {
92 const IMAGE_STEM: &'static str = "rootfs-apfs-v2";
97 const IMAGE_NAME: &'static str = "rootfs-apfs-v2.sparseimage";
98 const DATA_DIR: &'static str = ".a3s-rootfs";
99
100 fn clone_image(source: &Path, destination: &Path) -> Result<()> {
101 let output = std::process::Command::new("cp")
102 .arg("-c")
103 .arg(source)
104 .arg(destination)
105 .output()
106 .map_err(|error| {
107 BoxError::BuildError(format!("Failed to start APFS clone: {error}"))
108 })?;
109 if !output.status.success() {
110 return Err(BoxError::BuildError(format!(
111 "Failed to clone cached APFS rootfs {}: {}",
112 source.display(),
113 String::from_utf8_lossy(&output.stderr).trim()
114 )));
115 }
116 Ok(())
117 }
118
119 fn mount(&self, box_dir: &Path) -> Result<PathBuf> {
120 use std::process::Command;
121
122 std::fs::create_dir_all(box_dir).map_err(|error| {
123 BoxError::BuildError(format!(
124 "Failed to create box directory {}: {error}",
125 box_dir.display()
126 ))
127 })?;
128 let rootfs = box_dir.join("rootfs");
129 std::fs::create_dir_all(&rootfs).map_err(|error| {
130 BoxError::BuildError(format!(
131 "Failed to create APFS mountpoint {}: {error}",
132 rootfs.display()
133 ))
134 })?;
135 if super::is_mountpoint(&rootfs) {
136 return Self::data_dir(&rootfs);
137 }
138
139 let image = box_dir.join(Self::IMAGE_NAME);
140 if !image.exists() {
141 let stem = box_dir.join(Self::IMAGE_STEM);
142 let output = Command::new("hdiutil")
143 .args([
144 "create",
145 "-quiet",
146 "-size",
147 "64g",
148 "-type",
149 "SPARSE",
150 "-fs",
151 "Case-sensitive APFS",
152 "-volname",
153 "A3SRootfs",
154 ])
155 .arg(&stem)
156 .output()
157 .map_err(|error| {
158 BoxError::BuildError(format!("Failed to start hdiutil create: {error}"))
159 })?;
160 if !output.status.success() {
161 return Err(BoxError::BuildError(format!(
162 "Failed to create case-sensitive APFS rootfs image: {}",
163 String::from_utf8_lossy(&output.stderr).trim()
164 )));
165 }
166 }
167
168 let output = Command::new("hdiutil")
169 .args([
170 "attach",
171 "-quiet",
172 "-nobrowse",
173 "-owners",
174 "on",
175 "-mountpoint",
176 ])
177 .arg(&rootfs)
178 .arg(&image)
179 .output()
180 .map_err(|error| {
181 BoxError::BuildError(format!("Failed to start hdiutil attach: {error}"))
182 })?;
183 if !output.status.success() {
184 return Err(BoxError::BuildError(format!(
185 "Failed to mount case-sensitive APFS rootfs image {}: {}",
186 image.display(),
187 String::from_utf8_lossy(&output.stderr).trim()
188 )));
189 }
190 if !super::is_mountpoint(&rootfs) {
191 return Err(BoxError::BuildError(format!(
192 "hdiutil did not mount the rootfs image at {}",
193 rootfs.display()
194 )));
195 }
196 Self::data_dir(&rootfs)
197 }
198
199 fn data_dir(mountpoint: &Path) -> Result<PathBuf> {
200 let data = mountpoint.join(Self::DATA_DIR);
201 std::fs::create_dir_all(&data).map_err(|error| {
202 BoxError::BuildError(format!(
203 "Failed to create APFS rootfs data directory {}: {error}",
204 data.display()
205 ))
206 })?;
207 Ok(data)
208 }
209}
210
211#[cfg(target_os = "macos")]
212impl RootfsProvider for CaseSensitiveApfsProvider {
213 fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
214 let image = box_dir.join(Self::IMAGE_NAME);
215 if cache_dir.is_file() && !image.exists() {
216 std::fs::create_dir_all(box_dir).map_err(BoxError::IoError)?;
217 Self::clone_image(cache_dir, &image)?;
218 }
219 let rootfs = self.mount(box_dir)?;
220 if cache_dir.is_file() {
221 return Ok(rootfs);
222 }
223 if std::fs::read_dir(&rootfs)
224 .map_err(|error| BoxError::BuildError(error.to_string()))?
225 .next()
226 .is_none()
227 {
228 crate::cache::layer_cache::copy_dir_recursive(cache_dir, &rootfs)?;
229 } else {
230 tracing::info!(path = %rootfs.display(), "Reusing persistent APFS rootfs");
231 }
232 Ok(rootfs)
233 }
234
235 fn prepare_empty(&self, box_dir: &Path) -> Result<PathBuf> {
236 self.mount(box_dir)
237 }
238
239 fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()> {
240 super::unmount_box_rootfs(&box_dir.join("rootfs"));
241 if !persistent {
242 let image = box_dir.join(Self::IMAGE_NAME);
243 if image.exists() {
244 std::fs::remove_file(&image).map_err(|error| {
245 BoxError::BuildError(format!(
246 "Failed to remove rootfs image {}: {error}",
247 image.display()
248 ))
249 })?;
250 }
251 }
252 Ok(())
253 }
254
255 fn name(&self) -> &'static str {
256 "case-sensitive-apfs"
257 }
258}
259
260pub struct OverlayProvider;
270
271impl OverlayProvider {
272 fn lower_dir(box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
273 let rootfs = box_dir.join("rootfs");
274 match std::fs::read_dir(&rootfs) {
275 Ok(mut entries) => {
276 if entries.next().is_some() {
277 Ok(rootfs)
283 } else {
284 Ok(cache_dir.to_path_buf())
285 }
286 }
287 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
288 Ok(cache_dir.to_path_buf())
289 }
290 Err(error) => Err(BoxError::BuildError(format!(
291 "Failed to inspect existing rootfs {}: {error}",
292 rootfs.display()
293 ))),
294 }
295 }
296}
297
298impl RootfsProvider for OverlayProvider {
299 fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
300 let lower = Self::lower_dir(box_dir, cache_dir)?;
301 let upper = box_dir.join("upper");
302 let work = box_dir.join("work");
303 let merged = box_dir.join("merged");
304
305 for dir in [&upper, &work, &merged] {
306 std::fs::create_dir_all(dir).map_err(|e| {
307 BoxError::BuildError(format!(
308 "Failed to create overlay dir {}: {}",
309 dir.display(),
310 e
311 ))
312 })?;
313 }
314
315 if super::is_mountpoint(&merged) {
318 tracing::debug!(merged = %merged.display(), "Overlay already mounted; reusing");
319 return Ok(merged);
320 }
321
322 super::overlay::overlay_mount(&lower, &upper, &work, &merged)?;
323
324 tracing::info!(
325 lower = %lower.display(),
326 merged = %merged.display(),
327 "Overlay mount ready"
328 );
329
330 Ok(merged)
331 }
332
333 fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()> {
334 let merged = box_dir.join("merged");
335
336 if persistent {
337 super::unmount_box_overlay_for_reuse(&merged)?;
342 tracing::info!("Persistent box: keeping rootfs and overlay upper on disk");
346 for dir_name in &["merged", "work"] {
347 let dir = box_dir.join(dir_name);
348 if dir.exists() {
349 if let Err(e) = std::fs::remove_dir_all(&dir) {
350 tracing::warn!(path = %dir.display(), error = %e, "Failed to remove overlay dir");
351 }
352 }
353 }
354 return Ok(());
355 }
356
357 super::unmount_box_overlay(&merged);
360
361 for dir_name in &["rootfs", "upper", "work", "merged"] {
362 let dir = box_dir.join(dir_name);
363 if dir.exists() {
364 if let Err(e) = std::fs::remove_dir_all(&dir) {
365 tracing::warn!(
366 path = %dir.display(),
367 error = %e,
368 "Failed to remove overlay dir"
369 );
370 }
371 }
372 }
373
374 Ok(())
375 }
376
377 fn name(&self) -> &'static str {
378 "overlay"
379 }
380}
381
382pub fn default_provider() -> Box<dyn RootfsProvider> {
384 #[cfg(target_os = "macos")]
385 {
386 tracing::info!("Using case-sensitive APFS rootfs provider");
387 Box::new(CaseSensitiveApfsProvider)
388 }
389
390 #[cfg(not(target_os = "macos"))]
391 {
392 if super::overlay::is_overlay_supported() {
393 tracing::info!("Using overlayfs rootfs provider");
394 return Box::new(OverlayProvider);
395 }
396
397 tracing::info!("Overlayfs not available, using copy provider");
398 Box::new(CopyProvider)
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use tempfile::TempDir;
406
407 fn make_sample_rootfs(dir: &Path) {
408 std::fs::create_dir_all(dir.join("etc")).unwrap();
409 std::fs::create_dir_all(dir.join("bin")).unwrap();
410 std::fs::write(dir.join("etc/hostname"), "testbox").unwrap();
411 std::fs::write(dir.join("bin/hello"), "#!/bin/sh\necho hi").unwrap();
412 }
413
414 #[test]
415 fn test_copy_provider_prepare() {
416 let tmp = TempDir::new().unwrap();
417 let cache_dir = tmp.path().join("cache");
418 let box_dir = tmp.path().join("box");
419 std::fs::create_dir_all(&cache_dir).unwrap();
420 std::fs::create_dir_all(&box_dir).unwrap();
421 make_sample_rootfs(&cache_dir);
422
423 let provider = CopyProvider;
424 let rootfs = provider.prepare(&box_dir, &cache_dir).unwrap();
425
426 assert_eq!(rootfs, box_dir.join("rootfs"));
427 assert!(rootfs.join("etc/hostname").exists());
428 assert_eq!(
429 std::fs::read_to_string(rootfs.join("etc/hostname")).unwrap(),
430 "testbox"
431 );
432 assert!(rootfs.join("bin/hello").exists());
433 }
434
435 #[test]
436 fn test_copy_provider_prepare_reuses_existing_rootfs_without_overwriting() {
437 let tmp = TempDir::new().unwrap();
438 let cache_dir = tmp.path().join("cache");
439 let box_dir = tmp.path().join("box");
440 let rootfs = box_dir.join("rootfs");
441 std::fs::create_dir_all(&cache_dir).unwrap();
442 std::fs::create_dir_all(rootfs.join("etc")).unwrap();
443 make_sample_rootfs(&cache_dir);
444 std::fs::write(rootfs.join("etc/hostname"), "persistent-host").unwrap();
445
446 let provider = CopyProvider;
447 let prepared = provider.prepare(&box_dir, &cache_dir).unwrap();
448
449 assert_eq!(prepared, rootfs);
450 assert_eq!(
451 std::fs::read_to_string(prepared.join("etc/hostname")).unwrap(),
452 "persistent-host"
453 );
454 assert!(
455 !prepared.join("bin/hello").exists(),
456 "existing persistent rootfs must not be overwritten from cache"
457 );
458 }
459
460 #[test]
461 fn test_copy_provider_cleanup() {
462 let tmp = TempDir::new().unwrap();
463 let cache_dir = tmp.path().join("cache");
464 let box_dir = tmp.path().join("box");
465 std::fs::create_dir_all(&cache_dir).unwrap();
466 std::fs::create_dir_all(&box_dir).unwrap();
467 make_sample_rootfs(&cache_dir);
468
469 let provider = CopyProvider;
470 let rootfs = provider.prepare(&box_dir, &cache_dir).unwrap();
471 assert!(rootfs.exists());
472
473 provider.cleanup(&box_dir, false).unwrap();
474 assert!(!rootfs.exists());
475 }
476
477 #[test]
478 fn test_copy_provider_cleanup_persistent_keeps_rootfs() {
479 let tmp = TempDir::new().unwrap();
480 let box_dir = tmp.path().join("box");
481 let rootfs = box_dir.join("rootfs");
482 std::fs::create_dir_all(rootfs.join("etc")).unwrap();
483 std::fs::write(rootfs.join("etc/hostname"), "kept").unwrap();
484
485 CopyProvider.cleanup(&box_dir, true).unwrap();
486
487 assert_eq!(
488 std::fs::read_to_string(rootfs.join("etc/hostname")).unwrap(),
489 "kept"
490 );
491 }
492
493 #[test]
494 fn test_copy_provider_cleanup_nonexistent() {
495 let tmp = TempDir::new().unwrap();
496 let provider = CopyProvider;
497 provider.cleanup(tmp.path(), false).unwrap();
499 }
500
501 #[test]
502 fn test_copy_provider_name() {
503 assert_eq!(CopyProvider.name(), "copy");
504 }
505
506 #[test]
507 fn test_overlay_provider_name() {
508 assert_eq!(OverlayProvider.name(), "overlay");
509 }
510
511 #[test]
512 fn test_overlay_provider_uses_populated_rootfs_as_persistent_lower() {
513 let tmp = TempDir::new().unwrap();
514 let cache_dir = tmp.path().join("cache");
515 let box_dir = tmp.path().join("box");
516 let rootfs = box_dir.join("rootfs");
517 std::fs::create_dir_all(&cache_dir).unwrap();
518 std::fs::create_dir_all(&rootfs).unwrap();
519 std::fs::write(rootfs.join("restart-proof"), "generation-one").unwrap();
520
521 assert_eq!(
522 OverlayProvider::lower_dir(&box_dir, &cache_dir).unwrap(),
523 rootfs
524 );
525 }
526
527 #[test]
528 fn test_overlay_provider_ignores_empty_rootfs_as_lower() {
529 let tmp = TempDir::new().unwrap();
530 let cache_dir = tmp.path().join("cache");
531 let box_dir = tmp.path().join("box");
532 std::fs::create_dir_all(&cache_dir).unwrap();
533 std::fs::create_dir_all(box_dir.join("rootfs")).unwrap();
534
535 assert_eq!(
536 OverlayProvider::lower_dir(&box_dir, &cache_dir).unwrap(),
537 cache_dir
538 );
539 }
540
541 #[test]
542 fn test_overlay_provider_cleanup_persistent_keeps_rootfs_and_upper() {
543 let tmp = TempDir::new().unwrap();
544 let box_dir = tmp.path().join("box");
545 for dir in ["rootfs", "upper", "work", "merged"] {
546 std::fs::create_dir_all(box_dir.join(dir)).unwrap();
547 }
548 std::fs::write(box_dir.join("rootfs/restart-proof"), "generation-one").unwrap();
549 std::fs::write(box_dir.join("upper/data.txt"), "state").unwrap();
550 std::fs::write(box_dir.join("work/scratch.txt"), "work").unwrap();
551 std::fs::write(box_dir.join("merged/view.txt"), "merged").unwrap();
552
553 OverlayProvider.cleanup(&box_dir, true).unwrap();
554
555 assert_eq!(
556 std::fs::read_to_string(box_dir.join("upper/data.txt")).unwrap(),
557 "state"
558 );
559 assert_eq!(
560 std::fs::read_to_string(box_dir.join("rootfs/restart-proof")).unwrap(),
561 "generation-one"
562 );
563 assert!(!box_dir.join("work").exists());
564 assert!(!box_dir.join("merged").exists());
565 }
566
567 #[test]
568 fn test_overlay_provider_cleanup_nonpersistent_removes_all_overlay_dirs() {
569 let tmp = TempDir::new().unwrap();
570 let box_dir = tmp.path().join("box");
571 for dir in ["rootfs", "upper", "work", "merged"] {
572 std::fs::create_dir_all(box_dir.join(dir)).unwrap();
573 std::fs::write(box_dir.join(dir).join("file.txt"), "data").unwrap();
574 }
575
576 OverlayProvider.cleanup(&box_dir, false).unwrap();
577
578 assert!(!box_dir.join("rootfs").exists());
579 assert!(!box_dir.join("upper").exists());
580 assert!(!box_dir.join("work").exists());
581 assert!(!box_dir.join("merged").exists());
582 }
583
584 #[test]
585 fn test_default_provider_returns_something() {
586 let provider = default_provider();
587 assert!(!provider.name().is_empty());
589 }
590
591 #[cfg(target_os = "macos")]
592 #[test]
593 fn case_sensitive_apfs_provider_preserves_distinct_names() {
594 use std::os::unix::fs::MetadataExt;
595
596 let tmp = TempDir::new().unwrap();
597 let box_dir = tmp.path().join("box");
598 let provider = CaseSensitiveApfsProvider;
599 let rootfs = provider.prepare_empty(&box_dir).unwrap();
600 std::fs::write(rootfs.join("Foo"), "upper").unwrap();
601 std::fs::write(rootfs.join("foo"), "lower").unwrap();
602
603 assert_eq!(
604 std::fs::read_to_string(rootfs.join("Foo")).unwrap(),
605 "upper"
606 );
607 assert_eq!(
608 std::fs::read_to_string(rootfs.join("foo")).unwrap(),
609 "lower"
610 );
611 assert_ne!(
612 std::fs::metadata(rootfs.join("Foo")).unwrap().ino(),
613 std::fs::metadata(rootfs.join("foo")).unwrap().ino()
614 );
615
616 provider.cleanup(&box_dir, false).unwrap();
617 assert!(!box_dir.join(CaseSensitiveApfsProvider::IMAGE_NAME).exists());
618 }
619
620 #[cfg(target_os = "linux")]
621 #[test]
622 fn test_overlay_provider_prepare_and_cleanup() {
623 if !super::super::overlay::is_overlay_supported() {
624 return;
626 }
627
628 let tmp = TempDir::new().unwrap();
629 let cache_dir = tmp.path().join("cache");
630 let box_dir = tmp.path().join("box");
631 std::fs::create_dir_all(&cache_dir).unwrap();
632 std::fs::create_dir_all(&box_dir).unwrap();
633 make_sample_rootfs(&cache_dir);
634
635 let provider = OverlayProvider;
636 let merged = provider.prepare(&box_dir, &cache_dir).unwrap();
637
638 assert_eq!(merged, box_dir.join("merged"));
639 assert!(merged.join("etc/hostname").exists());
640 assert_eq!(
641 std::fs::read_to_string(merged.join("etc/hostname")).unwrap(),
642 "testbox"
643 );
644
645 std::fs::write(merged.join("etc/newfile"), "overlay write").unwrap();
647 assert!(box_dir.join("upper/etc/newfile").exists());
648
649 provider.cleanup(&box_dir, false).unwrap();
650 assert!(!box_dir.join("merged").exists());
651 assert!(!box_dir.join("upper").exists());
652 assert!(!box_dir.join("work").exists());
653 }
654
655 #[cfg(target_os = "linux")]
656 #[test]
657 fn test_overlay_provider_persistent_cleanup_remounts_retained_upper() {
658 if !super::super::overlay::is_overlay_supported() {
659 return;
660 }
661
662 let tmp = TempDir::new().unwrap();
663 let cache_dir = tmp.path().join("cache");
664 let box_dir = tmp.path().join("box");
665 std::fs::create_dir_all(&cache_dir).unwrap();
666 std::fs::create_dir_all(&box_dir).unwrap();
667 make_sample_rootfs(&cache_dir);
668
669 let provider = OverlayProvider;
670 let first = provider.prepare(&box_dir, &cache_dir).unwrap();
671 std::fs::write(first.join("restart-proof"), "generation-one").unwrap();
672
673 provider.cleanup(&box_dir, true).unwrap();
674 assert_eq!(
675 std::fs::read_to_string(box_dir.join("upper/restart-proof")).unwrap(),
676 "generation-one"
677 );
678
679 let second = provider.prepare(&box_dir, &cache_dir).unwrap();
680 assert_eq!(
681 std::fs::read_to_string(second.join("restart-proof")).unwrap(),
682 "generation-one"
683 );
684
685 provider.cleanup(&box_dir, false).unwrap();
686 }
687}