Skip to main content

gpp_core/
store.rs

1//! The content-addressed object store: `.gpp/objects/`.
2//!
3//! Objects are stored at `objects/<aa>/<rest>` where `<aa>` is the first two
4//! characters of the base32 id and `<rest>` is the remaining 50. Writes are
5//! atomic (temp file + rename) and idempotent (an id that already exists is a
6//! no-op). Reads verify the BLAKE3 content address before returning.
7
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use crate::error::{Error, Result};
13use crate::hash::Hash;
14use crate::object::Object;
15use crate::wire;
16
17/// Handle to a repository's object store.
18#[derive(Debug, Clone)]
19pub struct ObjectStore {
20    objects_dir: PathBuf,
21}
22
23impl ObjectStore {
24    /// Open the store rooted at `<gpp_dir>/objects` (does not create it).
25    pub fn open(gpp_dir: &Path) -> Self {
26        Self {
27            objects_dir: gpp_dir.join("objects"),
28        }
29    }
30
31    /// Create the `objects/` directory and return a handle.
32    pub fn init(gpp_dir: &Path) -> Result<Self> {
33        let store = Self::open(gpp_dir);
34        fs::create_dir_all(&store.objects_dir)?;
35        Ok(store)
36    }
37
38    /// Filesystem path an object id maps to.
39    fn path_for(&self, id: &Hash) -> PathBuf {
40        let s = id.to_base32();
41        let (shard, rest) = s.split_at(2);
42        self.objects_dir.join(shard).join(rest)
43    }
44
45    /// True if an object with this id is present.
46    pub fn contains(&self, id: &Hash) -> bool {
47        self.path_for(id).exists()
48    }
49
50    /// Store an object, returning its content address. Idempotent.
51    pub fn write<T: Object>(&self, object: &T) -> Result<Hash> {
52        let body = object.encode_body()?;
53        let id = Hash::of(&body);
54        let path = self.path_for(&id);
55        if path.exists() {
56            tracing::trace!(%id, "object already present, skipping write");
57            return Ok(id);
58        }
59
60        let frame = wire::encode(T::TYPE, &body)?;
61        let dir = path
62            .parent()
63            .expect("object path always has a shard parent");
64        fs::create_dir_all(dir)?;
65
66        // Atomic publish: write to a uniquely-named temp file, then rename.
67        let tmp = dir.join(format!(".tmp-{}", id.short()));
68        {
69            let mut f = fs::File::create(&tmp)?;
70            f.write_all(&frame)?;
71            f.sync_all()?;
72        }
73        fs::rename(&tmp, &path)?;
74        tracing::debug!(%id, bytes = frame.len(), "wrote object");
75        Ok(id)
76    }
77
78    /// Raw stored frame bytes for an object (for sync transfer). The object
79    /// is *not* decoded — callers move opaque, content-addressed frames.
80    pub fn read_raw(&self, id: &Hash) -> Result<Vec<u8>> {
81        match fs::read(self.path_for(id)) {
82            Ok(b) => Ok(b),
83            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::NotFound(*id)),
84            Err(e) => Err(Error::Io(e)),
85        }
86    }
87
88    /// Write a raw frame received from a peer. The frame is decoded and its
89    /// BLAKE3 body hash must equal `id`, so a malicious peer cannot inject
90    /// content under the wrong address. Idempotent.
91    pub fn write_raw(&self, id: &Hash, frame: &[u8]) -> Result<()> {
92        let decoded = wire::decode(frame)?;
93        let computed = Hash::of(&decoded.body);
94        if computed != *id {
95            return Err(Error::HashMismatch {
96                expected: *id,
97                computed,
98            });
99        }
100        let path = self.path_for(id);
101        if path.exists() {
102            return Ok(());
103        }
104        let dir = path
105            .parent()
106            .expect("object path always has a shard parent");
107        fs::create_dir_all(dir)?;
108        let tmp = dir.join(format!(".tmp-{}", id.short()));
109        {
110            let mut f = fs::File::create(&tmp)?;
111            f.write_all(frame)?;
112            f.sync_all()?;
113        }
114        fs::rename(&tmp, &path)?;
115        Ok(())
116    }
117
118    /// Enumerate every stored object id (skips `.tmp-` files).
119    pub fn iter_ids(&self) -> Vec<Hash> {
120        let mut out = Vec::new();
121        let Ok(shards) = fs::read_dir(&self.objects_dir) else {
122            return out;
123        };
124        for shard in shards.flatten() {
125            if !shard.path().is_dir() {
126                continue;
127            }
128            let shard_name = shard.file_name().to_string_lossy().into_owned();
129            let Ok(rest) = fs::read_dir(shard.path()) else {
130                continue;
131            };
132            for ent in rest.flatten() {
133                let fname = ent.file_name().to_string_lossy().into_owned();
134                if fname.starts_with(".tmp-") {
135                    continue;
136                }
137                if let Ok(h) = Hash::from_base32(&format!("{shard_name}{fname}")) {
138                    out.push(h);
139                }
140            }
141        }
142        out
143    }
144
145    /// Read an object by id, verifying its type and content address.
146    pub fn read<T: Object>(&self, id: &Hash) -> Result<T> {
147        let path = self.path_for(id);
148        let frame = match fs::read(&path) {
149            Ok(b) => b,
150            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
151                return Err(Error::NotFound(*id));
152            }
153            Err(e) => return Err(Error::Io(e)),
154        };
155
156        let decoded = wire::decode(&frame)?;
157        if decoded.object_type != T::TYPE {
158            return Err(Error::TypeMismatch {
159                expected: T::TYPE.code(),
160                found: decoded.object_type.code(),
161            });
162        }
163
164        let computed = Hash::of(&decoded.body);
165        if computed != *id {
166            return Err(Error::HashMismatch {
167                expected: *id,
168                computed,
169            });
170        }
171        T::decode_body(&decoded.body)
172    }
173}
174
175/// Recursively flatten a stored [`Tree`](crate::Tree) into `path -> blob hash`
176/// (files and symlinks; directories are descended into).
177pub fn flatten_tree(
178    store: &ObjectStore,
179    root: &Hash,
180) -> Result<std::collections::BTreeMap<String, Hash>> {
181    fn walk(
182        store: &ObjectStore,
183        tree_hash: &Hash,
184        prefix: &str,
185        out: &mut std::collections::BTreeMap<String, Hash>,
186    ) -> Result<()> {
187        let tree: crate::Tree = store.read(tree_hash)?;
188        for e in tree.entries {
189            let path = if prefix.is_empty() {
190                e.name.clone()
191            } else {
192                format!("{prefix}/{}", e.name)
193            };
194            match e.kind {
195                crate::EntryKind::Directory => walk(store, &e.hash, &path, out)?,
196                crate::EntryKind::File | crate::EntryKind::Symlink => {
197                    out.insert(path, e.hash);
198                }
199            }
200        }
201        Ok(())
202    }
203    let mut out = std::collections::BTreeMap::new();
204    walk(store, root, "", &mut out)?;
205    Ok(out)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::object::{Blob, EntryKind, Tree, TreeEntry};
212
213    fn store() -> (tempfile::TempDir, ObjectStore) {
214        let dir = tempfile::tempdir().unwrap();
215        let store = ObjectStore::init(dir.path()).unwrap();
216        (dir, store)
217    }
218
219    #[test]
220    fn write_then_read_blob() {
221        let (_d, s) = store();
222        let blob = Blob::new(b"content addressed!".to_vec());
223        let id = s.write(&blob).unwrap();
224        assert!(s.contains(&id));
225        assert_eq!(s.read::<Blob>(&id).unwrap(), blob);
226    }
227
228    #[test]
229    fn write_is_idempotent() {
230        let (_d, s) = store();
231        let blob = Blob::new(b"dup".to_vec());
232        assert_eq!(s.write(&blob).unwrap(), s.write(&blob).unwrap());
233    }
234
235    #[test]
236    fn write_then_read_tree() {
237        let (_d, s) = store();
238        let blob_id = s.write(&Blob::new(b"file".to_vec())).unwrap();
239        let tree = Tree::new(vec![TreeEntry {
240            name: "file.txt".into(),
241            kind: EntryKind::File,
242            hash: blob_id,
243            mode: 0o644,
244            size: 4,
245        }]);
246        let id = s.write(&tree).unwrap();
247        assert_eq!(s.read::<Tree>(&id).unwrap(), tree);
248    }
249
250    #[test]
251    fn missing_object_is_not_found() {
252        let (_d, s) = store();
253        let err = s.read::<Blob>(&Hash::of(b"nope")).unwrap_err();
254        assert!(matches!(err, Error::NotFound(_)));
255    }
256
257    #[test]
258    fn type_mismatch_is_rejected() {
259        let (_d, s) = store();
260        let id = s.write(&Blob::new(b"i am a blob".to_vec())).unwrap();
261        assert!(matches!(
262            s.read::<Tree>(&id).unwrap_err(),
263            Error::TypeMismatch { .. }
264        ));
265    }
266
267    #[test]
268    fn raw_transfer_roundtrips_and_enumerates() {
269        let (_d, src) = store();
270        let id = src.write(&Blob::new(b"sync me".to_vec())).unwrap();
271        let frame = src.read_raw(&id).unwrap();
272
273        let dst_dir = tempfile::tempdir().unwrap();
274        let dst = ObjectStore::init(dst_dir.path()).unwrap();
275        dst.write_raw(&id, &frame).unwrap();
276        assert_eq!(dst.read::<Blob>(&id).unwrap().content, b"sync me");
277
278        let ids = dst.iter_ids();
279        assert_eq!(ids, vec![id]);
280        // Idempotent re-write is fine.
281        dst.write_raw(&id, &frame).unwrap();
282    }
283
284    #[test]
285    fn write_raw_rejects_wrong_address() {
286        let (_d, s) = store();
287        let frame = wire::encode(crate::object::ObjectType::Blob, b"real").unwrap();
288        let wrong = Hash::of(b"not-the-body");
289        assert!(matches!(
290            s.write_raw(&wrong, &frame).unwrap_err(),
291            Error::HashMismatch { .. }
292        ));
293    }
294
295    #[test]
296    fn corrupted_object_is_detected() {
297        let (_d, s) = store();
298        let id = s.write(&Blob::new(b"tamper me".to_vec())).unwrap();
299        let path = s.path_for(&id);
300        let mut bytes = fs::read(&path).unwrap();
301        let n = bytes.len();
302        bytes[n - 5] ^= 0xff;
303        fs::write(&path, &bytes).unwrap();
304        assert!(s.read::<Blob>(&id).is_err());
305    }
306}