Skip to main content

osdk_core/store/
mod.rs

1//! Content-addressed store (CAS): the dedup engine.
2//!
3//! Every regular file extracted from an SDK archive is hashed with blake3 and
4//! stored once at `store/<aa>/<bb>/<hash>`. Multiple tool versions that share
5//! identical files (e.g. node 20.11.0 and 20.11.1) therefore keep only one copy
6//! on disk; each install dir is materialized from the store via hardlink /
7//! reflink / copy (see [`super::link`]).
8
9use 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
27/// Outcome of materializing an extracted tree into an install dir.
28pub struct MaterializeReport {
29    pub manifest: Manifest,
30    /// Distinct link modes actually used (for diagnostics).
31    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    /// Path of a blob given its hex hash, with 2-level fanout.
46    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    /// Ingest a file's bytes into the store, returning its hash. If an object
56    /// with the same content already exists, the source is not duplicated.
57    /// Returns `(hash, is_new)`.
58    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        // Move into place when possible (same fs), else copy. Use a temp name
72        // then atomic rename so concurrent ingests don't see partial objects.
73        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                // ignore error if another process won the race
79                let _ = std::fs::rename(&tmp, &obj);
80                let _ = std::fs::remove_file(&tmp);
81            }
82        }
83        Ok((hash, true, size))
84    }
85
86    /// Ingest one file and return its content hash, whether the object was new,
87    /// and its size. The source may be moved into the store.
88    pub fn ingest(&self, src: &Path) -> Result<(String, bool, u64)> {
89        self.ingest_file(src)
90    }
91
92    /// Ingest one file without consuming the source cache entry.
93    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    /// Materialize one existing CAS object at `destination`.
121    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    /// Ingest an extracted directory tree into the store and materialize it at
132    /// `install_dir` using `mode`. Writes and returns the manifest.
133    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    /// Garbage-collect: delete store objects not referenced by any manifest in
201    /// `installs_root`. Returns (objects_removed, bytes_removed).
202    pub fn gc(&self, installs_root: &Path) -> Result<(usize, u64)> {
203        self.gc_roots(&[installs_root])
204    }
205
206    /// Garbage-collect objects not referenced by a manifest below any root.
207    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                // object file names are the hex hash; skip temp files
247                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
265/// Compute the blake3 hash of a file's contents (hex).
266pub 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        // version A
407        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        // version B: README identical, binary different
420        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        // README was already in store ⇒ only the new binary is a new object.
434        assert_eq!(rep_b.objects_new, 1);
435
436        // store holds exactly 3 objects total: BINARY, same-doc, BINARY-v2
437        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        // simulate uninstall: remove the install dir (and its manifest)
458        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}