1use std::io::Read;
10use std::path::{Path, PathBuf};
11
12use walkdir::WalkDir;
13
14use crate::dirs::create_dir_all;
15use crate::error::{Error, Result};
16
17pub mod link;
18pub mod manifest;
19
20use link::{materialize, LinkMode};
21use manifest::{FileEntry, Manifest};
22
23pub struct Cas {
24 root: PathBuf,
25}
26
27pub struct MaterializeReport {
29 pub manifest: Manifest,
30 pub files_written: usize,
32 pub bytes_ingested: u64,
33 pub objects_new: usize,
34}
35
36impl Cas {
37 pub fn new(root: impl Into<PathBuf>) -> Cas {
38 Cas { root: root.into() }
39 }
40
41 pub fn root(&self) -> &Path {
42 &self.root
43 }
44
45 pub fn object_path(&self, hash: &str) -> PathBuf {
47 let (a, b) = (&hash[0..2], &hash[2..4]);
48 self.root.join(a).join(b).join(hash)
49 }
50
51 pub fn has_object(&self, hash: &str) -> bool {
52 self.object_path(hash).exists()
53 }
54
55 fn ingest_file(&self, src: &Path) -> Result<(String, bool, u64)> {
59 let hash = hash_file(src)?;
60 let obj = self.object_path(&hash);
61 let mut size = 0u64;
62 if let Ok(m) = std::fs::metadata(src) {
63 size = m.len();
64 }
65 if obj.exists() {
66 return Ok((hash, false, size));
67 }
68 if let Some(parent) = obj.parent() {
69 create_dir_all(parent)?;
70 }
71 let tmp = obj.with_extension("tmp");
74 match std::fs::rename(src, &obj) {
75 Ok(()) => {}
76 Err(_) => {
77 std::fs::copy(src, &tmp).map_err(|e| Error::io(&tmp, e))?;
78 let _ = std::fs::rename(&tmp, &obj);
80 let _ = std::fs::remove_file(&tmp);
81 }
82 }
83 Ok((hash, true, size))
84 }
85
86 pub fn ingest(&self, src: &Path) -> Result<(String, bool, u64)> {
89 self.ingest_file(src)
90 }
91
92 pub fn ingest_preserve(&self, src: &Path) -> Result<(String, bool, u64)> {
94 let hash = hash_file(src)?;
95 let object = self.object_path(&hash);
96 let size = std::fs::metadata(src)
97 .map_err(|error| Error::io(src, error))?
98 .len();
99 if object.exists() {
100 return Ok((hash, false, size));
101 }
102 if let Some(parent) = object.parent() {
103 create_dir_all(parent)?;
104 }
105 let temporary = object.with_extension(format!("tmp-{}", std::process::id()));
106 std::fs::copy(src, &temporary).map_err(|error| Error::io(&temporary, error))?;
107 match std::fs::rename(&temporary, &object) {
108 Ok(()) => Ok((hash, true, size)),
109 Err(_) if object.exists() => {
110 let _ = std::fs::remove_file(&temporary);
111 Ok((hash, false, size))
112 }
113 Err(error) => {
114 let _ = std::fs::remove_file(&temporary);
115 Err(Error::io(&object, error))
116 }
117 }
118 }
119
120 pub fn materialize_object(&self, hash: &str, destination: &Path, mode: LinkMode) -> Result<()> {
122 let object = self.object_path(hash);
123 if !object.is_file() {
124 return Err(Error::other(format!(
125 "content-addressed object is missing: {hash}"
126 )));
127 }
128 materialize(&object, destination, mode).map(|_| ())
129 }
130
131 pub fn ingest_tree(
134 &self,
135 extracted_root: &Path,
136 install_dir: &Path,
137 tool: &str,
138 version: &str,
139 mode: LinkMode,
140 ) -> Result<MaterializeReport> {
141 create_dir_all(install_dir)?;
142 let mut manifest = Manifest::new(tool, version, mode.to_string());
143 let mut files_written = 0usize;
144 let mut bytes_ingested = 0u64;
145 let mut objects_new = 0usize;
146
147 for entry in WalkDir::new(extracted_root).follow_links(false) {
148 let entry = entry.map_err(|e| Error::other(format!("walkdir: {e}")))?;
149 let path = entry.path();
150 let rel = path
151 .strip_prefix(extracted_root)
152 .map_err(|_| Error::other("strip_prefix failed"))?;
153 if rel.as_os_str().is_empty() {
154 continue;
155 }
156 let rel_str = rel_to_slash(rel);
157 let ft = entry.file_type();
158 let dst = install_dir.join(rel);
159
160 if ft.is_dir() {
161 create_dir_all(&dst)?;
162 } else if ft.is_symlink() {
163 let target = std::fs::read_link(path).map_err(|e| Error::io(path, e))?;
164 recreate_symlink(path, &target, &dst)?;
165 manifest.files.push(FileEntry {
166 path: rel_str,
167 hash: None,
168 mode: 0,
169 symlink: Some(rel_to_slash(&target)),
170 });
171 } else if ft.is_file() {
172 let mode_bits = file_mode(path);
173 let (hash, is_new, size) = self.ingest_file(path)?;
174 if is_new {
175 objects_new += 1;
176 bytes_ingested += size;
177 }
178 let obj = self.object_path(&hash);
179 materialize(&obj, &dst, mode)?;
180 apply_mode(&dst, mode_bits);
181 files_written += 1;
182 manifest.files.push(FileEntry {
183 path: rel_str,
184 hash: Some(hash),
185 mode: mode_bits,
186 symlink: None,
187 });
188 }
189 }
190
191 manifest.save(install_dir)?;
192 Ok(MaterializeReport {
193 manifest,
194 files_written,
195 bytes_ingested,
196 objects_new,
197 })
198 }
199
200 pub fn gc(&self, installs_root: &Path) -> Result<(usize, u64)> {
203 self.gc_roots(&[installs_root])
204 }
205
206 pub fn gc_roots(&self, roots: &[&Path]) -> Result<(usize, u64)> {
208 use std::collections::HashSet;
209 let mut live: HashSet<String> = HashSet::new();
210 for root in roots {
211 if !root.exists() {
212 continue;
213 }
214 for entry in WalkDir::new(root).follow_links(false) {
215 let entry = match entry {
216 Ok(e) => e,
217 Err(_) => continue,
218 };
219 if entry.file_name() == manifest::MANIFEST_FILE {
220 let install = entry.path().parent().unwrap();
221 let manifest = Manifest::load(install).map_err(|error| {
222 Error::other(format!(
223 "refusing store GC because manifest is corrupt at {}: {error}",
224 install.display()
225 ))
226 })?;
227 for hash in manifest.referenced_hashes() {
228 live.insert(hash.to_string());
229 }
230 }
231 }
232 }
233
234 let mut removed = 0usize;
235 let mut bytes = 0u64;
236 if self.root.exists() {
237 for entry in WalkDir::new(&self.root).follow_links(false) {
238 let entry = match entry {
239 Ok(e) => e,
240 Err(_) => continue,
241 };
242 if !entry.file_type().is_file() {
243 continue;
244 }
245 let name = entry.file_name().to_string_lossy().to_string();
246 if name.ends_with(".tmp") {
248 let _ = std::fs::remove_file(entry.path());
249 continue;
250 }
251 if !live.contains(&name) {
252 if let Ok(m) = entry.metadata() {
253 bytes += m.len();
254 }
255 if std::fs::remove_file(entry.path()).is_ok() {
256 removed += 1;
257 }
258 }
259 }
260 }
261 Ok((removed, bytes))
262 }
263}
264
265pub fn hash_file(path: &Path) -> Result<String> {
267 let mut f = std::fs::File::open(path).map_err(|e| Error::io(path, e))?;
268 let mut hasher = blake3::Hasher::new();
269 let mut buf = [0u8; 64 * 1024];
270 loop {
271 let n = f.read(&mut buf).map_err(|e| Error::io(path, e))?;
272 if n == 0 {
273 break;
274 }
275 hasher.update(&buf[..n]);
276 }
277 Ok(hasher.finalize().to_hex().to_string())
278}
279
280fn rel_to_slash(p: &Path) -> String {
281 p.components()
282 .map(|c| c.as_os_str().to_string_lossy())
283 .collect::<Vec<_>>()
284 .join("/")
285}
286
287#[cfg(unix)]
288fn file_mode(path: &Path) -> u32 {
289 use std::os::unix::fs::PermissionsExt;
290 std::fs::metadata(path)
291 .map(|m| m.permissions().mode())
292 .unwrap_or(0o644)
293}
294
295#[cfg(not(unix))]
296fn file_mode(_path: &Path) -> u32 {
297 0
298}
299
300#[cfg(unix)]
301fn apply_mode(path: &Path, mode: u32) {
302 use std::os::unix::fs::PermissionsExt;
303 if mode != 0 {
304 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));
305 }
306}
307
308#[cfg(not(unix))]
309fn apply_mode(_path: &Path, _mode: u32) {}
310
311#[cfg(unix)]
312fn recreate_symlink(_source_link: &Path, target: &Path, dst: &Path) -> Result<()> {
313 if let Some(parent) = dst.parent() {
314 create_dir_all(parent)?;
315 }
316 let _ = std::fs::remove_file(dst);
317 std::os::unix::fs::symlink(target, dst).map_err(|e| Error::io(dst, e))
318}
319
320#[cfg(windows)]
321fn recreate_symlink(source_link: &Path, target: &Path, dst: &Path) -> Result<()> {
322 recreate_windows_symlink(source_link, target, dst, |target, dst, is_directory| {
323 if is_directory {
324 std::os::windows::fs::symlink_dir(target, dst)
325 } else {
326 std::os::windows::fs::symlink_file(target, dst)
327 }
328 })
329}
330
331#[cfg(windows)]
332fn recreate_windows_symlink(
333 source_link: &Path,
334 target: &Path,
335 dst: &Path,
336 create: impl FnOnce(&Path, &Path, bool) -> std::io::Result<()>,
337) -> Result<()> {
338 if let Some(parent) = dst.parent() {
339 create_dir_all(parent)?;
340 }
341 if dst.symlink_metadata().is_ok() {
342 if dst.is_dir() {
343 let _ = std::fs::remove_dir_all(dst);
344 } else {
345 let _ = std::fs::remove_file(dst);
346 }
347 }
348 let source = if target.is_absolute() {
349 target.to_path_buf()
350 } else {
351 source_link
352 .parent()
353 .unwrap_or_else(|| Path::new("."))
354 .join(target)
355 };
356 let is_directory = source.is_dir();
357 if create(target, dst, is_directory).is_err() {
358 if is_directory {
359 copy_directory(&source, dst)?;
360 } else {
361 std::fs::copy(&source, dst).map_err(|e| Error::io(&source, e))?;
362 }
363 }
364 Ok(())
365}
366
367#[cfg(windows)]
368fn copy_directory(source: &Path, destination: &Path) -> Result<()> {
369 create_dir_all(destination)?;
370 for entry in WalkDir::new(source).min_depth(1) {
371 let entry = entry.map_err(|error| Error::other(format!("walkdir: {error}")))?;
372 let relative = entry
373 .path()
374 .strip_prefix(source)
375 .map_err(|_| Error::other("strip_prefix failed"))?;
376 let target = destination.join(relative);
377 if entry.file_type().is_dir() {
378 create_dir_all(&target)?;
379 } else {
380 if let Some(parent) = target.parent() {
381 create_dir_all(parent)?;
382 }
383 std::fs::copy(entry.path(), &target).map_err(|error| Error::io(&target, error))?;
384 }
385 }
386 Ok(())
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use std::io::Write;
393
394 fn write(p: &Path, b: &[u8]) {
395 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
396 let mut f = std::fs::File::create(p).unwrap();
397 f.write_all(b).unwrap();
398 }
399
400 #[test]
401 fn dedup_identical_files_across_versions() {
402 let td = tempfile::tempdir().unwrap();
403 let cas = Cas::new(td.path().join("store"));
404 let installs = td.path().join("installs");
405
406 let ex_a = td.path().join("ex_a");
408 write(&ex_a.join("bin/node"), b"BINARY");
409 write(&ex_a.join("README.md"), b"same-doc");
410 cas.ingest_tree(
411 &ex_a,
412 &installs.join("node/20.11.0"),
413 "node",
414 "20.11.0",
415 LinkMode::Copy,
416 )
417 .unwrap();
418
419 let ex_b = td.path().join("ex_b");
421 write(&ex_b.join("bin/node"), b"BINARY-v2");
422 write(&ex_b.join("README.md"), b"same-doc");
423 let rep_b = cas
424 .ingest_tree(
425 &ex_b,
426 &installs.join("node/20.11.1"),
427 "node",
428 "20.11.1",
429 LinkMode::Copy,
430 )
431 .unwrap();
432
433 assert_eq!(rep_b.objects_new, 1);
435
436 let count = WalkDir::new(cas.root())
438 .into_iter()
439 .filter_map(|e| e.ok())
440 .filter(|e| e.file_type().is_file())
441 .count();
442 assert_eq!(count, 3);
443 }
444
445 #[test]
446 fn gc_removes_unreferenced_after_uninstall() {
447 let td = tempfile::tempdir().unwrap();
448 let cas = Cas::new(td.path().join("store"));
449 let installs = td.path().join("installs");
450
451 let ex = td.path().join("ex");
452 write(&ex.join("bin/tool"), b"unique-bytes");
453 let inst = installs.join("go/1.22.0");
454 cas.ingest_tree(&ex, &inst, "go", "1.22.0", LinkMode::Copy)
455 .unwrap();
456
457 std::fs::remove_dir_all(&inst).unwrap();
459
460 let (removed, _) = cas.gc(&installs).unwrap();
461 assert_eq!(removed, 1);
462 }
463
464 #[test]
465 fn gc_refuses_to_delete_when_an_install_manifest_is_corrupt() {
466 let temporary = tempfile::tempdir().unwrap();
467 let cas = Cas::new(temporary.path().join("store"));
468 let installs = temporary.path().join("installs");
469 let extracted = temporary.path().join("extracted");
470 write(&extracted.join("bin/tool"), b"preserve-me");
471 let install = installs.join("tool/1.0.0");
472 cas.ingest_tree(&extracted, &install, "tool", "1.0.0", LinkMode::Copy)
473 .unwrap();
474 std::fs::write(install.join(manifest::MANIFEST_FILE), b"{broken").unwrap();
475
476 let error = cas.gc(&installs).unwrap_err();
477 assert!(error.to_string().contains("refusing store GC"));
478 assert_eq!(
479 WalkDir::new(cas.root())
480 .into_iter()
481 .filter_map(|entry| entry.ok())
482 .filter(|entry| entry.file_type().is_file())
483 .count(),
484 1
485 );
486 }
487
488 #[cfg(windows)]
489 #[test]
490 fn relative_symlink_is_preserved_when_available() {
491 let temporary = tempfile::tempdir().unwrap();
492 let source_link = temporary.path().join("extracted/bin/alias.exe");
493 let destination = temporary.path().join("installed/bin/alias.exe");
494 let target = Path::new("real.exe");
495 std::fs::create_dir_all(source_link.parent().unwrap()).unwrap();
496 std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
497 std::fs::write(source_link.parent().unwrap().join(target), b"runtime").unwrap();
498 std::fs::write(destination.parent().unwrap().join(target), b"runtime").unwrap();
499
500 recreate_symlink(&source_link, target, &destination).unwrap();
501 assert_eq!(std::fs::read(destination).unwrap(), b"runtime");
502 }
503
504 #[cfg(windows)]
505 #[test]
506 fn file_and_directory_symlinks_copy_when_permission_is_denied() {
507 let temporary = tempfile::tempdir().unwrap();
508 let extracted = temporary.path().join("extracted");
509 let installed = temporary.path().join("installed");
510 let denied = |_: &Path, _: &Path, _: bool| {
511 Err(std::io::Error::new(
512 std::io::ErrorKind::PermissionDenied,
513 "fixture",
514 ))
515 };
516
517 let file_link = extracted.join("bin/alias.exe");
518 std::fs::create_dir_all(file_link.parent().unwrap()).unwrap();
519 std::fs::write(extracted.join("bin/real.exe"), b"runtime").unwrap();
520 let file_destination = installed.join("bin/alias.exe");
521 recreate_windows_symlink(&file_link, Path::new("real.exe"), &file_destination, denied)
522 .unwrap();
523 assert_eq!(std::fs::read(file_destination).unwrap(), b"runtime");
524
525 let directory_link = extracted.join("current");
526 std::fs::create_dir_all(extracted.join("versions/1/bin")).unwrap();
527 std::fs::write(extracted.join("versions/1/bin/tool.exe"), b"directory").unwrap();
528 let directory_destination = installed.join("current");
529 recreate_windows_symlink(
530 &directory_link,
531 Path::new("versions/1"),
532 &directory_destination,
533 denied,
534 )
535 .unwrap();
536 assert_eq!(
537 std::fs::read(directory_destination.join("bin/tool.exe")).unwrap(),
538 b"directory"
539 );
540 }
541}