a3s_box_runtime/rootfs/
mod.rs1mod builder;
11mod layout;
12pub(crate) mod overlay;
13mod provider;
14
15pub use builder::RootfsBuilder;
16pub use layout::{GuestLayout, GUEST_WORKDIR};
17pub use provider::{default_provider, CopyProvider, OverlayProvider, RootfsProvider};
18
19use std::path::Path;
20
21pub fn read_persisted_exit_code(box_dir: &Path) -> Option<i32> {
27 let candidates = [
28 box_dir.join("upper").join(".a3s_exit_code"),
29 box_dir
30 .join("rootfs")
31 .join(".a3s-rootfs")
32 .join(".a3s_exit_code"),
33 box_dir.join("rootfs").join(".a3s_exit_code"),
34 ];
35
36 candidates.into_iter().find_map(|path| {
37 std::fs::read_to_string(path)
38 .ok()
39 .and_then(|contents| contents.trim().parse::<i32>().ok())
40 })
41}
42
43pub struct AttachedRootfs {
48 path: std::path::PathBuf,
49 detach_on_drop: bool,
50}
51
52impl AttachedRootfs {
53 pub fn path(&self) -> &Path {
54 &self.path
55 }
56}
57
58impl Drop for AttachedRootfs {
59 fn drop(&mut self) {
60 if self.detach_on_drop {
61 unmount_box_rootfs(&self.path);
62 }
63 }
64}
65
66pub fn attach_persistent_rootfs(
72 box_dir: &Path,
73) -> a3s_box_core::error::Result<Option<AttachedRootfs>> {
74 #[cfg(target_os = "macos")]
75 {
76 let image = box_dir.join("rootfs-apfs-v2.sparseimage");
77 if !image.is_file() {
78 return Ok(None);
79 }
80 let rootfs = box_dir.join("rootfs");
81 let was_mounted = is_mountpoint(&rootfs);
82 let path = provider::CaseSensitiveApfsProvider.prepare_empty(box_dir)?;
83 Ok(Some(AttachedRootfs {
84 path,
85 detach_on_drop: !was_mounted,
86 }))
87 }
88
89 #[cfg(not(target_os = "macos"))]
90 {
91 let _ = box_dir;
92 Ok(None)
93 }
94}
95
96pub fn unmount_box_overlay(merged: &Path) {
104 for _ in 0..8 {
105 if !is_mountpoint(merged) {
106 break;
107 }
108 if overlay::overlay_unmount(merged).is_err() {
109 break;
110 }
111 }
112}
113
114#[cfg(unix)]
116pub(crate) fn is_mountpoint(path: &Path) -> bool {
117 use std::os::unix::fs::MetadataExt;
118 match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
119 (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
120 _ => false,
121 }
122}
123
124#[cfg(not(unix))]
125pub(crate) fn is_mountpoint(_path: &Path) -> bool {
126 false
127}
128
129pub fn unmount_box_rootfs(rootfs: &Path) {
131 #[cfg(target_os = "macos")]
132 {
133 let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
137 rootfs.parent().unwrap_or(rootfs)
138 } else {
139 rootfs
140 };
141 if !is_mountpoint(mountpoint) {
142 return;
143 }
144 match std::process::Command::new("hdiutil")
145 .arg("detach")
146 .arg("-quiet")
147 .arg(mountpoint)
148 .status()
149 {
150 Ok(status) if status.success() => {}
151 Ok(status) => tracing::warn!(
152 path = %mountpoint.display(),
153 ?status,
154 "Failed to detach case-sensitive rootfs image"
155 ),
156 Err(error) => tracing::warn!(
157 path = %mountpoint.display(),
158 %error,
159 "Failed to run hdiutil detach"
160 ),
161 }
162 }
163
164 #[cfg(not(target_os = "macos"))]
165 let _ = rootfs;
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn persisted_exit_code_supports_each_rootfs_provider_layout() {
174 for (relative, expected) in [
175 ("upper/.a3s_exit_code", 17),
176 ("rootfs/.a3s_exit_code", 23),
177 ("rootfs/.a3s-rootfs/.a3s_exit_code", 29),
178 ] {
179 let temp = tempfile::tempdir().unwrap();
180 let path = temp.path().join(relative);
181 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
182 std::fs::write(path, format!("{expected}\n")).unwrap();
183
184 assert_eq!(read_persisted_exit_code(temp.path()), Some(expected));
185 }
186 }
187
188 #[test]
189 fn persisted_exit_code_ignores_missing_or_invalid_files() {
190 let temp = tempfile::tempdir().unwrap();
191 assert_eq!(read_persisted_exit_code(temp.path()), None);
192
193 let path = temp.path().join("rootfs/.a3s_exit_code");
194 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
195 std::fs::write(path, "not-an-exit-code").unwrap();
196 assert_eq!(read_persisted_exit_code(temp.path()), None);
197 }
198
199 #[test]
200 fn missing_path_is_not_mountpoint() {
201 let temp = tempfile::tempdir().unwrap();
202 let missing = temp.path().join("missing");
203
204 assert!(!is_mountpoint(&missing));
205 }
206
207 #[test]
208 fn unmount_overlay_noops_for_non_mountpoint() {
209 let temp = tempfile::tempdir().unwrap();
210 let merged = temp.path().join("merged");
211 std::fs::create_dir(&merged).unwrap();
212
213 unmount_box_overlay(&merged);
214
215 assert!(merged.exists());
216 }
217}