Skip to main content

celln_store/
lib.rs

1//! The content-addressed store — the on-disk half of `assay`, where the
2//! "distro" actually lives. Blobs are keyed by their BLAKE3 hash, so:
3//!
4//!   * storing the same bytes twice dedups to one object (the density story), and
5//!   * a read is integrity-checked for free: if the bytes don't hash back to the
6//!     key, the object is corrupt and the read fails.
7//!
8//! No KVM here — this is plain filesystem work and is fully tested.
9
10use celln_manifest::Hash;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, thiserror::Error)]
16pub enum StoreError {
17    #[error("io error: {0}")]
18    Io(#[from] io::Error),
19    #[error("object not found: {0}")]
20    NotFound(Hash),
21    #[error("integrity failure for {expected}: bytes on disk hash to {actual}")]
22    Integrity { expected: Hash, actual: Hash },
23    #[error("hash {0} is not in the expected blake3:<hex> form")]
24    BadHash(String),
25}
26
27/// A content-addressed blob store rooted at a directory.
28///
29/// Layout: `root/objects/<aa>/<full-hex>` — a two-char fan-out so a single
30/// directory never holds millions of entries.
31pub struct Store {
32    objects: PathBuf,
33}
34
35impl Store {
36    /// Open (creating if needed) a store rooted at `root`.
37    pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
38        let objects = root.as_ref().join("objects");
39        fs::create_dir_all(&objects)?;
40        Ok(Store { objects })
41    }
42
43    fn hex_of(hash: &Hash) -> Result<&str, StoreError> {
44        hash.0
45            .strip_prefix("blake3:")
46            .ok_or_else(|| StoreError::BadHash(hash.0.clone()))
47    }
48
49    fn path_for(&self, hash: &Hash) -> Result<PathBuf, StoreError> {
50        let hex = Self::hex_of(hash)?;
51        let (fanout, _) = hex.split_at(2.min(hex.len()));
52        Ok(self.objects.join(fanout).join(hex))
53    }
54
55    /// Store bytes, returning their content hash. Idempotent: storing identical
56    /// bytes again is a no-op that returns the same hash (dedup).
57    pub fn put(&self, bytes: &[u8]) -> Result<Hash, StoreError> {
58        let hash = Hash::of(bytes);
59        let path = self.path_for(&hash)?;
60        if path.exists() {
61            return Ok(hash); // dedup — already present
62        }
63        if let Some(parent) = path.parent() {
64            fs::create_dir_all(parent)?;
65        }
66        // write-to-temp then rename, so a reader never sees a half-written object
67        let tmp = path.with_extension("tmp");
68        fs::write(&tmp, bytes)?;
69        fs::rename(&tmp, &path)?;
70        Ok(hash)
71    }
72
73    /// Fetch bytes by hash, verifying integrity on the way out.
74    pub fn get(&self, hash: &Hash) -> Result<Vec<u8>, StoreError> {
75        let path = self.path_for(hash)?;
76        if !path.exists() {
77            return Err(StoreError::NotFound(hash.clone()));
78        }
79        let bytes = fs::read(&path)?;
80        let actual = Hash::of(&bytes);
81        if &actual != hash {
82            return Err(StoreError::Integrity {
83                expected: hash.clone(),
84                actual,
85            });
86        }
87        Ok(bytes)
88    }
89
90    pub fn has(&self, hash: &Hash) -> bool {
91        self.path_for(hash).map(|p| p.exists()).unwrap_or(false)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use tempfile::tempdir;
99
100    #[test]
101    fn put_get_roundtrip() {
102        let dir = tempdir().unwrap();
103        let store = Store::open(dir.path()).unwrap();
104        let h = store.put(b"print(1+1)").unwrap();
105        assert!(store.has(&h));
106        assert_eq!(store.get(&h).unwrap(), b"print(1+1)");
107    }
108
109    #[test]
110    fn put_dedups() {
111        let dir = tempdir().unwrap();
112        let store = Store::open(dir.path()).unwrap();
113        let a = store.put(b"same").unwrap();
114        let b = store.put(b"same").unwrap();
115        assert_eq!(a, b); // one object serves both — the density mechanism
116    }
117
118    #[test]
119    fn missing_object_is_not_found() {
120        let dir = tempdir().unwrap();
121        let store = Store::open(dir.path()).unwrap();
122        let ghost = Hash::of(b"never stored");
123        assert!(matches!(store.get(&ghost), Err(StoreError::NotFound(_))));
124    }
125
126    #[test]
127    fn corruption_is_caught_on_read() {
128        let dir = tempdir().unwrap();
129        let store = Store::open(dir.path()).unwrap();
130        let h = store.put(b"trusted tool bytes").unwrap();
131        // tamper with the object on disk
132        let path = store.path_for(&h).unwrap();
133        fs::write(&path, b"trojaned bytes").unwrap();
134        assert!(matches!(store.get(&h), Err(StoreError::Integrity { .. })));
135    }
136}