1#[cfg(target_os = "macos")]
15mod apfs;
16mod baseline;
17mod builder;
18#[cfg(unix)]
19mod ext4;
20#[cfg(any(target_os = "macos", all(unix, test)))]
21mod ext4_artifact;
22#[cfg(any(target_os = "macos", all(unix, test)))]
23mod ext4_cache;
24#[cfg(target_os = "macos")]
25mod guest_native_ext4;
26#[cfg(target_os = "macos")]
27mod guest_native_migration;
28mod layout;
29#[cfg(any(target_os = "macos", all(unix, test)))]
30mod oci_ext4;
31pub(crate) mod overlay;
32mod provider;
33mod staging_path;
34
35pub use baseline::{
36 create_diff_baseline_if_absent, guest_diff_baseline_required, publish_guest_diff_baseline,
37 walk_rootfs, RootfsFileInfo, DIFF_BASELINE_FILE,
38};
39pub use builder::RootfsBuilder;
40#[cfg(unix)]
41pub use ext4::{
42 publish_ext4_artifact, Ext4Artifact, Ext4ArtifactManifest, Ext4ArtifactOptions,
43 EXT4_ARTIFACT_SCHEMA, EXT4_BUILDER_ID,
44};
45#[cfg(target_os = "macos")]
46pub(crate) use ext4_cache::{Ext4ArtifactCache, Ext4CacheIdentity};
47#[cfg(target_os = "macos")]
48pub use guest_native_ext4::GuestNativeExt4Provider;
49pub use layout::{GuestLayout, GUEST_WORKDIR};
50pub use provider::{
51 default_provider, default_provider_for_box, CopyProvider, OverlayProvider, ResumedRootfs,
52 RootfsArtifactCacheOptions, RootfsFinalizeOptions, RootfsOciPrepareOptions, RootfsProvider,
53 RootfsResumeOptions,
54};
55pub(crate) use provider::{default_provider_for_boot, default_provider_for_box_boot};
56pub(crate) use staging_path::{
57 ensure_directory_transport_is_lossless, host_staging_path, logical_path_for_staged_child,
58 staging_path_map,
59};
60
61use std::io::Read;
62use std::path::{Path, PathBuf};
63
64use a3s_box_core::error::{BoxError, Result};
65use a3s_box_core::guest_exec::{
66 GuestTerminalStatus, GUEST_TERMINAL_STATUS_FILE_NAME, MAX_GUEST_TERMINAL_STATUS_BYTES,
67};
68
69enum TerminalStatusRead {
70 Absent,
71 PendingOrInvalid,
72 Complete(GuestTerminalStatus),
73}
74
75fn read_guest_terminal_status(box_dir: &Path) -> TerminalStatusRead {
76 let path = box_dir
77 .join("runtime-control")
78 .join(GUEST_TERMINAL_STATUS_FILE_NAME);
79 let metadata = match std::fs::symlink_metadata(&path) {
80 Ok(metadata) => metadata,
81 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
82 return TerminalStatusRead::Absent;
83 }
84 Err(_) => return TerminalStatusRead::PendingOrInvalid,
85 };
86 if !metadata.is_file()
87 || metadata.file_type().is_symlink()
88 || metadata.len() > MAX_GUEST_TERMINAL_STATUS_BYTES as u64
89 {
90 return TerminalStatusRead::PendingOrInvalid;
91 }
92
93 let mut options = std::fs::OpenOptions::new();
94 options.read(true);
95 #[cfg(unix)]
96 {
97 use std::os::unix::fs::OpenOptionsExt;
98 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
99 }
100 let Ok(file) = options.open(&path) else {
101 return TerminalStatusRead::PendingOrInvalid;
102 };
103 let mut bytes = Vec::with_capacity(metadata.len() as usize);
104 if file
105 .take(MAX_GUEST_TERMINAL_STATUS_BYTES as u64 + 1)
106 .read_to_end(&mut bytes)
107 .is_err()
108 || bytes.is_empty()
109 || bytes.len() > MAX_GUEST_TERMINAL_STATUS_BYTES
110 {
111 return TerminalStatusRead::PendingOrInvalid;
112 }
113 let Ok(status) = serde_json::from_slice::<GuestTerminalStatus>(&bytes) else {
114 return TerminalStatusRead::PendingOrInvalid;
115 };
116 if status.validate().is_err() {
117 return TerminalStatusRead::PendingOrInvalid;
118 }
119 TerminalStatusRead::Complete(status)
120}
121
122pub(crate) fn guest_rootfs_handoff_complete(box_dir: &Path) -> bool {
125 matches!(
126 read_guest_terminal_status(box_dir),
127 TerminalStatusRead::Complete(GuestTerminalStatus {
128 rootfs_quiesced: true,
129 ..
130 })
131 )
132}
133
134pub fn read_persisted_exit_code(box_dir: &Path) -> Option<i32> {
140 resolve_workload_exit_code(box_dir, None)
141}
142
143pub fn resolve_workload_exit_code(box_dir: &Path, provider_exit_code: Option<i32>) -> Option<i32> {
151 match read_guest_terminal_status(box_dir) {
152 TerminalStatusRead::Complete(status) => return Some(status.exit_code),
153 TerminalStatusRead::PendingOrInvalid => {
156 return provider_exit_code.filter(|exit_code| *exit_code != 0);
157 }
158 TerminalStatusRead::Absent => {}
159 }
160
161 let candidates = [
162 box_dir
163 .join("upper")
164 .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
165 box_dir
166 .join("rootfs")
167 .join(".a3s-rootfs")
168 .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
169 box_dir
170 .join("rootfs")
171 .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
172 ];
173
174 candidates
175 .into_iter()
176 .find_map(|path| {
177 std::fs::read_to_string(path)
178 .ok()
179 .and_then(|contents| contents.trim().parse::<i32>().ok())
180 })
181 .or(provider_exit_code)
182}
183
184pub struct AttachedRootfs {
189 path: std::path::PathBuf,
190 detach_on_drop: bool,
191}
192
193pub fn guest_native_ext4_generation_exists(box_dir: &Path) -> Result<bool> {
199 let path = box_dir.join("rootfs-ext4-v1");
200 match std::fs::symlink_metadata(&path) {
201 Ok(_) => Ok(true),
202 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
203 Err(error) => Err(BoxError::BuildError(format!(
204 "Failed to inspect guest-native rootfs generation {}: {error}",
205 path.display()
206 ))),
207 }
208}
209
210pub fn guest_native_ext4_disk_mib(box_dir: &Path) -> Result<Option<u32>> {
217 if !guest_native_ext4_generation_exists(box_dir)? {
218 return Ok(None);
219 }
220 #[cfg(target_os = "macos")]
221 {
222 let directory = GuestNativeExt4Provider::artifact_directory(box_dir);
223 let (artifact, _) = ext4_artifact::open_ext4_artifact_for_resume(&directory)?;
224 const MIB: u64 = 1024 * 1024;
225 if artifact.manifest.capacity_bytes % MIB != 0 {
226 return Err(BoxError::StateError(format!(
227 "Guest-native rootfs capacity is not MiB-aligned at {}",
228 artifact.disk.display()
229 )));
230 }
231 let disk_mib = u32::try_from(artifact.manifest.capacity_bytes / MIB).map_err(|_| {
232 BoxError::StateError(format!(
233 "Guest-native rootfs capacity exceeds the Box configuration range at {}",
234 artifact.disk.display()
235 ))
236 })?;
237 Ok(Some(disk_mib))
238 }
239 #[cfg(not(target_os = "macos"))]
240 {
241 Err(BoxError::StateError(format!(
242 "Guest-native rootfs state is unsupported on this host: {}",
243 box_dir.join("rootfs-ext4-v1").display()
244 )))
245 }
246}
247
248#[cfg(target_os = "macos")]
254pub(crate) fn open_clean_guest_native_ext4_artifact(
255 artifact_directory: &Path,
256) -> Result<Ext4Artifact> {
257 let (artifact, validation) = ext4_artifact::open_ext4_artifact_for_resume(artifact_directory)?;
258 if validation == ext4::Ext4ResumeValidation::JournalRecoveryRequired {
259 return Err(BoxError::StateError(format!(
260 "Guest-native rootfs at {} needs ext4 journal recovery; start the box and stop it cleanly before creating or restoring a filesystem snapshot",
261 artifact.disk.display()
262 )));
263 }
264 Ok(artifact)
265}
266
267#[cfg(target_os = "macos")]
270pub(crate) fn clone_clean_guest_native_ext4_artifact(
271 artifact_directory: &Path,
272 destination: &Path,
273) -> Result<Ext4Artifact> {
274 let source = open_clean_guest_native_ext4_artifact(artifact_directory)?;
275 let cloned = ext4_cache::clone_artifact(&source, destination)?;
276 let validated = match open_clean_guest_native_ext4_artifact(destination) {
277 Ok(validated) => validated,
278 Err(error) => {
279 let _ = std::fs::remove_dir_all(destination);
280 return Err(error);
281 }
282 };
283 if cloned != validated || source.manifest != validated.manifest {
284 let _ = std::fs::remove_dir_all(destination);
285 return Err(BoxError::StateError(format!(
286 "Cloned guest-native rootfs identity changed at {}",
287 destination.display()
288 )));
289 }
290 Ok(validated)
291}
292
293#[cfg(target_os = "macos")]
294pub(crate) fn guest_native_ext4_sparse_digest(artifact: &Ext4Artifact) -> Result<String> {
295 ext4_cache::sparse_sha256(&artifact.disk, artifact.manifest.capacity_bytes)
296}
297
298#[cfg(target_os = "macos")]
299pub(crate) fn guest_native_ext4_allocated_bytes(artifact_directory: &Path) -> Result<u64> {
300 ext4_cache::allocated_bytes(artifact_directory)
301}
302
303#[cfg(target_os = "macos")]
310pub(crate) fn guest_native_ext4_maintenance_disk(box_dir: &Path) -> Result<PathBuf> {
311 let directory = GuestNativeExt4Provider::artifact_directory(box_dir);
312 let (artifact, validation) = ext4_artifact::open_ext4_artifact_for_resume(&directory)?;
313 if validation == ext4::Ext4ResumeValidation::JournalRecoveryRequired {
314 return Err(BoxError::StateError(format!(
315 "Guest-native rootfs at {} needs ext4 journal recovery; start the box and stop it cleanly before offline diff, export, or commit",
316 artifact.disk.display()
317 )));
318 }
319 Ok(artifact.disk)
320}
321
322impl AttachedRootfs {
323 pub fn path(&self) -> &Path {
324 &self.path
325 }
326}
327
328impl Drop for AttachedRootfs {
329 fn drop(&mut self) {
330 if self.detach_on_drop {
331 unmount_box_rootfs(&self.path);
332 }
333 }
334}
335
336pub fn attach_persistent_rootfs(
342 box_dir: &Path,
343) -> a3s_box_core::error::Result<Option<AttachedRootfs>> {
344 if guest_native_ext4_generation_exists(box_dir)? {
345 return Err(BoxError::StateError(
346 "Guest-native rootfs generations have no host directory attachment; use the trusted maintenance archive path for stopped access"
347 .to_string(),
348 ));
349 }
350
351 #[cfg(target_os = "macos")]
352 {
353 let image = box_dir.join("rootfs-apfs-v2.sparseimage");
354 if !image.is_file() {
355 return Ok(None);
356 }
357 let rootfs = box_dir.join("rootfs");
358 let was_mounted = is_mountpoint(&rootfs);
359 let path = provider::CaseSensitiveApfsProvider.prepare_empty(box_dir)?;
360 Ok(Some(AttachedRootfs {
361 path,
362 detach_on_drop: !was_mounted,
363 }))
364 }
365
366 #[cfg(not(target_os = "macos"))]
367 {
368 let _ = box_dir;
369 Ok(None)
370 }
371}
372
373pub fn stage_box_terminal_rootfs_metadata(box_dir: &Path) -> a3s_box_core::error::Result<()> {
381 if guest_native_ext4_generation_exists(box_dir)? {
382 return Ok(());
385 }
386 let attached = attach_persistent_rootfs(box_dir)?;
387 let mut roots = Vec::<PathBuf>::new();
388 if let Some(rootfs) = attached.as_ref() {
389 roots.push(rootfs.path().to_path_buf());
390 }
391 roots.extend([
392 box_dir.join("rootfs"),
393 box_dir.join("upper"),
394 box_dir.join("merged"),
395 ]);
396 roots.sort();
397 roots.dedup();
398
399 let mut existing_roots = Vec::new();
400 for root in roots {
401 match std::fs::symlink_metadata(&root) {
402 Ok(_) => existing_roots.push(root),
403 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
404 Err(error) => return Err(error.into()),
405 }
406 }
407 stage_metadata_roots(&existing_roots)?;
408 Ok(())
409}
410
411fn stage_metadata_roots(roots: &[PathBuf]) -> std::io::Result<()> {
412 for root in roots {
413 a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(root)?;
414 }
415 Ok(())
416}
417
418pub fn unmount_box_overlay(merged: &Path) {
426 for _ in 0..8 {
427 if !is_mountpoint(merged) {
428 break;
429 }
430 if overlay::overlay_unmount(merged).is_err() {
431 break;
432 }
433 }
434}
435
436pub(crate) fn unmount_box_overlay_for_reuse(merged: &Path) -> a3s_box_core::error::Result<()> {
442 for _ in 0..8 {
443 if !is_mountpoint(merged) {
444 return Ok(());
445 }
446 overlay::overlay_unmount_for_reuse(merged)?;
447 }
448
449 if is_mountpoint(merged) {
450 return Err(a3s_box_core::error::BoxError::BuildError(format!(
451 "Overlay at {} remained mounted after synchronous cleanup",
452 merged.display()
453 )));
454 }
455 Ok(())
456}
457
458#[cfg(unix)]
460pub(crate) fn is_mountpoint(path: &Path) -> bool {
461 use std::os::unix::fs::MetadataExt;
462 match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
463 (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
464 _ => false,
465 }
466}
467
468#[cfg(not(unix))]
469pub(crate) fn is_mountpoint(_path: &Path) -> bool {
470 false
471}
472
473pub fn unmount_box_rootfs(rootfs: &Path) {
475 #[cfg(target_os = "macos")]
476 {
477 let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
481 rootfs.parent().unwrap_or(rootfs)
482 } else {
483 rootfs
484 };
485 if !is_mountpoint(mountpoint) {
486 return;
487 }
488 match std::process::Command::new("hdiutil")
489 .arg("detach")
490 .arg("-quiet")
491 .arg(mountpoint)
492 .status()
493 {
494 Ok(status) if status.success() => {}
495 Ok(status) => tracing::warn!(
496 path = %mountpoint.display(),
497 ?status,
498 "Failed to detach case-sensitive rootfs image"
499 ),
500 Err(error) => tracing::warn!(
501 path = %mountpoint.display(),
502 %error,
503 "Failed to run hdiutil detach"
504 ),
505 }
506 }
507
508 #[cfg(not(target_os = "macos"))]
509 let _ = rootfs;
510}
511
512#[cfg(target_os = "macos")]
517pub(crate) fn unmount_box_rootfs_for_handoff(rootfs: &Path) -> a3s_box_core::error::Result<()> {
518 let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
519 rootfs.parent().unwrap_or(rootfs)
520 } else {
521 rootfs
522 };
523 if !is_mountpoint(mountpoint) {
524 return Err(a3s_box_core::error::BoxError::BuildError(format!(
525 "Expected a mounted rootfs staging filesystem at {}",
526 mountpoint.display()
527 )));
528 }
529 let status = std::process::Command::new("hdiutil")
530 .arg("detach")
531 .arg("-quiet")
532 .arg(mountpoint)
533 .status()
534 .map_err(|error| {
535 a3s_box_core::error::BoxError::BuildError(format!(
536 "Failed to run hdiutil detach for {}: {error}",
537 mountpoint.display()
538 ))
539 })?;
540 if !status.success() || is_mountpoint(mountpoint) {
541 return Err(a3s_box_core::error::BoxError::BuildError(format!(
542 "Rootfs staging filesystem remained attached at {} after handoff",
543 mountpoint.display()
544 )));
545 }
546 Ok(())
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn persisted_exit_code_supports_each_rootfs_provider_layout() {
555 for (relative, expected) in [
556 ("upper/.a3s_exit_code", 17),
557 ("rootfs/.a3s_exit_code", 23),
558 ("rootfs/.a3s-rootfs/.a3s_exit_code", 29),
559 ] {
560 let temp = tempfile::tempdir().unwrap();
561 let path = temp.path().join(relative);
562 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
563 std::fs::write(path, format!("{expected}\n")).unwrap();
564
565 assert_eq!(read_persisted_exit_code(temp.path()), Some(expected));
566 }
567 }
568
569 #[test]
570 fn persisted_exit_code_ignores_missing_or_invalid_files() {
571 let temp = tempfile::tempdir().unwrap();
572 assert_eq!(read_persisted_exit_code(temp.path()), None);
573
574 let path = temp.path().join("rootfs/.a3s_exit_code");
575 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
576 std::fs::write(path, "not-an-exit-code").unwrap();
577 assert_eq!(read_persisted_exit_code(temp.path()), None);
578 }
579
580 #[test]
581 fn terminal_status_is_preferred_over_legacy_rootfs_marker() {
582 let temp = tempfile::tempdir().unwrap();
583 let terminal = temp
584 .path()
585 .join("runtime-control")
586 .join(GUEST_TERMINAL_STATUS_FILE_NAME);
587 std::fs::create_dir_all(terminal.parent().unwrap()).unwrap();
588 std::fs::write(
589 &terminal,
590 serde_json::to_vec(&GuestTerminalStatus::new(31)).unwrap(),
591 )
592 .unwrap();
593 let legacy = temp.path().join("rootfs/.a3s_exit_code");
594 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
595 std::fs::write(legacy, "7").unwrap();
596
597 assert_eq!(read_persisted_exit_code(temp.path()), Some(31));
598 }
599
600 #[test]
601 fn rootfs_handoff_requires_an_explicit_guest_quiescence_ack() {
602 let temp = tempfile::tempdir().unwrap();
603 let terminal = temp
604 .path()
605 .join("runtime-control")
606 .join(GUEST_TERMINAL_STATUS_FILE_NAME);
607 std::fs::create_dir_all(terminal.parent().unwrap()).unwrap();
608
609 std::fs::write(
610 &terminal,
611 serde_json::to_vec(&GuestTerminalStatus::new(0)).unwrap(),
612 )
613 .unwrap();
614 assert!(!guest_rootfs_handoff_complete(temp.path()));
615
616 std::fs::write(
617 &terminal,
618 serde_json::to_vec(&GuestTerminalStatus::new(0).with_rootfs_quiesced()).unwrap(),
619 )
620 .unwrap();
621 assert!(guest_rootfs_handoff_complete(temp.path()));
622 }
623
624 #[test]
625 fn pending_terminal_status_blocks_stale_rootfs_fallback() {
626 let temp = tempfile::tempdir().unwrap();
627 let terminal = temp
628 .path()
629 .join("runtime-control")
630 .join(GUEST_TERMINAL_STATUS_FILE_NAME);
631 std::fs::create_dir_all(terminal.parent().unwrap()).unwrap();
632 std::fs::write(terminal, []).unwrap();
633 let legacy = temp.path().join("rootfs/.a3s_exit_code");
634 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
635 std::fs::write(legacy, "0").unwrap();
636
637 assert_eq!(read_persisted_exit_code(temp.path()), None);
638 assert_eq!(resolve_workload_exit_code(temp.path(), Some(0)), None);
639 assert_eq!(resolve_workload_exit_code(temp.path(), Some(9)), Some(9));
640 }
641
642 #[test]
643 fn missing_path_is_not_mountpoint() {
644 let temp = tempfile::tempdir().unwrap();
645 let missing = temp.path().join("missing");
646
647 assert!(!is_mountpoint(&missing));
648 }
649
650 #[test]
651 fn unmount_overlay_noops_for_non_mountpoint() {
652 let temp = tempfile::tempdir().unwrap();
653 let merged = temp.path().join("merged");
654 std::fs::create_dir(&merged).unwrap();
655
656 unmount_box_overlay(&merged);
657
658 assert!(merged.exists());
659 }
660
661 #[test]
662 fn staging_is_idempotent_until_guest_replay_succeeds() {
663 let root = tempfile::tempdir().unwrap();
664 let terminal = root
665 .path()
666 .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/'));
667 let previous = root.path().join(
668 a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'),
669 );
670 std::fs::write(&terminal, b"clean generation").unwrap();
671
672 stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
673 stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
674
675 assert!(!terminal.exists());
676 assert_eq!(std::fs::read(previous).unwrap(), b"clean generation");
677 }
678
679 #[test]
680 fn staging_one_candidate_never_discards_an_alias_replay() {
681 let directory = tempfile::tempdir().unwrap();
682 let merged = directory.path().join("merged");
683 let upper = directory.path().join("upper");
684 std::fs::create_dir_all(&merged).unwrap();
685 std::fs::create_dir_all(&upper).unwrap();
686 let terminal_name =
687 a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/');
688 let previous_name =
689 a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/');
690 std::fs::write(merged.join(terminal_name), b"clean generation").unwrap();
691 std::fs::write(upper.join(previous_name), b"clean generation").unwrap();
694
695 stage_metadata_roots(&[merged.clone(), upper.clone()]).unwrap();
696
697 assert!(merged.join(previous_name).is_file());
698 assert!(upper.join(previous_name).is_file());
699 }
700
701 #[test]
702 fn staging_box_roots_clears_every_previous_exit_status() {
703 let directory = tempfile::tempdir().unwrap();
704 let box_dir = directory.path().join("box");
705 for provider_root in ["rootfs", "upper", "merged"] {
706 let root = box_dir.join(provider_root);
707 std::fs::create_dir_all(&root).unwrap();
708 std::fs::write(
709 root.join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
710 b"17\n",
711 )
712 .unwrap();
713 }
714
715 stage_box_terminal_rootfs_metadata(&box_dir).unwrap();
716
717 assert_eq!(read_persisted_exit_code(&box_dir), None);
718 for provider_root in ["rootfs", "upper", "merged"] {
719 assert!(!box_dir
720 .join(provider_root)
721 .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/'))
722 .exists());
723 }
724 }
725
726 #[test]
727 fn raw_generation_keeps_terminal_fencing_inside_guest() {
728 let directory = tempfile::tempdir().unwrap();
729 let box_dir = directory.path().join("box");
730 let artifact = box_dir.join("rootfs-ext4-v1");
731 let rootfs = box_dir.join("rootfs");
732 std::fs::create_dir_all(&artifact).unwrap();
733 std::fs::create_dir_all(&rootfs).unwrap();
734 let terminal = rootfs
735 .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/'));
736 std::fs::write(&terminal, b"guest-owned").unwrap();
737
738 assert!(guest_native_ext4_generation_exists(&box_dir).unwrap());
739 stage_box_terminal_rootfs_metadata(&box_dir).unwrap();
740 assert_eq!(std::fs::read(&terminal).unwrap(), b"guest-owned");
741 assert!(attach_persistent_rootfs(&box_dir).is_err());
742 }
743}