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, PathBuf};
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 stage_box_terminal_rootfs_metadata(box_dir: &Path) -> a3s_box_core::error::Result<()> {
104 let attached = attach_persistent_rootfs(box_dir)?;
105 let mut roots = Vec::<PathBuf>::new();
106 if let Some(rootfs) = attached.as_ref() {
107 roots.push(rootfs.path().to_path_buf());
108 }
109 roots.extend([
110 box_dir.join("rootfs"),
111 box_dir.join("upper"),
112 box_dir.join("merged"),
113 ]);
114 roots.sort();
115 roots.dedup();
116
117 let mut existing_roots = Vec::new();
118 for root in roots {
119 match std::fs::symlink_metadata(&root) {
120 Ok(_) => existing_roots.push(root),
121 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
122 Err(error) => return Err(error.into()),
123 }
124 }
125 stage_metadata_roots(&existing_roots)?;
126 Ok(())
127}
128
129fn stage_metadata_roots(roots: &[PathBuf]) -> std::io::Result<()> {
130 for root in roots {
131 a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(root)?;
132 }
133 Ok(())
134}
135
136pub fn unmount_box_overlay(merged: &Path) {
144 for _ in 0..8 {
145 if !is_mountpoint(merged) {
146 break;
147 }
148 if overlay::overlay_unmount(merged).is_err() {
149 break;
150 }
151 }
152}
153
154#[cfg(unix)]
156pub(crate) fn is_mountpoint(path: &Path) -> bool {
157 use std::os::unix::fs::MetadataExt;
158 match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) {
159 (Ok(here), Ok(parent)) => here.dev() != parent.dev(),
160 _ => false,
161 }
162}
163
164#[cfg(not(unix))]
165pub(crate) fn is_mountpoint(_path: &Path) -> bool {
166 false
167}
168
169pub fn unmount_box_rootfs(rootfs: &Path) {
171 #[cfg(target_os = "macos")]
172 {
173 let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") {
177 rootfs.parent().unwrap_or(rootfs)
178 } else {
179 rootfs
180 };
181 if !is_mountpoint(mountpoint) {
182 return;
183 }
184 match std::process::Command::new("hdiutil")
185 .arg("detach")
186 .arg("-quiet")
187 .arg(mountpoint)
188 .status()
189 {
190 Ok(status) if status.success() => {}
191 Ok(status) => tracing::warn!(
192 path = %mountpoint.display(),
193 ?status,
194 "Failed to detach case-sensitive rootfs image"
195 ),
196 Err(error) => tracing::warn!(
197 path = %mountpoint.display(),
198 %error,
199 "Failed to run hdiutil detach"
200 ),
201 }
202 }
203
204 #[cfg(not(target_os = "macos"))]
205 let _ = rootfs;
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn persisted_exit_code_supports_each_rootfs_provider_layout() {
214 for (relative, expected) in [
215 ("upper/.a3s_exit_code", 17),
216 ("rootfs/.a3s_exit_code", 23),
217 ("rootfs/.a3s-rootfs/.a3s_exit_code", 29),
218 ] {
219 let temp = tempfile::tempdir().unwrap();
220 let path = temp.path().join(relative);
221 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
222 std::fs::write(path, format!("{expected}\n")).unwrap();
223
224 assert_eq!(read_persisted_exit_code(temp.path()), Some(expected));
225 }
226 }
227
228 #[test]
229 fn persisted_exit_code_ignores_missing_or_invalid_files() {
230 let temp = tempfile::tempdir().unwrap();
231 assert_eq!(read_persisted_exit_code(temp.path()), None);
232
233 let path = temp.path().join("rootfs/.a3s_exit_code");
234 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
235 std::fs::write(path, "not-an-exit-code").unwrap();
236 assert_eq!(read_persisted_exit_code(temp.path()), None);
237 }
238
239 #[test]
240 fn missing_path_is_not_mountpoint() {
241 let temp = tempfile::tempdir().unwrap();
242 let missing = temp.path().join("missing");
243
244 assert!(!is_mountpoint(&missing));
245 }
246
247 #[test]
248 fn unmount_overlay_noops_for_non_mountpoint() {
249 let temp = tempfile::tempdir().unwrap();
250 let merged = temp.path().join("merged");
251 std::fs::create_dir(&merged).unwrap();
252
253 unmount_box_overlay(&merged);
254
255 assert!(merged.exists());
256 }
257
258 #[test]
259 fn staging_is_idempotent_until_guest_replay_succeeds() {
260 let root = tempfile::tempdir().unwrap();
261 let terminal = root
262 .path()
263 .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/'));
264 let previous = root.path().join(
265 a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/'),
266 );
267 std::fs::write(&terminal, b"clean generation").unwrap();
268
269 stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
270 stage_metadata_roots(&[root.path().to_path_buf()]).unwrap();
271
272 assert!(!terminal.exists());
273 assert_eq!(std::fs::read(previous).unwrap(), b"clean generation");
274 }
275
276 #[test]
277 fn staging_one_candidate_never_discards_an_alias_replay() {
278 let directory = tempfile::tempdir().unwrap();
279 let merged = directory.path().join("merged");
280 let upper = directory.path().join("upper");
281 std::fs::create_dir_all(&merged).unwrap();
282 std::fs::create_dir_all(&upper).unwrap();
283 let terminal_name =
284 a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/');
285 let previous_name =
286 a3s_box_core::rootfs_metadata::PREVIOUS_ROOTFS_METADATA_PATH.trim_start_matches('/');
287 std::fs::write(merged.join(terminal_name), b"clean generation").unwrap();
288 std::fs::write(upper.join(previous_name), b"clean generation").unwrap();
291
292 stage_metadata_roots(&[merged.clone(), upper.clone()]).unwrap();
293
294 assert!(merged.join(previous_name).is_file());
295 assert!(upper.join(previous_name).is_file());
296 }
297}