1use std::collections::HashSet;
10use std::path::Path;
11use std::path::PathBuf;
12
13use rootcause::prelude::*;
14use sha2::Digest;
15use sha2::Sha256;
16
17const HASH_LEN: usize = 20;
20
21pub const CAS_DIR: &str = "common";
24
25pub const LEGACY_CAS_DIR: &str = "vfs_common";
27
28pub fn cas_root(output_base: &Path) -> PathBuf {
30 output_base.join(CAS_DIR)
31}
32
33pub fn hash_bytes(data: &[u8]) -> String {
36 let digest = Sha256::digest(data);
37 let full_hex = format!("{digest:x}");
38 full_hex[..HASH_LEN].to_string()
39}
40
41pub fn hash_file(path: &Path) -> std::io::Result<String> {
47 use std::io::Read;
48
49 let mut file = std::fs::File::open(path)?;
50 let mut hasher = Sha256::new();
51 let mut buffer = vec![0u8; 64 * 1024];
52 loop {
53 let read = file.read(&mut buffer)?;
54 if read == 0 {
55 break;
56 }
57 hasher.update(&buffer[..read]);
58 }
59 let full_hex = format!("{:x}", hasher.finalize());
60 Ok(full_hex[..HASH_LEN].to_string())
61}
62
63pub fn cas_path(cas_root: &Path, hash: &str) -> PathBuf {
67 cas_root.join(&hash[..2]).join(&hash[2..])
68}
69
70pub fn object_exists(cas_root: &Path, hash: &str) -> bool {
72 cas_path(cas_root, hash).exists()
73}
74
75pub fn store(cas_root: &Path, data: &[u8]) -> Result<String, rootcause::Report> {
86 let hash = hash_bytes(data);
87 let path = cas_path(cas_root, &hash);
88 if path.exists() {
89 return Ok(hash);
90 }
91 let Some(parent) = path.parent() else {
92 bail!("CAS object path {} has no parent directory", path.display());
93 };
94 std::fs::create_dir_all(parent).attach_with(|| format!("Failed to create CAS directory {}", parent.display()))?;
95
96 let temp_path = parent.join(temp_name(&hash));
97 std::fs::write(&temp_path, data).attach_with(|| format!("Failed to write CAS object {}", temp_path.display()))?;
98
99 match std::fs::rename(&temp_path, &path) {
102 Ok(()) => Ok(hash),
103 Err(_) if path.exists() => {
104 let _ = std::fs::remove_file(&temp_path);
105 Ok(hash)
106 }
107 Err(e) => {
108 let _ = std::fs::remove_file(&temp_path);
109 Err(e).attach_with(|| format!("Failed to store CAS object {}", path.display()))?
110 }
111 }
112}
113
114fn temp_name(hash: &str) -> String {
117 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
118 let ticket = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
119 format!(".{}.{}.{}.tmp", &hash[2..], std::process::id(), ticket)
120}
121
122pub fn link_file(cas_root: &Path, hash: &str, link_path: &Path) -> Result<(), rootcause::Report> {
128 let target = cas_path(cas_root, hash);
129 if let Some(parent) = link_path.parent() {
130 std::fs::create_dir_all(parent)
131 .attach_with(|| format!("Failed to create parent directory {}", parent.display()))?;
132 }
133
134 let link_parent = link_path.parent().unwrap_or(Path::new("."));
138 let rel_target = relative_path(link_parent, &target);
139
140 if link_path.symlink_metadata().is_ok() {
143 let _ = std::fs::remove_file(link_path);
144 }
145
146 try_symlink(&rel_target, link_path)
147 .attach_with(|| format!("Failed to create symlink {} -> {}", link_path.display(), rel_target.display()))?;
148 Ok(())
149}
150
151fn relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
153 let from_components: Vec<_> = from_dir.components().collect();
155 let to_components: Vec<_> = to_path.components().collect();
156
157 let common = from_components.iter().zip(to_components.iter()).take_while(|(a, b)| a == b).count();
159
160 let ups = from_components.len() - common;
162 let mut rel = PathBuf::new();
163 for _ in 0..ups {
164 rel.push("..");
165 }
166 for comp in &to_components[common..] {
168 rel.push(comp);
169 }
170 rel
171}
172
173fn try_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
175 #[cfg(target_os = "windows")]
176 {
177 std::os::windows::fs::symlink_file(target, link)
178 }
179 #[cfg(not(target_os = "windows"))]
180 {
181 std::os::unix::fs::symlink(target, link)
182 }
183}
184
185pub fn gc(cas_root: &Path, live_hashes: &HashSet<String>) -> Result<usize, rootcause::Report> {
188 let mut removed = 0;
189 if !cas_root.exists() {
190 return Ok(0);
191 }
192
193 for fanout_entry in std::fs::read_dir(cas_root)?.flatten() {
195 if !fanout_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
196 continue;
197 }
198 let prefix = fanout_entry.file_name();
199 let prefix_str = prefix.to_string_lossy();
200
201 for file_entry in std::fs::read_dir(fanout_entry.path())?.flatten() {
202 if !file_entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
203 continue;
204 }
205 let suffix = file_entry.file_name();
206 let hash = format!("{}{}", prefix_str, suffix.to_string_lossy());
207
208 if !live_hashes.contains(&hash) {
209 if let Err(e) = std::fs::remove_file(file_entry.path()) {
210 tracing::warn!("Failed to remove CAS object {}: {e}", file_entry.path().display());
211 } else {
212 removed += 1;
213 }
214 }
215 }
216
217 if std::fs::read_dir(fanout_entry.path()).map(|mut d| d.next().is_none()).unwrap_or(false) {
219 let _ = std::fs::remove_dir(fanout_entry.path());
220 }
221 }
222
223 Ok(removed)
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn hash_is_deterministic_and_truncated() {
232 let hash = hash_bytes(b"hello world");
233 assert_eq!(hash.len(), HASH_LEN);
234 assert_eq!(hash, hash_bytes(b"hello world"));
235 assert_ne!(hash, hash_bytes(b"hello world!"));
236 }
237
238 #[test]
241 fn hashing_a_file_matches_hashing_its_bytes() {
242 let dir = tempfile::tempdir().unwrap();
243 let cas_root = dir.path().join("common");
244
245 let data: Vec<u8> = (0..200_000).map(|i| (i % 251) as u8).collect();
246 let hash = store(&cas_root, &data).unwrap();
247
248 assert_eq!(hash_file(&cas_path(&cas_root, &hash)).unwrap(), hash_bytes(&data));
249 }
250
251 #[test]
252 fn hashing_a_missing_file_reports_not_found() {
253 let dir = tempfile::tempdir().unwrap();
254 let err = hash_file(&dir.path().join("nothing")).unwrap_err();
255 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
256 }
257
258 #[test]
259 fn store_and_retrieve() {
260 let dir = tempfile::tempdir().unwrap();
261 let cas_root = dir.path().join("common");
262
263 let data = b"test file contents";
264 let hash = store(&cas_root, data).unwrap();
265
266 let stored_path = cas_path(&cas_root, &hash);
267 assert!(stored_path.exists());
268 assert_eq!(std::fs::read(&stored_path).unwrap(), data);
269
270 let hash2 = store(&cas_root, data).unwrap();
272 assert_eq!(hash, hash2);
273 }
274
275 #[test]
289 fn an_object_is_never_visible_before_it_is_complete() {
290 use std::sync::atomic::AtomicBool;
291 use std::sync::atomic::AtomicUsize;
292 use std::sync::atomic::Ordering;
293
294 let dir = tempfile::tempdir().unwrap();
295 let cas_root = dir.path().join("common");
296
297 let data: Vec<u8> = (0..8 * 1024 * 1024).map(|i| (i % 251) as u8).collect();
300 let expected = hash_bytes(&data);
301 let expected_len = data.len() as u64;
302 let path = cas_path(&cas_root, &expected);
303
304 let torn = AtomicUsize::new(0);
305 let complete = AtomicUsize::new(0);
306 let done = AtomicBool::new(false);
307
308 std::thread::scope(|scope| {
309 for _ in 0..3 {
310 let path = path.clone();
311 let (torn, complete, done) = (&torn, &complete, &done);
312 scope.spawn(move || {
313 loop {
314 let finished = done.load(Ordering::Acquire);
318 if let Ok(meta) = std::fs::metadata(&path) {
319 if meta.len() == expected_len {
320 complete.fetch_add(1, Ordering::Relaxed);
321 } else {
322 torn.fetch_add(1, Ordering::Relaxed);
323 }
324 }
325 if finished {
326 break;
327 }
328 }
329 });
330 }
331
332 for _ in 0..8 {
335 let _ = std::fs::remove_file(&path);
336 store(&cas_root, &data).unwrap();
337 }
338 done.store(true, Ordering::Release);
339 });
340
341 assert!(complete.load(Ordering::Relaxed) > 0, "readers never saw the object at all, so nothing was checked");
342 assert_eq!(torn.load(Ordering::Relaxed), 0, "a reader saw the published object before all of it was there");
343
344 let stored = std::fs::read(&path).unwrap();
345 assert_eq!(hash_bytes(&stored), expected, "the stored object does not hash to its own name");
346
347 let fanout = cas_root.join(&expected[..2]);
349 let entries: Vec<_> = std::fs::read_dir(&fanout).unwrap().flatten().map(|e| e.file_name()).collect();
350 assert_eq!(entries.len(), 1, "expected one object in the fanout directory, found {entries:?}");
351 }
352
353 #[test]
354 fn link_creates_readable_file() {
355 let dir = tempfile::tempdir().unwrap();
356 let cas_root = dir.path().join("common");
357
358 let data = b"linked file";
359 let hash = store(&cas_root, data).unwrap();
360
361 let link_path = dir.path().join("build/vfs/some/file.txt");
362 link_file(&cas_root, &hash, &link_path).unwrap();
363
364 assert!(link_path.exists());
365 assert_eq!(std::fs::read(&link_path).unwrap(), data);
366 }
367
368 #[test]
369 fn gc_removes_orphans() {
370 let dir = tempfile::tempdir().unwrap();
371 let cas_root = dir.path().join("common");
372
373 let hash_a = store(&cas_root, b"file a").unwrap();
374 let hash_b = store(&cas_root, b"file b").unwrap();
375
376 let mut live = HashSet::new();
377 live.insert(hash_a.clone());
378
379 let removed = gc(&cas_root, &live).unwrap();
380 assert_eq!(removed, 1);
381
382 assert!(cas_path(&cas_root, &hash_a).exists());
383 assert!(!cas_path(&cas_root, &hash_b).exists());
384 }
385}