a3s_box_runtime/rootfs/
mod.rs1mod baseline;
11mod builder;
12mod layout;
13pub(crate) mod overlay;
14mod provider;
15
16pub use baseline::{
17 create_diff_baseline_if_absent, walk_rootfs, RootfsFileInfo, DIFF_BASELINE_FILE,
18};
19pub use builder::RootfsBuilder;
20pub use layout::{GuestLayout, GUEST_WORKDIR};
21pub use provider::{default_provider, CopyProvider, OverlayProvider, RootfsProvider};
22
23use std::path::{Path, PathBuf};
24
25pub fn read_persisted_exit_code(box_dir: &Path) -> Option<i32> {
31 let candidates = [
32 box_dir
33 .join("upper")
34 .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
35 box_dir
36 .join("rootfs")
37 .join(".a3s-rootfs")
38 .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
39 box_dir
40 .join("rootfs")
41 .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
42 ];
43
44 candidates.into_iter().find_map(|path| {
45 std::fs::read_to_string(path)
46 .ok()
47 .and_then(|contents| contents.trim().parse::<i32>().ok())
48 })
49}
50
51pub struct AttachedRootfs {
56 path: std::path::PathBuf,
57 detach_on_drop: bool,
58}
59
60impl AttachedRootfs {
61 pub fn path(&self) -> &Path {
62 &self.path
63 }
64}
65
66impl Drop for AttachedRootfs {
67 fn drop(&mut self) {
68 if self.detach_on_drop {
69 unmount_box_rootfs(&self.path);
70 }
71 }
72}
73
74pub fn attach_persistent_rootfs(
80 box_dir: &Path,
81) -> a3s_box_core::error::Result<Option<AttachedRootfs>> {
82 #[cfg(target_os = "macos")]
83 {
84 let image = box_dir.join("rootfs-apfs-v2.sparseimage");
85 if !image.is_file() {
86 return Ok(None);
87 }
88 let rootfs = box_dir.join("rootfs");
89 let was_mounted = is_mountpoint(&rootfs);
90 let path = provider::CaseSensitiveApfsProvider.prepare_empty(box_dir)?;
91 Ok(Some(AttachedRootfs {
92 path,
93 detach_on_drop: !was_mounted,
94 }))
95 }
96
97 #[cfg(not(target_os = "macos"))]
98 {
99 let _ = box_dir;
100 Ok(None)
101 }
102}
103
104pub fn stage_box_terminal_rootfs_metadata(box_dir: &Path) -> a3s_box_core::error::Result<()> {
112 let attached = attach_persistent_rootfs(box_dir)?;
113 let mut roots = Vec::<PathBuf>::new();
114 if let Some(rootfs) = attached.as_ref() {
115 roots.push(rootfs.path().to_path_buf());
116 }
117 roots.extend([
118 box_dir.join("rootfs"),
119 box_dir.join("upper"),
120 box_dir.join("merged"),
121 ]);
122 roots.sort();
123 roots.dedup();
124
125 let mut existing_roots = Vec::new();
126 for root in roots {
127 match std::fs::symlink_metadata(&root) {
128 Ok(_) => existing_roots.push(root),
129 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
130 Err(error) => return Err(error.into()),
131 }
132 }
133 stage_metadata_roots(&existing_roots)?;
134 Ok(())
135}
136
137fn stage_metadata_roots(roots: &[PathBuf]) -> std::io::Result<()> {
138 for root in roots {
139 a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(root)?;
140 }
141 Ok(())
142}
143
144pub fn unmount_box_overlay(merged: &Path) {
152 for _ in 0..8 {
153 if !is_mountpoint(merged) {
154 break;
155 }
156 if overlay::overlay_unmount(merged).is_err() {
157 break;
158 }
159 }
160}
161
162pub(crate) fn unmount_box_overlay_for_reuse(merged: &Path) -> a3s_box_core::error::Result<()> {
168 for _ in 0..8 {
169 if !is_mountpoint(merged) {
170 return Ok(());
171 }
172 overlay::overlay_unmount_for_reuse(merged)?;
173 }
174
175 if is_mountpoint(merged) {
176 return Err(a3s_box_core::error::BoxError::BuildError(format!(
177 "Overlay at {} remained mounted after synchronous cleanup",
178 merged.display()
179 )));
180 }
181 Ok(())
182}
183
184#[cfg(unix)]
186pub(crate) fn is_mountpoint(path: &Path) -> bool {
187 use std::os::unix::fs::MetadataExt;
188 match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
189 (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
190 _ => false,
191 }
192}
193
194#[cfg(not(unix))]
195pub(crate) fn is_mountpoint(_path: &Path) -> bool {
196 false
197}
198
199pub fn unmount_box_rootfs(rootfs: &Path) {
201 #[cfg(target_os = "macos")]
202 {
203 let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
207 rootfs.parent().unwrap_or(rootfs)
208 } else {
209 rootfs
210 };
211 if !is_mountpoint(mountpoint) {
212 return;
213 }
214 match std::process::Command::new("hdiutil")
215 .arg("detach")
216 .arg("-quiet")
217 .arg(mountpoint)
218 .status()
219 {
220 Ok(status) if status.success() => {}
221 Ok(status) => tracing::warn!(
222 path = %mountpoint.display(),
223 ?status,
224 "Failed to detach case-sensitive rootfs image"
225 ),
226 Err(error) => tracing::warn!(
227 path = %mountpoint.display(),
228 %error,
229 "Failed to run hdiutil detach"
230 ),
231 }
232 }
233
234 #[cfg(not(target_os = "macos"))]
235 let _ = rootfs;
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn persisted_exit_code_supports_each_rootfs_provider_layout() {
244 for (relative, expected) in [
245 ("upper/.a3s_exit_code", 17),
246 ("rootfs/.a3s_exit_code", 23),
247 ("rootfs/.a3s-rootfs/.a3s_exit_code", 29),
248 ] {
249 let temp = tempfile::tempdir().unwrap();
250 let path = temp.path().join(relative);
251 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
252 std::fs::write(path, format!("{expected}\n")).unwrap();
253
254 assert_eq!(read_persisted_exit_code(temp.path()), Some(expected));
255 }
256 }
257
258 #[test]
259 fn persisted_exit_code_ignores_missing_or_invalid_files() {
260 let temp = tempfile::tempdir().unwrap();
261 assert_eq!(read_persisted_exit_code(temp.path()), None);
262
263 let path = temp.path().join("rootfs/.a3s_exit_code");
264 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
265 std::fs::write(path, "not-an-exit-code").unwrap();
266 assert_eq!(read_persisted_exit_code(temp.path()), None);
267 }
268
269 #[test]
270 fn missing_path_is_not_mountpoint() {
271 let temp = tempfile::tempdir().unwrap();
272 let missing = temp.path().join("missing");
273
274 assert!(!is_mountpoint(&missing));
275 }
276
277 #[test]
278 fn unmount_overlay_noops_for_non_mountpoint() {
279 let temp = tempfile::tempdir().unwrap();
280 let merged = temp.path().join("merged");
281 std::fs::create_dir(&merged).unwrap();
282
283 unmount_box_overlay(&merged);
284
285 assert!(merged.exists());
286 }
287
288 #[test]
289 fn staging_is_idempotent_until_guest_replay_succeeds() {
290 let root = tempfile::tempdir().unwrap();
291 let terminal = root
292 .path()
293 .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/'));
294 let previous = root.path().join(
295 a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'),
296 );
297 std::fs::write(&terminal, b"clean generation").unwrap();
298
299 stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
300 stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
301
302 assert!(!terminal.exists());
303 assert_eq!(std::fs::read(previous).unwrap(), b"clean generation");
304 }
305
306 #[test]
307 fn staging_one_candidate_never_discards_an_alias_replay() {
308 let directory = tempfile::tempdir().unwrap();
309 let merged = directory.path().join("merged");
310 let upper = directory.path().join("upper");
311 std::fs::create_dir_all(&merged).unwrap();
312 std::fs::create_dir_all(&upper).unwrap();
313 let terminal_name =
314 a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/');
315 let previous_name =
316 a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/');
317 std::fs::write(merged.join(terminal_name), b"clean generation").unwrap();
318 std::fs::write(upper.join(previous_name), b"clean generation").unwrap();
321
322 stage_metadata_roots(&[merged.clone(), upper.clone()]).unwrap();
323
324 assert!(merged.join(previous_name).is_file());
325 assert!(upper.join(previous_name).is_file());
326 }
327
328 #[test]
329 fn staging_box_roots_clears_every_previous_exit_status() {
330 let directory = tempfile::tempdir().unwrap();
331 let box_dir = directory.path().join("box");
332 for provider_root in ["rootfs", "upper", "merged"] {
333 let root = box_dir.join(provider_root);
334 std::fs::create_dir_all(&root).unwrap();
335 std::fs::write(
336 root.join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/')),
337 b"17\n",
338 )
339 .unwrap();
340 }
341
342 stage_box_terminal_rootfs_metadata(&box_dir).unwrap();
343
344 assert_eq!(read_persisted_exit_code(&box_dir), None);
345 for provider_root in ["rootfs", "upper", "merged"] {
346 assert!(!box_dir
347 .join(provider_root)
348 .join(a3s_box_core::rootfs_metadata::EXIT_CODE_PATH.trim_start_matches('/'))
349 .exists());
350 }
351 }
352}