kranz_engine/
disk_preflight.rs1use cap_fs_ext::DirExt as _;
14use cap_std::ambient_authority;
15use cap_std::fs::Dir;
16use std::path::Path;
17
18#[cfg(unix)]
19fn statvfs_field_to_u64<T: Into<u64>>(value: T) -> u64 {
20 value.into()
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum DiskPreflight {
26 Sufficient,
30 Insufficient {
32 free_bytes: u64,
33 estimate_bytes: u64,
34 },
35}
36
37#[cfg(unix)]
41pub fn available_bytes(path: &Path) -> Option<u64> {
42 use std::os::unix::ffi::OsStrExt;
43 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
44 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
45 let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
46 if rc != 0 {
47 return None;
48 }
49 Some(statvfs_field_to_u64(stat.f_bavail).saturating_mul(statvfs_field_to_u64(stat.f_frsize)))
50}
51
52#[cfg(not(unix))]
55pub fn available_bytes(_path: &Path) -> Option<u64> {
56 None
57}
58
59const FOOTPRINT_MULTIPLIER: u64 = 2;
64
65const FOOTPRINT_FLOOR_BYTES: u64 = 4 * 1024 * 1024 * 1024; pub fn estimate_build_footprint_bytes(repo_root: &Path) -> u64 {
75 const WALK_CAP: u64 = 256 * 1024 * 1024 * 1024; let measured = dir_size_capped(&repo_root.join("target"), WALK_CAP);
79 measured
80 .saturating_mul(FOOTPRINT_MULTIPLIER)
81 .max(FOOTPRINT_FLOOR_BYTES)
82}
83
84fn dir_size_capped(dir: &Path, cap: u64) -> u64 {
85 const MAX_WALK_ENTRIES: usize = 250_000;
89 dir_size_capped_with_entry_limit(dir, cap, MAX_WALK_ENTRIES)
90}
91
92fn dir_size_capped_with_entry_limit(dir: &Path, cap: u64, max_entries: usize) -> u64 {
93 let Some(parent) = dir.parent() else {
94 return 0;
95 };
96 let Some(name) = dir.file_name() else {
97 return 0;
98 };
99 let Ok(parent) = Dir::open_ambient_dir(parent, ambient_authority()) else {
103 return 0;
104 };
105 let Ok(root) = parent.open_dir_nofollow(name) else {
106 return 0;
107 };
108
109 let mut total = 0u64;
110 let mut visited = 0usize;
111 let mut stack = vec![root];
112 while let Some(d) = stack.pop() {
113 let Ok(entries) = d.entries() else {
114 continue;
115 };
116 for entry in entries.flatten() {
117 visited = visited.saturating_add(1);
118 if visited > max_entries {
119 return cap;
120 }
121 let name = entry.file_name();
122 let Ok(file_type) = entry.file_type() else {
123 continue;
124 };
125 if file_type.is_dir() {
126 if let Ok(child) = d.open_dir_nofollow(&name) {
127 stack.push(child);
128 }
129 } else if file_type.is_file() {
130 if let Ok(meta) = d.symlink_metadata(&name) {
134 if meta.file_type().is_file() {
135 total = total.saturating_add(meta.len());
136 if total >= cap {
137 return total;
138 }
139 }
140 }
141 }
142 }
143 }
144 total
145}
146
147pub fn check(repo_root: &Path) -> DiskPreflight {
150 decide(
151 available_bytes(repo_root),
152 estimate_build_footprint_bytes(repo_root),
153 )
154}
155
156fn decide(free: Option<u64>, estimate: u64) -> DiskPreflight {
160 match free {
161 Some(free) if free < estimate => DiskPreflight::Insufficient {
162 free_bytes: free,
163 estimate_bytes: estimate,
164 },
165 _ => DiskPreflight::Sufficient,
166 }
167}
168
169pub fn gib(bytes: u64) -> String {
171 format!("{:.1} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn mission_build_footprint_estimate_floors_a_targetless_repo() {
180 let dir = tempfile::tempdir().unwrap();
181 let estimate = estimate_build_footprint_bytes(dir.path());
183 assert_eq!(estimate, FOOTPRINT_FLOOR_BYTES);
184 }
185
186 #[test]
187 fn mission_build_footprint_measurement_and_cap() {
188 let dir = tempfile::tempdir().unwrap();
189 let target = dir.path().join("target").join("debug");
190 std::fs::create_dir_all(&target).unwrap();
191 let chunk = vec![0_u8; 1024 * 1024];
193 for i in 0..3 {
194 std::fs::write(target.join(format!("f{i}")), &chunk).unwrap();
195 }
196 assert_eq!(
198 dir_size_capped(&dir.path().join("target"), u64::MAX),
199 3 * 1024 * 1024
200 );
201 assert_eq!(
202 dir_size_capped(&dir.path().join("target"), 1024 * 1024),
203 1024 * 1024
204 );
205 assert_eq!(
208 estimate_build_footprint_bytes(dir.path()),
209 FOOTPRINT_FLOOR_BYTES
210 );
211 }
212
213 #[test]
214 fn mission_build_footprint_entry_limit_is_conservative() {
215 let dir = tempfile::tempdir().unwrap();
216 let target = dir.path().join("target");
217 std::fs::create_dir(&target).unwrap();
218 for i in 0..3 {
219 std::fs::write(target.join(format!("empty-{i}")), []).unwrap();
220 }
221 assert_eq!(
222 dir_size_capped_with_entry_limit(&target, 123_456, 2),
223 123_456
224 );
225 }
226
227 #[cfg(unix)]
228 #[test]
229 fn mission_build_footprint_never_follows_symlinks() {
230 use std::os::unix::fs::symlink;
231
232 let dir = tempfile::tempdir().unwrap();
233 let target = dir.path().join("target");
234 let outside = tempfile::tempdir().unwrap();
235 std::fs::create_dir(&target).unwrap();
236 std::fs::write(target.join("real"), [0_u8; 7]).unwrap();
237 std::fs::write(outside.path().join("large"), vec![0_u8; 1024 * 1024]).unwrap();
238 symlink(outside.path(), target.join("outside-link")).unwrap();
239
240 assert_eq!(dir_size_capped(&target, u64::MAX), 7);
241 }
242
243 #[test]
244 fn mission_build_footprint_decide_refuses_only_below_the_estimate() {
245 assert_eq!(
247 decide(Some(1024), 2048),
248 DiskPreflight::Insufficient {
249 free_bytes: 1024,
250 estimate_bytes: 2048
251 }
252 );
253 assert_eq!(decide(Some(2048), 2048), DiskPreflight::Sufficient);
255 assert_eq!(decide(Some(4096), 2048), DiskPreflight::Sufficient);
256 assert_eq!(decide(None, 2048), DiskPreflight::Sufficient);
258 }
259
260 #[cfg(unix)]
261 #[test]
262 fn mission_build_footprint_available_bytes_reports_a_real_figure() {
263 let dir = tempfile::tempdir().unwrap();
264 let free = available_bytes(dir.path()).expect("statvfs on a tempdir");
265 assert!(free > 0, "a real volume reports non-zero free bytes");
266 }
267}