Skip to main content

verdant_runtime/
cas.rs

1use crate::store::{FileStore, Key, StoreError};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct ManifestEntry {
7    pub path: PathBuf,
8    pub content_hash: Key,
9    pub mode: u32,
10    pub mtime_secs: i64,
11    pub mtime_nanos: u32,
12}
13
14impl ManifestEntry {
15    pub fn entry_for(path: PathBuf, content_hash: Key, mode: u32, mtime: (i64, u32)) -> Self {
16        Self {
17            path,
18            content_hash,
19            mode,
20            mtime_secs: mtime.0,
21            mtime_nanos: mtime.1,
22        }
23    }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct TreeManifest {
28    pub entries: Vec<ManifestEntry>,
29    pub deleted: Vec<PathBuf>,
30}
31
32impl TreeManifest {
33    pub fn new(entries: Vec<ManifestEntry>, deleted: Vec<PathBuf>) -> Self {
34        Self { entries, deleted }
35    }
36}
37
38#[derive(Debug, thiserror::Error)]
39pub enum CasError {
40    #[error("store error: {0}")]
41    Store(#[from] StoreError),
42    #[error("json error: {0}")]
43    Json(#[from] serde_json::Error),
44}
45
46pub struct TreeBlobStore<'a> {
47    store: &'a FileStore,
48}
49
50impl<'a> TreeBlobStore<'a> {
51    pub fn new(store: &'a FileStore) -> Self {
52        Self { store }
53    }
54
55    pub fn put_file_blob(&self, content: &[u8]) -> Result<Key, CasError> {
56        let key = Key::from_bytes(content);
57        if !self.store.contains(&key) {
58            self.store.persist(&key, content, "cas-blob", vec![])?;
59        }
60        Ok(key)
61    }
62
63    pub fn get_file_blob(&self, key: &Key) -> Result<Option<Vec<u8>>, CasError> {
64        Ok(self.store.lookup(key)?.map(|p| p.bytes))
65    }
66
67    pub fn put_manifest(&self, manifest: &TreeManifest) -> Result<Key, CasError> {
68        let bytes = serde_json::to_vec(manifest)?;
69        self.put_file_blob(&bytes)
70    }
71
72    pub fn get_manifest(&self, key: &Key) -> Result<Option<TreeManifest>, CasError> {
73        match self.store.lookup(key)? {
74            None => Ok(None),
75            Some(payload) => {
76                let manifest = serde_json::from_slice(&payload.bytes)?;
77                Ok(Some(manifest))
78            }
79        }
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::store::FileStore;
87    use std::path::PathBuf;
88    use tempfile::TempDir;
89
90    fn setup() -> (TempDir, FileStore) {
91        let dir = TempDir::new().unwrap();
92        let store = FileStore::open(dir.path()).unwrap();
93        (dir, store)
94    }
95
96    #[test]
97    fn manifest_round_trip() {
98        let (_dir, store) = setup();
99        let cas = TreeBlobStore::new(&store);
100
101        let hash_a = Key::from_bytes(b"file-a-content");
102        let hash_b = Key::from_bytes(b"file-b-content");
103        let hash_c = Key::from_bytes(b"file-c-content");
104
105        let entries = vec![
106            ManifestEntry::entry_for(
107                PathBuf::from("src/main.rs"),
108                hash_a,
109                0o644,
110                (1_700_000_000, 123_456_789),
111            ),
112            ManifestEntry::entry_for(
113                PathBuf::from("src/lib.rs"),
114                hash_b,
115                0o755,
116                (1_700_000_001, 0),
117            ),
118            ManifestEntry::entry_for(
119                PathBuf::from("build.rs"),
120                hash_c,
121                0o600,
122                (-86400, 999_999_999),
123            ),
124        ];
125        let deleted = vec![PathBuf::from("old/removed.rs")];
126        let manifest = TreeManifest::new(entries, deleted);
127
128        let key = cas.put_manifest(&manifest).unwrap();
129        let loaded = cas
130            .get_manifest(&key)
131            .unwrap()
132            .expect("manifest must exist");
133
134        assert_eq!(loaded, manifest);
135        assert_eq!(loaded.entries[0].mode, 0o644);
136        assert_eq!(loaded.entries[0].mtime_secs, 1_700_000_000);
137        assert_eq!(loaded.entries[0].mtime_nanos, 123_456_789);
138        assert_eq!(loaded.entries[2].mtime_secs, -86400);
139        assert_eq!(loaded.deleted.len(), 1);
140        assert_eq!(loaded.deleted[0], PathBuf::from("old/removed.rs"));
141    }
142
143    #[test]
144    fn blob_round_trip() {
145        let (_dir, store) = setup();
146        let cas = TreeBlobStore::new(&store);
147
148        let key = cas.put_file_blob(b"hello").unwrap();
149        let fetched = cas.get_file_blob(&key).unwrap();
150        assert_eq!(fetched, Some(b"hello".to_vec()));
151    }
152
153    #[test]
154    fn blob_dedup() {
155        let (_dir, store) = setup();
156        let cas = TreeBlobStore::new(&store);
157
158        let key1 = cas.put_file_blob(b"same content").unwrap();
159        let key2 = cas.put_file_blob(b"same content").unwrap();
160        assert_eq!(key1, key2);
161
162        let root = store.root();
163        let shard = &key1.as_str()[..2];
164        let shard_path = root.join(shard);
165        let count = std::fs::read_dir(&shard_path)
166            .unwrap()
167            .filter_map(|e| e.ok())
168            .filter(|e| e.file_name().to_string_lossy().ends_with(".payload"))
169            .count();
170        assert_eq!(
171            count, 1,
172            "dedup: only one payload file for identical content"
173        );
174    }
175
176    #[test]
177    fn get_missing_key_returns_none() {
178        let (_dir, store) = setup();
179        let cas = TreeBlobStore::new(&store);
180
181        let phantom_key = Key::from_bytes(b"never stored");
182        assert_eq!(cas.get_file_blob(&phantom_key).unwrap(), None);
183        assert!(cas.get_manifest(&phantom_key).unwrap().is_none());
184    }
185
186    #[test]
187    fn mtime_nanosecond_fidelity() {
188        let (_dir, store) = setup();
189        let cas = TreeBlobStore::new(&store);
190
191        let hash = Key::from_bytes(b"precision-test");
192        let entry = ManifestEntry::entry_for(
193            PathBuf::from("file.txt"),
194            hash,
195            0o644,
196            (1_000_000_000, 123_456_789),
197        );
198        let manifest = TreeManifest::new(vec![entry], vec![]);
199
200        let key = cas.put_manifest(&manifest).unwrap();
201        let loaded = cas.get_manifest(&key).unwrap().unwrap();
202
203        assert_eq!(loaded.entries[0].mtime_nanos, 123_456_789);
204        assert_eq!(loaded.entries[0].mtime_secs, 1_000_000_000);
205    }
206}