1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use crate::chunk::chunk_data;
6use crate::hash::ContentHash;
7use crate::manifest::{Manifest, ManifestError};
8
9const COMPRESSION_THRESHOLD: usize = 512;
10const ZSTD_LEVEL: i32 = 3;
11const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
13
14pub struct ContentStore {
16 objects_dir: PathBuf,
17 manifests_dir: PathBuf,
18}
19
20#[derive(Debug, thiserror::Error)]
21pub enum StoreError {
22 #[error("I/O error: {0}")]
23 Io(#[from] std::io::Error),
24 #[error("chunk not found: {0}")]
25 ChunkNotFound(ContentHash),
26 #[error("manifest not found: {0}")]
27 ManifestNotFound(ContentHash),
28 #[error("manifest error: {0}")]
29 Manifest(#[from] ManifestError),
30 #[error("zstd decompression failed: {0}")]
31 Decompression(String),
32}
33
34impl ContentStore {
35 pub fn open(root: &Path) -> Result<Self, StoreError> {
37 let objects_dir = root.join("objects");
38 let manifests_dir = root.join("manifests");
39 fs::create_dir_all(&objects_dir)?;
40 fs::create_dir_all(&manifests_dir)?;
41 Ok(ContentStore {
42 objects_dir,
43 manifests_dir,
44 })
45 }
46
47 pub fn put_chunk(&self, data: &[u8]) -> Result<ContentHash, StoreError> {
51 let hash = ContentHash::from_bytes(data);
52 let path = self.chunk_path(&hash);
53 if path.exists() {
54 return Ok(hash);
55 }
56 fs::create_dir_all(path.parent().unwrap())?;
57 let stored = if data.len() > COMPRESSION_THRESHOLD {
58 zstd::encode_all(data, ZSTD_LEVEL)
59 .map_err(|e| StoreError::Decompression(e.to_string()))?
60 } else {
61 data.to_vec()
62 };
63 let mut file = fs::File::create(&path)?;
64 file.write_all(&stored)?;
65 Ok(hash)
66 }
67
68 pub fn get_chunk(&self, hash: &ContentHash) -> Result<Vec<u8>, StoreError> {
70 let raw = self.read_raw_chunk(hash)?;
71 if raw.len() >= 4 && raw[..4] == ZSTD_MAGIC {
72 zstd::decode_all(raw.as_slice())
73 .map_err(|e| StoreError::Decompression(e.to_string()))
74 } else {
75 Ok(raw)
76 }
77 }
78
79 pub fn has_chunk(&self, hash: &ContentHash) -> bool {
81 self.chunk_path(hash).exists()
82 }
83
84 pub fn read_raw_chunk(&self, hash: &ContentHash) -> Result<Vec<u8>, StoreError> {
86 let path = self.chunk_path(hash);
87 if !path.exists() {
88 return Err(StoreError::ChunkNotFound(*hash));
89 }
90 Ok(fs::read(&path)?)
91 }
92
93 pub fn put_manifest(&self, manifest: &Manifest) -> Result<ContentHash, StoreError> {
95 let hash = manifest.hash();
96 let path = self.manifest_path(&hash);
97 if path.exists() {
98 return Ok(hash);
99 }
100 fs::create_dir_all(path.parent().unwrap())?;
101 let bytes = manifest.to_bytes();
102 fs::write(&path, &bytes)?;
103 Ok(hash)
104 }
105
106 pub fn get_manifest(&self, hash: &ContentHash) -> Result<Manifest, StoreError> {
108 let path = self.manifest_path(hash);
109 if !path.exists() {
110 return Err(StoreError::ManifestNotFound(*hash));
111 }
112 let bytes = fs::read(&path)?;
113 Ok(Manifest::from_bytes(&bytes)?)
114 }
115
116 pub fn put_file_from_bytes(&self, data: &[u8], mode: u32) -> Result<ContentHash, StoreError> {
119 let chunks = chunk_data(data);
120 let mut chunk_hashes = Vec::with_capacity(chunks.len());
121 for chunk in chunks {
122 let hash = self.put_chunk(chunk)?;
123 chunk_hashes.push(hash);
124 }
125 let manifest = Manifest::new(chunk_hashes, data.len() as u64, mode);
126 self.put_manifest(&manifest)
127 }
128
129 pub fn put_file(&self, path: &Path) -> Result<ContentHash, StoreError> {
132 let data = fs::read(path)?;
133 let metadata = fs::metadata(path)?;
134 let mode = {
135 #[cfg(unix)]
136 {
137 use std::os::unix::fs::PermissionsExt;
138 metadata.permissions().mode()
139 }
140 #[cfg(not(unix))]
141 {
142 0o644
143 }
144 };
145
146 let chunks = chunk_data(&data);
147 let mut chunk_hashes = Vec::with_capacity(chunks.len());
148 for chunk in chunks {
149 let hash = self.put_chunk(chunk)?;
150 chunk_hashes.push(hash);
151 }
152
153 let manifest = Manifest::new(chunk_hashes, data.len() as u64, mode);
154 self.put_manifest(&manifest)
155 }
156
157 pub fn get_file(&self, manifest_hash: &ContentHash, dest: &Path) -> Result<(), StoreError> {
159 let manifest = self.get_manifest(manifest_hash)?;
160 let mut file = fs::File::create(dest)?;
161 for chunk_hash in &manifest.chunks {
162 let data = self.get_chunk(chunk_hash)?;
163 file.write_all(&data)?;
164 }
165
166 #[cfg(unix)]
167 {
168 use std::os::unix::fs::PermissionsExt;
169 fs::set_permissions(dest, fs::Permissions::from_mode(manifest.mode))?;
170 }
171
172 Ok(())
173 }
174
175 pub fn list_manifests(&self) -> Result<Vec<ContentHash>, StoreError> {
177 Self::list_hashes(&self.manifests_dir)
178 }
179
180 pub fn list_chunks(&self) -> Result<Vec<ContentHash>, StoreError> {
182 Self::list_hashes(&self.objects_dir)
183 }
184
185 pub fn remove_manifest(&self, hash: &ContentHash) -> Result<(), StoreError> {
187 let path = self.manifest_path(hash);
188 if path.exists() {
189 fs::remove_file(&path)?;
190 }
191 Ok(())
192 }
193
194 pub fn remove_chunk(&self, hash: &ContentHash) -> Result<(), StoreError> {
196 let path = self.chunk_path(hash);
197 if path.exists() {
198 fs::remove_file(&path)?;
199 }
200 Ok(())
201 }
202
203 fn list_hashes(dir: &Path) -> Result<Vec<ContentHash>, StoreError> {
204 let mut hashes = Vec::new();
205 if !dir.exists() {
206 return Ok(hashes);
207 }
208 for shard_entry in fs::read_dir(dir)? {
209 let shard_entry = shard_entry?;
210 if !shard_entry.file_type()?.is_dir() {
211 continue;
212 }
213 let shard = shard_entry.file_name().to_string_lossy().to_string();
214 for entry in fs::read_dir(shard_entry.path())? {
215 let entry = entry?;
216 let rest = entry.file_name().to_string_lossy().to_string();
217 let hex = format!("{}{}", shard, rest);
218 if let Ok(hash) = ContentHash::from_hex(&hex) {
219 hashes.push(hash);
220 }
221 }
222 }
223 Ok(hashes)
224 }
225
226 fn chunk_path(&self, hash: &ContentHash) -> PathBuf {
227 let hex = hash.to_hex();
228 self.objects_dir.join(&hex[..2]).join(&hex[2..])
229 }
230
231 fn manifest_path(&self, hash: &ContentHash) -> PathBuf {
232 let hex = hash.to_hex();
233 self.manifests_dir.join(&hex[..2]).join(&hex[2..])
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use tempfile::TempDir;
241
242 fn test_store() -> (ContentStore, TempDir) {
243 let dir = TempDir::new().unwrap();
244 let store = ContentStore::open(dir.path()).unwrap();
245 (store, dir)
246 }
247
248 #[test]
249 fn put_and_get_chunk() {
250 let (store, _dir) = test_store();
251 let data = b"hello world";
252 let hash = store.put_chunk(data).unwrap();
253 let retrieved = store.get_chunk(&hash).unwrap();
254 assert_eq!(retrieved, data);
255 }
256
257 #[test]
258 fn has_chunk_returns_false_for_missing() {
259 let (store, _dir) = test_store();
260 let fake_hash = crate::hash::ContentHash::from_bytes(b"nonexistent");
261 assert!(!store.has_chunk(&fake_hash));
262 }
263
264 #[test]
265 fn put_chunk_deduplicates() {
266 let (store, _dir) = test_store();
267 let data = b"duplicate data";
268 let h1 = store.put_chunk(data).unwrap();
269 let h2 = store.put_chunk(data).unwrap();
270 assert_eq!(h1, h2);
271 }
272
273 #[test]
274 fn put_and_get_manifest() {
275 let (store, _dir) = test_store();
276 let chunk_hash = store.put_chunk(b"some data").unwrap();
277 let manifest = crate::manifest::Manifest::new(vec![chunk_hash], 9, 0o644);
278 let manifest_hash = store.put_manifest(&manifest).unwrap();
279 let retrieved = store.get_manifest(&manifest_hash).unwrap();
280 assert_eq!(manifest.hash(), retrieved.hash());
281 }
282
283 #[test]
284 fn put_file_end_to_end() {
285 let (store, dir) = test_store();
286 let file_path = dir.path().join("testfile.txt");
287 std::fs::write(&file_path, b"file content for testing").unwrap();
288 let manifest_hash = store.put_file(&file_path).unwrap();
289 let out_path = dir.path().join("output.txt");
290 store.get_file(&manifest_hash, &out_path).unwrap();
291 assert_eq!(
292 std::fs::read(&file_path).unwrap(),
293 std::fs::read(&out_path).unwrap(),
294 );
295 }
296
297 #[test]
298 fn put_file_large_produces_multiple_chunks() {
299 let (store, dir) = test_store();
300 let file_path = dir.path().join("large.bin");
301 let data: Vec<u8> = (0..256 * 1024).map(|i| (i % 251) as u8).collect();
302 std::fs::write(&file_path, &data).unwrap();
303 let manifest_hash = store.put_file(&file_path).unwrap();
304 let manifest = store.get_manifest(&manifest_hash).unwrap();
305 assert!(manifest.chunks.len() > 1);
306 let out_path = dir.path().join("large_out.bin");
307 store.get_file(&manifest_hash, &out_path).unwrap();
308 assert_eq!(std::fs::read(&file_path).unwrap(), std::fs::read(&out_path).unwrap());
309 }
310
311 #[test]
312 fn small_chunks_not_compressed() {
313 let (store, _dir) = test_store();
314 let data = b"tiny chunk";
315 let hash = store.put_chunk(data).unwrap();
316 let raw = store.read_raw_chunk(&hash).unwrap();
317 assert_eq!(raw, data.as_slice(), "small chunks should be stored uncompressed");
318 }
319
320 #[test]
321 fn large_chunks_compressed() {
322 let (store, _dir) = test_store();
323 let data = vec![0xAA; 1024];
324 let hash = store.put_chunk(&data).unwrap();
325 let raw = store.read_raw_chunk(&hash).unwrap();
326 assert!(raw.len() < data.len(), "large chunks should be compressed");
327 let retrieved = store.get_chunk(&hash).unwrap();
328 assert_eq!(retrieved, data);
329 }
330}