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)]
29pub struct ManifestDir {
30 pub path: PathBuf,
31 pub mode: u32,
32 pub mtime_secs: i64,
33 pub mtime_nanos: u32,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ManifestLink {
39 pub path: PathBuf,
40 pub target: PathBuf,
41 pub mtime_secs: i64,
42 pub mtime_nanos: u32,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ManifestDirAttrs {
49 pub path: PathBuf,
50 pub mode: Option<u32>,
51 pub mtime: Option<(i64, u32)>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct TreeManifest {
56 pub entries: Vec<ManifestEntry>,
57 pub deleted: Vec<PathBuf>,
58 #[serde(default)]
60 pub dirs: Vec<ManifestDir>,
61 #[serde(default)]
63 pub links: Vec<ManifestLink>,
64 #[serde(default)]
67 pub dir_attrs: Vec<ManifestDirAttrs>,
68}
69
70impl TreeManifest {
71 pub fn new(entries: Vec<ManifestEntry>, deleted: Vec<PathBuf>) -> Self {
72 Self {
73 entries,
74 deleted,
75 dirs: Vec::new(),
76 links: Vec::new(),
77 dir_attrs: Vec::new(),
78 }
79 }
80}
81
82#[derive(Debug, thiserror::Error)]
83pub enum CasError {
84 #[error("store error: {0}")]
85 Store(#[from] StoreError),
86 #[error("json error: {0}")]
87 Json(#[from] serde_json::Error),
88}
89
90pub struct TreeBlobStore<'a> {
91 store: &'a FileStore,
92}
93
94impl<'a> TreeBlobStore<'a> {
95 pub fn new(store: &'a FileStore) -> Self {
96 Self { store }
97 }
98
99 pub fn put_file_blob(&self, content: &[u8]) -> Result<Key, CasError> {
100 let key = Key::from_bytes(content);
101 if !self.store.contains(&key) {
102 self.store.persist(&key, content, "cas-blob", vec![])?;
103 }
104 Ok(key)
105 }
106
107 pub fn get_file_blob(&self, key: &Key) -> Result<Option<Vec<u8>>, CasError> {
108 Ok(self.store.lookup(key)?.map(|p| p.bytes))
109 }
110
111 pub fn put_manifest(&self, manifest: &TreeManifest) -> Result<Key, CasError> {
112 let bytes = serde_json::to_vec(manifest)?;
113 self.put_file_blob(&bytes)
114 }
115
116 pub fn get_manifest(&self, key: &Key) -> Result<Option<TreeManifest>, CasError> {
117 match self.store.lookup(key)? {
118 None => Ok(None),
119 Some(payload) => {
120 let manifest = serde_json::from_slice(&payload.bytes)?;
121 Ok(Some(manifest))
122 }
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::store::FileStore;
131 use std::path::PathBuf;
132 use tempfile::TempDir;
133
134 fn setup() -> (TempDir, FileStore) {
135 let dir = TempDir::new().unwrap();
136 let store = FileStore::open(dir.path()).unwrap();
137 (dir, store)
138 }
139
140 #[test]
141 fn manifest_round_trip() {
142 let (_dir, store) = setup();
143 let cas = TreeBlobStore::new(&store);
144
145 let hash_a = Key::from_bytes(b"file-a-content");
146 let hash_b = Key::from_bytes(b"file-b-content");
147 let hash_c = Key::from_bytes(b"file-c-content");
148
149 let entries = vec![
150 ManifestEntry::entry_for(
151 PathBuf::from("src/main.rs"),
152 hash_a,
153 0o644,
154 (1_700_000_000, 123_456_789),
155 ),
156 ManifestEntry::entry_for(
157 PathBuf::from("src/lib.rs"),
158 hash_b,
159 0o755,
160 (1_700_000_001, 0),
161 ),
162 ManifestEntry::entry_for(
163 PathBuf::from("build.rs"),
164 hash_c,
165 0o600,
166 (-86400, 999_999_999),
167 ),
168 ];
169 let deleted = vec![PathBuf::from("old/removed.rs")];
170 let manifest = TreeManifest::new(entries, deleted);
171
172 let key = cas.put_manifest(&manifest).unwrap();
173 let loaded = cas
174 .get_manifest(&key)
175 .unwrap()
176 .expect("manifest must exist");
177
178 assert_eq!(loaded, manifest);
179 assert_eq!(loaded.entries[0].mode, 0o644);
180 assert_eq!(loaded.entries[0].mtime_secs, 1_700_000_000);
181 assert_eq!(loaded.entries[0].mtime_nanos, 123_456_789);
182 assert_eq!(loaded.entries[2].mtime_secs, -86400);
183 assert_eq!(loaded.deleted.len(), 1);
184 assert_eq!(loaded.deleted[0], PathBuf::from("old/removed.rs"));
185 }
186
187 #[test]
188 fn blob_round_trip() {
189 let (_dir, store) = setup();
190 let cas = TreeBlobStore::new(&store);
191
192 let key = cas.put_file_blob(b"hello").unwrap();
193 let fetched = cas.get_file_blob(&key).unwrap();
194 assert_eq!(fetched, Some(b"hello".to_vec()));
195 }
196
197 #[test]
198 fn blob_dedup() {
199 let (_dir, store) = setup();
200 let cas = TreeBlobStore::new(&store);
201
202 let key1 = cas.put_file_blob(b"same content").unwrap();
203 let key2 = cas.put_file_blob(b"same content").unwrap();
204 assert_eq!(key1, key2);
205
206 let root = store.root();
207 let shard = &key1.as_str()[..2];
208 let shard_path = root.join(shard);
209 let count = std::fs::read_dir(&shard_path)
210 .unwrap()
211 .filter_map(|e| e.ok())
212 .filter(|e| e.file_name().to_string_lossy().ends_with(".payload"))
213 .count();
214 assert_eq!(
215 count, 1,
216 "dedup: only one payload file for identical content"
217 );
218 }
219
220 #[test]
221 fn get_missing_key_returns_none() {
222 let (_dir, store) = setup();
223 let cas = TreeBlobStore::new(&store);
224
225 let phantom_key = Key::from_bytes(b"never stored");
226 assert_eq!(cas.get_file_blob(&phantom_key).unwrap(), None);
227 assert!(cas.get_manifest(&phantom_key).unwrap().is_none());
228 }
229
230 #[test]
231 fn mtime_nanosecond_fidelity() {
232 let (_dir, store) = setup();
233 let cas = TreeBlobStore::new(&store);
234
235 let hash = Key::from_bytes(b"precision-test");
236 let entry = ManifestEntry::entry_for(
237 PathBuf::from("file.txt"),
238 hash,
239 0o644,
240 (1_000_000_000, 123_456_789),
241 );
242 let manifest = TreeManifest::new(vec![entry], vec![]);
243
244 let key = cas.put_manifest(&manifest).unwrap();
245 let loaded = cas.get_manifest(&key).unwrap().unwrap();
246
247 assert_eq!(loaded.entries[0].mtime_nanos, 123_456_789);
248 assert_eq!(loaded.entries[0].mtime_secs, 1_000_000_000);
249 }
250
251 #[test]
252 fn a_manifest_written_before_dirs_were_recorded_still_loads() {
253 let manifest: TreeManifest =
254 serde_json::from_str(r#"{"entries":[],"deleted":["gone"]}"#).unwrap();
255 assert_eq!(manifest.deleted, [PathBuf::from("gone")]);
256 assert!(manifest.dirs.is_empty());
257 assert!(manifest.links.is_empty());
258 assert!(manifest.dir_attrs.is_empty());
259 }
260}