a3s_box_runtime/rootfs/
baseline.rs1use std::collections::HashMap;
4use std::path::{Component, Path};
5
6use a3s_box_core::error::{BoxError, Result};
7use serde::{Deserialize, Serialize};
8
9pub const DIFF_BASELINE_FILE: &str = "rootfs_snapshot.json";
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct RootfsFileInfo {
15 pub size: u64,
16 pub mode: u32,
17 pub is_dir: bool,
18}
19
20pub fn walk_rootfs(root: &Path) -> Result<HashMap<String, RootfsFileInfo>> {
22 let mut entries = HashMap::new();
23 walk_recursive(root, root, &mut entries)?;
24 Ok(entries)
25}
26
27pub fn create_diff_baseline_if_absent(box_dir: &Path, rootfs: &Path) -> Result<()> {
33 let destination = box_dir.join(DIFF_BASELINE_FILE);
34 if destination.exists() {
35 return Ok(());
36 }
37
38 let entries = walk_rootfs(rootfs)?;
39 let encoded = serde_json::to_vec(&entries).map_err(|error| {
40 BoxError::BuildError(format!("failed to encode rootfs baseline: {error}"))
41 })?;
42 let temporary = box_dir.join(format!(
43 ".{DIFF_BASELINE_FILE}.{}.tmp",
44 uuid::Uuid::new_v4().simple()
45 ));
46 std::fs::write(&temporary, encoded).map_err(BoxError::IoError)?;
47
48 let install = std::fs::hard_link(&temporary, &destination);
52 let _ = std::fs::remove_file(&temporary);
53 match install {
54 Ok(()) => Ok(()),
55 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
56 Err(error) => Err(BoxError::IoError(error)),
57 }
58}
59
60fn walk_recursive(
61 root: &Path,
62 current: &Path,
63 entries: &mut HashMap<String, RootfsFileInfo>,
64) -> Result<()> {
65 let directory = match std::fs::read_dir(current) {
66 Ok(directory) => directory,
67 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()),
68 Err(error) => return Err(BoxError::IoError(error)),
69 };
70
71 for entry in directory {
72 let entry = entry.map_err(BoxError::IoError)?;
73 let path = entry.path();
74 let relative = match path.strip_prefix(root) {
75 Ok(relative) => relative,
76 Err(_) => continue,
77 };
78 if a3s_box_core::rootfs_metadata::is_runtime_internal_rootfs_path(relative) {
79 continue;
80 }
81
82 let metadata = match std::fs::symlink_metadata(&path) {
84 Ok(metadata) => metadata,
85 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
86 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => continue,
87 Err(error) => return Err(BoxError::IoError(error)),
88 };
89
90 #[cfg(unix)]
91 let mode = {
92 use std::os::unix::fs::MetadataExt;
93 metadata.mode()
94 };
95 #[cfg(not(unix))]
96 let mode = 0;
97
98 entries.insert(
99 rootfs_path_string(relative),
100 RootfsFileInfo {
101 size: metadata.len(),
102 mode,
103 is_dir: metadata.is_dir(),
104 },
105 );
106 if metadata.is_dir() {
107 walk_recursive(root, &path, entries)?;
108 }
109 }
110 Ok(())
111}
112
113fn rootfs_path_string(relative: &Path) -> String {
114 let segments = relative
115 .components()
116 .filter_map(|component| match component {
117 Component::Normal(segment) => Some(segment.to_string_lossy()),
118 _ => None,
119 })
120 .collect::<Vec<_>>();
121 format!("/{}", segments.join("/"))
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn baseline_is_first_writer_wins_and_excludes_runtime_control_files() {
130 let directory = tempfile::tempdir().unwrap();
131 let box_dir = directory.path().join("box");
132 let rootfs = box_dir.join("rootfs");
133 std::fs::create_dir_all(rootfs.join("root")).unwrap();
134 std::fs::write(rootfs.join("root/original.txt"), b"original").unwrap();
135 std::fs::write(rootfs.join(".a3s-box-exec.json"), b"runtime").unwrap();
136
137 create_diff_baseline_if_absent(&box_dir, &rootfs).unwrap();
138 std::fs::write(rootfs.join("root/later.txt"), b"later").unwrap();
139 create_diff_baseline_if_absent(&box_dir, &rootfs).unwrap();
140
141 let encoded = std::fs::read(box_dir.join(DIFF_BASELINE_FILE)).unwrap();
142 let baseline: HashMap<String, RootfsFileInfo> = serde_json::from_slice(&encoded).unwrap();
143 assert!(baseline.contains_key("/root/original.txt"));
144 assert!(!baseline.contains_key("/root/later.txt"));
145 assert!(!baseline.contains_key("/.a3s-box-exec.json"));
146 }
147
148 #[cfg(unix)]
149 #[test]
150 fn walk_does_not_follow_rootfs_symlinks() {
151 let directory = tempfile::tempdir().unwrap();
152 let outside = directory.path().join("outside");
153 let rootfs = directory.path().join("rootfs");
154 std::fs::create_dir_all(&outside).unwrap();
155 std::fs::create_dir_all(&rootfs).unwrap();
156 std::fs::write(outside.join("secret"), b"secret").unwrap();
157 std::os::unix::fs::symlink(&outside, rootfs.join("escape")).unwrap();
158
159 let entries = walk_rootfs(&rootfs).unwrap();
160 assert!(entries.contains_key("/escape"));
161 assert!(!entries.contains_key("/escape/secret"));
162 }
163}