Skip to main content

hd_cas/
store.rs

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;
11// zstd frame magic number: 0xFD2FB528 (little-endian)
12const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
13
14/// On-disk content-addressable store.
15pub 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    /// Open or create a content store at the given root directory.
36    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    /// Store a chunk. Returns its content hash. Deduplicates: if the chunk
48    /// already exists, returns the hash without writing.
49    /// Chunks > 512 bytes are zstd-compressed on disk.
50    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    /// Retrieve a chunk by its hash.
69    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    /// Check if a chunk exists in the store.
80    pub fn has_chunk(&self, hash: &ContentHash) -> bool {
81        self.chunk_path(hash).exists()
82    }
83
84    /// Read the raw bytes of a chunk from disk (possibly compressed).
85    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    /// Store a manifest. Returns the manifest's content hash.
94    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    /// Retrieve a manifest by its hash.
107    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    /// Ingest raw bytes as a file: chunk, store chunks, create manifest.
117    /// Returns the manifest hash. Convenience method for when you have data in memory.
118    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    /// Ingest a file: chunk it, store all chunks, create and store a manifest.
130    /// Returns the manifest hash.
131    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    /// Reconstruct a file from a manifest hash and write it to the destination path.
158    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    /// List all manifest hashes in the store.
176    pub fn list_manifests(&self) -> Result<Vec<ContentHash>, StoreError> {
177        Self::list_hashes(&self.manifests_dir)
178    }
179
180    /// List all chunk hashes in the store.
181    pub fn list_chunks(&self) -> Result<Vec<ContentHash>, StoreError> {
182        Self::list_hashes(&self.objects_dir)
183    }
184
185    /// Remove a manifest by hash.
186    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    /// Remove a chunk by hash.
195    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}