Skip to main content

cas_kit/
store.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Blob Store — the primary CAS interface for storing and retrieving blobs.
3//!
4//! Blobs are stored on disk using a content-addressed scheme:
5//! - Hash is split into a 2-char prefix directory and 62-char filename
6//! - This creates 256 buckets, avoiding any single directory having too many files
7//! - Blobs are optionally Zstd-compressed
8//!
9//! # Thread Safety
10//!
11//! [`BlobStore`] is `Send + Sync` and can be shared across threads via `Arc`.
12//! File operations are the primary bottleneck; the store itself holds no mutable
13//! state beyond the root path. Interior caches use `std::sync::Mutex`; a poisoned
14//! lock is surfaced as [`CasError::LockPoisoned`] where recovery is meaningful,
15//! and ignored (best-effort) where the operation is advisory.
16
17use std::collections::HashSet;
18use std::fs;
19use std::io;
20use std::path::PathBuf;
21use std::sync::Mutex;
22
23use crate::compressor;
24use crate::error::CasError;
25use crate::hash::Hash;
26use crate::hasher;
27use crate::pack::{PackCache, PackFile, PackIndex};
28
29/// Default maximum number of entries in the in-memory blob cache.
30const BLOB_CACHE_CAPACITY: usize = 1024;
31
32/// Check whether data starts with the Zstd frame magic (`0x28 0xB5 0x2F 0xFD`).
33#[cfg(feature = "zstd")]
34#[must_use]
35pub fn is_zstd_compressed(data: &[u8]) -> bool {
36    data.len() >= 4 && data[..4] == [0x28, 0xB5, 0x2F, 0xFD]
37}
38
39/// Feature-less build: the store never writes Zstd frames, so every blob
40/// is treated as raw. Reading a store written by a zstd-enabled build
41/// fails hash verification on such blobs instead of silently returning
42/// still-compressed bytes.
43#[cfg(not(feature = "zstd"))]
44#[must_use]
45pub fn is_zstd_compressed(_data: &[u8]) -> bool {
46    false
47}
48
49/// The content-addressable-storage blob store.
50///
51/// Stores blobs indexed by BLAKE3 hash on the local filesystem.
52/// Provides deduplication, optional compression, and integrity verification.
53///
54/// # Thread Safety
55///
56/// `BlobStore` is `Send + Sync` and can be shared across threads via `Arc`.
57/// The pack index cache uses `Mutex` for interior mutability.
58pub struct BlobStore {
59    /// Root directory containing the `objects/` subdirectory.
60    root: PathBuf,
61    /// Whether to compress blobs with Zstd.
62    compress: bool,
63    /// Zstd compression level (1-22).
64    #[cfg_attr(not(feature = "zstd"), allow(dead_code))]
65    compression_level: i32,
66    /// Whether to verify blob hashes on read. Default: true.
67    /// Set to false for hot paths where performance matters more than
68    /// per-read integrity verification (content addressing already
69    /// provides correctness by construction).
70    verify_on_read: bool,
71    /// Cached pack indices, loaded lazily on first pack access.
72    /// Invalidated when `repack()` creates new pack files.
73    pack_cache: Mutex<Option<PackCache>>,
74    /// In-memory LRU-like blob cache. Uses a simple ordered Vec as a ring buffer
75    /// to bound memory usage without external dependencies. Most-recently-accessed
76    /// entries are promoted to the front on cache hit.
77    blob_cache: Mutex<Vec<(Hash, Vec<u8>)>>,
78    /// Cache of known blob prefix directories (2-hex-char buckets).
79    /// Avoids redundant `fs::create_dir_all` syscalls when many blobs share
80    /// the same prefix. At most 256 entries (00–ff).
81    known_dirs: Mutex<HashSet<PathBuf>>,
82}
83
84impl BlobStore {
85    /// Create a new BlobStore rooted at the given directory.
86    ///
87    /// Creates the `objects/` subdirectory if it doesn't exist.
88    pub fn new(root: impl Into<PathBuf>) -> Result<Self, CasError> {
89        let root = root.into();
90        let objects_dir = root.join("objects");
91        fs::create_dir_all(&objects_dir)?;
92        Ok(Self {
93            root,
94            compress: true,
95            compression_level: compressor::DEFAULT_COMPRESSION_LEVEL,
96            verify_on_read: true,
97            pack_cache: Mutex::new(None),
98            blob_cache: Mutex::new(Vec::with_capacity(BLOB_CACHE_CAPACITY)),
99            known_dirs: Mutex::new(HashSet::new()),
100        })
101    }
102
103    /// Create a BlobStore backed by a temporary directory.
104    ///
105    /// Useful for testing and in-memory repository usage. The temporary
106    /// directory is cleaned up when the returned `TempDir` is dropped.
107    pub fn open_in_memory() -> Result<(tempfile::TempDir, Self), CasError> {
108        let root = tempfile::tempdir()?;
109        let objects_dir = root.path().join("objects");
110        fs::create_dir_all(&objects_dir)?;
111        let store = Self {
112            root: root.path().to_path_buf(),
113            compress: true,
114            compression_level: compressor::DEFAULT_COMPRESSION_LEVEL,
115            verify_on_read: true,
116            pack_cache: Mutex::new(None),
117            blob_cache: Mutex::new(Vec::with_capacity(BLOB_CACHE_CAPACITY)),
118            known_dirs: Mutex::new(HashSet::new()),
119        };
120        Ok((root, store))
121    }
122
123    /// Create a BlobStore with compression disabled (for testing).
124    pub fn new_uncompressed(root: impl Into<PathBuf>) -> Result<Self, CasError> {
125        let mut store = Self::new(root)?;
126        store.compress = false;
127        Ok(store)
128    }
129
130    /// Set whether to verify blob hashes on read.
131    ///
132    /// When disabled, `get_blob()` skips the BLAKE3 hash verification
133    /// step, saving O(n) computation per read. The content-addressed
134    /// storage scheme already provides correctness by construction
135    /// (the filename is the hash), so this is safe for performance-critical
136    /// paths which may read many blobs in sequence.
137    pub fn set_verify_on_read(&mut self, verify: bool) {
138        self.verify_on_read = verify;
139    }
140
141    /// Check whether hash verification is enabled on read.
142    pub fn verify_on_read(&self) -> bool {
143        self.verify_on_read
144    }
145
146    fn ensure_parent_dir(&self, parent: &std::path::Path) -> Result<(), CasError> {
147        {
148            let known = self
149                .known_dirs
150                .lock()
151                .map_err(|e| CasError::LockPoisoned(e.to_string()))?;
152            if known.contains(parent) {
153                return Ok(());
154            }
155        }
156        fs::create_dir_all(parent)?;
157        self.known_dirs
158            .lock()
159            .map_err(|e| CasError::LockPoisoned(e.to_string()))?
160            .insert(parent.to_path_buf());
161        Ok(())
162    }
163
164    /// Store a blob, returning its BLAKE3 hash.
165    ///
166    /// If a blob with the same hash already exists, this is a no-op
167    /// (deduplication). Returns the hash either way.
168    pub fn put_blob(&self, data: &[u8]) -> Result<Hash, CasError> {
169        let hash = hasher::hash_bytes(data);
170        let blob_path = self.blob_path(&hash);
171
172        // Deduplication: if blob already exists, return immediately.
173        if blob_path.exists() {
174            return Ok(hash);
175        }
176
177        // Ensure the prefix directory exists.
178        if let Some(parent) = blob_path.parent() {
179            self.ensure_parent_dir(parent)?;
180        }
181
182        // Write blob (optionally compressed).
183        #[cfg(feature = "zstd")]
184        if self.compress {
185            let compressed = compressor::compress(data, self.compression_level)?;
186            fs::write(&blob_path, &compressed)?;
187        } else {
188            fs::write(&blob_path, data)?;
189        }
190        #[cfg(not(feature = "zstd"))]
191        fs::write(&blob_path, data)?;
192
193        Ok(hash)
194    }
195
196    /// Store a blob, returning an error if it already exists.
197    pub fn put_blob_new(&self, data: &[u8]) -> Result<Hash, CasError> {
198        let hash = hasher::hash_bytes(data);
199        let blob_path = self.blob_path(&hash);
200
201        if blob_path.exists() {
202            return Err(CasError::AlreadyExists(hash.to_hex()));
203        }
204
205        if let Some(parent) = blob_path.parent() {
206            self.ensure_parent_dir(parent)?;
207        }
208
209        #[cfg(feature = "zstd")]
210        if self.compress {
211            let compressed = compressor::compress(data, self.compression_level)?;
212            fs::write(&blob_path, &compressed)?;
213        } else {
214            fs::write(&blob_path, data)?;
215        }
216        #[cfg(not(feature = "zstd"))]
217        fs::write(&blob_path, data)?;
218
219        Ok(hash)
220    }
221
222    /// Store a blob with an explicit hash (used when receiving blobs from a remote).
223    ///
224    /// Verifies the data matches the expected hash before storing.
225    pub fn put_blob_with_hash(&self, data: &[u8], expected_hash: &Hash) -> Result<(), CasError> {
226        let blob_path = self.blob_path(expected_hash);
227
228        if blob_path.exists() {
229            return Ok(());
230        }
231
232        hasher::verify_hash(data, expected_hash)?;
233
234        if let Some(parent) = blob_path.parent() {
235            self.ensure_parent_dir(parent)?;
236        }
237
238        #[cfg(feature = "zstd")]
239        if self.compress {
240            let compressed = compressor::compress(data, self.compression_level)?;
241            fs::write(&blob_path, &compressed)?;
242        } else {
243            fs::write(&blob_path, data)?;
244        }
245        #[cfg(not(feature = "zstd"))]
246        fs::write(&blob_path, data)?;
247
248        Ok(())
249    }
250
251    /// Retrieve a blob by its BLAKE3 hash.
252    ///
253    /// Tries the in-memory cache, then loose objects, then pack files.
254    /// Decompresses if necessary and verifies the hash of the result
255    /// (unless verification was disabled via `set_verify_on_read(false)`).
256    pub fn get_blob(&self, hash: &Hash) -> Result<Vec<u8>, CasError> {
257        {
258            let mut cache = self
259                .blob_cache
260                .lock()
261                .map_err(|e| CasError::LockPoisoned(e.to_string()))?;
262            if let Some(pos) = cache.iter().position(|(h, _)| h == hash) {
263                let (_, data) = cache.remove(pos);
264                cache.insert(0, (*hash, data.clone()));
265                return Ok(data);
266            }
267        }
268
269        let data = if self.blob_path(hash).exists() {
270            let raw = fs::read(self.blob_path(hash))?;
271            #[cfg(feature = "zstd")]
272            let result = if is_zstd_compressed(&raw) {
273                compressor::decompress(&raw)?
274            } else {
275                raw
276            };
277            #[cfg(not(feature = "zstd"))]
278            let result = raw;
279            if self.verify_on_read {
280                hasher::verify_hash(&result, hash)?;
281            }
282            result
283        } else {
284            match self.get_blob_packed(hash) {
285                Ok(data) => data,
286                // A genuinely absent blob is the expected miss path; any
287                // other error (I/O, corrupt pack, poisoned lock) is real
288                // corruption or resource failure and must not masquerade
289                // as "not found".
290                Err(CasError::BlobNotFound(_)) => {
291                    return Err(CasError::BlobNotFound(hash.to_hex()))
292                }
293                Err(e) => return Err(e),
294            }
295        };
296
297        self.cache_blob(*hash, data.clone());
298        Ok(data)
299    }
300
301    /// Insert a blob into the in-memory cache with LRU eviction.
302    fn cache_blob(&self, hash: Hash, data: Vec<u8>) {
303        // Best-effort caching: if the lock is poisoned the blob is still
304        // on disk, so a failed cache insert only costs a future disk read.
305        let Ok(mut cache) = self.blob_cache.lock() else {
306            return;
307        };
308        // Evict oldest entry if at capacity.
309        if cache.len() >= BLOB_CACHE_CAPACITY {
310            cache.pop();
311        }
312        cache.insert(0, (hash, data));
313    }
314
315    /// Check if a blob exists in the store.
316    ///
317    /// Checks loose objects first, then pack files.
318    /// This does NOT verify the blob's integrity — it only checks for existence.
319    pub fn has_blob(&self, hash: &Hash) -> bool {
320        self.blob_path(hash).exists() || self.has_blob_packed(hash)
321    }
322
323    /// Delete a blob from the store.
324    ///
325    /// The caller is responsible for ensuring no patches reference this blob.
326    pub fn delete_blob(&self, hash: &Hash) -> Result<(), CasError> {
327        let blob_path = self.blob_path(hash);
328        fs::remove_file(&blob_path).map_err(|e| {
329            if e.kind() == io::ErrorKind::NotFound {
330                CasError::BlobNotFound(hash.to_hex())
331            } else {
332                CasError::Io(e)
333            }
334        })
335    }
336
337    /// Get the total number of loose blobs in the store.
338    pub fn blob_count(&self) -> Result<u64, CasError> {
339        let objects_dir = self.root.join("objects");
340        let mut count = 0u64;
341        if objects_dir.exists() {
342            for entry in fs::read_dir(&objects_dir)? {
343                let entry = entry?;
344                if entry.file_type()?.is_dir() {
345                    let dir_name = entry.file_name();
346                    if dir_name == "pack" {
347                        continue;
348                    }
349                    for sub_entry in fs::read_dir(entry.path())? {
350                        let sub_entry = sub_entry?;
351                        if sub_entry.file_type()?.is_file() {
352                            count += 1;
353                        }
354                    }
355                }
356            }
357        }
358        Ok(count)
359    }
360
361    /// Get the total size of all loose blobs in the store (on-disk size).
362    pub fn total_size(&self) -> Result<u64, CasError> {
363        let objects_dir = self.root.join("objects");
364        let mut total = 0u64;
365        if objects_dir.exists() {
366            for entry in fs::read_dir(&objects_dir)? {
367                let entry = entry?;
368                if entry.file_type()?.is_dir() {
369                    let dir_name = entry.file_name();
370                    if dir_name == "pack" {
371                        continue;
372                    }
373                    for sub_entry in fs::read_dir(entry.path())? {
374                        let sub_entry = sub_entry?;
375                        if sub_entry.file_type()?.is_file() {
376                            total += sub_entry.metadata()?.len();
377                        }
378                    }
379                }
380            }
381        }
382        Ok(total)
383    }
384
385    /// List all loose blob hashes in the store.
386    ///
387    /// Entries whose names do not form valid 64-char hex are skipped
388    /// (they are not valid content addresses).
389    pub fn list_blobs(&self) -> Result<Vec<Hash>, CasError> {
390        let objects_dir = self.root.join("objects");
391        let mut hashes = Vec::new();
392        if !objects_dir.exists() {
393            return Ok(hashes);
394        }
395        for entry in fs::read_dir(&objects_dir)? {
396            let entry = entry?;
397            if entry.file_type()?.is_dir() {
398                let dir_name = entry.file_name();
399                if dir_name == "pack" {
400                    continue;
401                }
402                let prefix = dir_name.to_string_lossy().to_string();
403                for sub_entry in fs::read_dir(entry.path())? {
404                    let sub_entry = sub_entry?;
405                    if sub_entry.file_type()?.is_file() {
406                        let suffix = sub_entry.file_name().to_string_lossy().to_string();
407                        let hex = format!("{prefix}{suffix}");
408                        if let Ok(hash) = Hash::from_hex(&hex) {
409                            hashes.push(hash);
410                        }
411                    }
412                }
413            }
414        }
415        hashes.sort();
416        Ok(hashes)
417    }
418
419    /// Get the path to the objects directory.
420    pub fn objects_dir(&self) -> PathBuf {
421        self.root.join("objects")
422    }
423
424    /// Get the store root directory (the parent of `objects/`).
425    ///
426    /// GC sweeps in trash mode place recoverable copies under
427    /// `<root>/trash/` (see [`crate::gc`]).
428    #[must_use]
429    pub fn root(&self) -> &std::path::Path {
430        &self.root
431    }
432
433    /// Get the path to the pack directory.
434    pub fn pack_dir(&self) -> PathBuf {
435        self.root.join("objects").join("pack")
436    }
437
438    /// Ensure pack cache is loaded, then call `f` with a reference to it.
439    ///
440    /// On first access, reads all `.idx` files from the pack directory.
441    /// Subsequent calls return the cached data without disk I/O.
442    /// Call `invalidate_pack_cache()` after `repack()` to force a reload.
443    fn with_pack_cache<F, R>(&self, f: F) -> Result<R, CasError>
444    where
445        F: FnOnce(&PackCache) -> R,
446    {
447        let mut guard = self
448            .pack_cache
449            .lock()
450            .map_err(|e| CasError::LockPoisoned(e.to_string()))?;
451        if guard.is_none() {
452            let cache = PackCache::load_all(&self.pack_dir())?;
453            *guard = Some(cache);
454        }
455        // INVARIANT (documented-infallible): `guard` was `Some` on entry or
456        // was just assigned `Some(cache)` directly above. The mutex guard
457        // is exclusive, so no other thread can have set it back to `None`
458        // between those two statements. The `expect` can therefore never
459        // fire; it exists to convert `Option` -> `&PackCache` without
460        // cloning.
461        // Justified: see invariant comment above.
462        #[allow(clippy::expect_used)]
463        let cache = guard
464            .as_ref()
465            .expect("pack cache was populated two statements above under an exclusive lock");
466        Ok(f(cache))
467    }
468
469    /// Invalidate the pack cache (call after repack or external pack changes).
470    ///
471    /// Best-effort: if the lock is poisoned the cache is left as-is; the
472    /// next successful lock still sees whatever entries exist, and pack
473    /// lookups remain correct because pack content is immutable once
474    /// written.
475    pub fn invalidate_pack_cache(&self) {
476        if let Ok(mut guard) = self.pack_cache.lock() {
477            *guard = None;
478        }
479    }
480
481    /// Retrieve a blob from pack files only (not loose objects).
482    pub fn get_blob_packed(&self, hash: &Hash) -> Result<Vec<u8>, CasError> {
483        // Find which pack file contains this blob.
484        let pack_path = self.with_pack_cache(|cache| cache.find(hash).map(|(p, _)| p.clone()))?;
485        let pack_path = pack_path.ok_or_else(|| CasError::BlobNotFound(hash.to_hex()))?;
486
487        let idx_path = pack_path.with_extension("idx");
488        let index = PackIndex::load(&idx_path)?;
489        let data = PackFile::read_blob(&pack_path, &index, hash)?;
490        Ok(data)
491    }
492
493    /// Check if a blob exists in any pack file.
494    ///
495    /// Best-effort: a poisoned pack-cache lock reads as "not present"
496    /// rather than panicking; callers doing integrity-critical work should
497    /// use `get_blob` (which propagates lock poisoning) instead.
498    pub fn has_blob_packed(&self, hash: &Hash) -> bool {
499        self.with_pack_cache(|cache| cache.find(hash).is_some())
500            .unwrap_or(false)
501    }
502
503    /// List all blob hashes stored in pack files.
504    pub fn list_blobs_packed(&self) -> Result<Vec<Hash>, CasError> {
505        self.with_pack_cache(PackCache::all_hashes)
506    }
507
508    /// Repack loose blobs into a pack file if the count exceeds the threshold.
509    ///
510    /// Returns the number of blobs that were packed. If the loose blob count
511    /// is at or below the threshold, no packing occurs and 0 is returned.
512    /// After successful packing, the loose blobs are removed.
513    ///
514    /// Loose-blob deletion after the pack write is best-effort: if a delete
515    /// fails, the blob simply remains loose (and takes read priority), so
516    /// no data is lost and no error is raised.
517    pub fn repack(&self, threshold: usize) -> Result<usize, CasError> {
518        let loose_hashes = self.list_blobs()?;
519        if loose_hashes.len() <= threshold {
520            return Ok(0);
521        }
522
523        let mut objects = Vec::with_capacity(loose_hashes.len());
524        for hash in &loose_hashes {
525            let data = self.get_blob(hash)?;
526            objects.push((*hash, data));
527        }
528
529        let (pack_path, _idx_path) = PackFile::create(&self.pack_dir(), &objects)?;
530        debug_assert!(pack_path.exists());
531
532        for hash in &loose_hashes {
533            // Best-effort delete (see doc comment): failure leaves the
534            // blob loose, which is safe because loose reads take priority.
535            let _ = self.delete_blob(hash);
536        }
537
538        // Invalidate pack cache since we created new pack files.
539        self.invalidate_pack_cache();
540
541        Ok(loose_hashes.len())
542    }
543
544    /// Get the on-disk path for a given hash.
545    fn blob_path(&self, hash: &Hash) -> PathBuf {
546        let hex = hash.to_hex();
547        // INVARIANT (Kani-verified in tests/kani.rs): `to_hex` always emits
548        // exactly 64 lowercase hex chars whose first two encode
549        // `hash.bucket()`, so the 2/62 split below can never go out of
550        // bounds and always yields a 2-char bucket + 62-char filename.
551        let prefix = &hex[..2];
552        let suffix = &hex[2..];
553        self.root.join("objects").join(prefix).join(suffix)
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use tempfile::TempDir;
561
562    /// All fallible test steps use `?` into `Box<dyn Error>`; there are no
563    /// bare `unwrap()`s in this crate (enforced by the unwrap sweep gate).
564    type TestResult = Result<(), Box<dyn std::error::Error>>;
565
566    fn make_store() -> Result<(TempDir, BlobStore), Box<dyn std::error::Error>> {
567        let dir = tempfile::tempdir()?;
568        let store = BlobStore::new_uncompressed(dir.path())?;
569        Ok((dir, store))
570    }
571
572    /// Whether the pack cache is populated, mapping lock poisoning to a
573    /// test failure instead of unwrapping.
574    fn pack_cache_loaded(store: &BlobStore) -> Result<bool, Box<dyn std::error::Error>> {
575        Ok(store
576            .pack_cache
577            .lock()
578            .map_err(|_| "pack cache lock poisoned")?
579            .is_some())
580    }
581
582    #[test]
583    fn test_put_and_get_blob() -> TestResult {
584        let (_dir, store) = make_store()?;
585        let data = b"hello, suture!";
586        let hash = store.put_blob(data)?;
587
588        let retrieved = store.get_blob(&hash)?;
589        assert_eq!(data.as_slice(), retrieved.as_slice());
590        Ok(())
591    }
592
593    #[test]
594    fn test_deduplication() -> TestResult {
595        let (_dir, store) = make_store()?;
596        let data = b"deduplicate me";
597
598        let h1 = store.put_blob(data)?;
599        let h2 = store.put_blob(data)?;
600        assert_eq!(h1, h2);
601
602        assert_eq!(store.blob_count()?, 1, "Only one copy should exist");
603        Ok(())
604    }
605
606    #[test]
607    fn test_has_blob() -> TestResult {
608        let (_dir, store) = make_store()?;
609        let hash = store.put_blob(b"exists")?;
610
611        assert!(store.has_blob(&hash));
612        let missing = Hash::from_hex(&"f".repeat(64))?;
613        assert!(!store.has_blob(&missing));
614        Ok(())
615    }
616
617    #[test]
618    fn test_get_nonexistent_blob() -> TestResult {
619        let (_dir, store) = make_store()?;
620        let missing = Hash::from_hex(&"a".repeat(64))?;
621        let result = store.get_blob(&missing);
622        assert!(matches!(result, Err(CasError::BlobNotFound(_))));
623        Ok(())
624    }
625
626    #[test]
627    fn test_delete_blob() -> TestResult {
628        let (_dir, store) = make_store()?;
629        let hash = store.put_blob(b"delete me")?;
630        assert!(store.has_blob(&hash));
631
632        store.delete_blob(&hash)?;
633        assert!(!store.has_blob(&hash));
634        Ok(())
635    }
636
637    #[test]
638    fn test_delete_nonexistent_blob() -> TestResult {
639        let (_dir, store) = make_store()?;
640        let missing = Hash::from_hex(&"b".repeat(64))?;
641        let result = store.delete_blob(&missing);
642        assert!(matches!(result, Err(CasError::BlobNotFound(_))));
643        Ok(())
644    }
645
646    #[test]
647    fn test_put_blob_new_rejects_duplicate() -> TestResult {
648        let (_dir, store) = make_store()?;
649        let data = b"duplicate";
650        store.put_blob(data)?;
651        let result = store.put_blob_new(data);
652        assert!(matches!(result, Err(CasError::AlreadyExists(_))));
653        Ok(())
654    }
655
656    #[test]
657    fn test_put_blob_with_hash_verifies() -> TestResult {
658        let (_dir, store) = make_store()?;
659        let data = b"verified content";
660        let hash = hasher::hash_bytes(data);
661        store.put_blob_with_hash(data, &hash)?;
662
663        // Wrong content for a claimed (not-yet-present) hash must be rejected.
664        let wrong = hasher::hash_bytes(b"other content");
665        let result = store.put_blob_with_hash(b"original", &wrong);
666        assert!(matches!(result, Err(CasError::HashMismatch { .. })));
667        Ok(())
668    }
669
670    #[test]
671    fn test_blob_count_and_list() -> TestResult {
672        let (_dir, store) = make_store()?;
673        store.put_blob(b"one")?;
674        store.put_blob(b"two")?;
675        store.put_blob(b"three")?;
676
677        assert_eq!(store.blob_count()?, 3);
678        assert_eq!(store.list_blobs()?.len(), 3);
679        Ok(())
680    }
681
682    #[test]
683    fn test_large_blob() -> TestResult {
684        let (_dir, store) = make_store()?;
685        // 10 MB blob.
686        let data: Vec<u8> = (0..10_000_000).map(|i| (i % 256) as u8).collect();
687        let hash = store.put_blob(&data)?;
688
689        let retrieved = store.get_blob(&hash)?;
690        assert_eq!(data.len(), retrieved.len());
691        assert_eq!(data, retrieved);
692        Ok(())
693    }
694
695    #[test]
696    fn test_hash_integrity() -> TestResult {
697        let (_dir, store) = make_store()?;
698        let data = b"integrity check";
699        let hash = store.put_blob(data)?;
700
701        // Manually corrupt the stored blob.
702        let blob_path = store.blob_path(&hash);
703        let mut corrupted = fs::read(&blob_path)?;
704        corrupted[0] = corrupted[0].wrapping_add(1);
705        fs::write(&blob_path, &corrupted)?;
706
707        // Getting the corrupted blob should fail integrity check.
708        let result = store.get_blob(&hash);
709        assert!(matches!(result, Err(CasError::HashMismatch { .. })));
710        Ok(())
711    }
712
713    #[test]
714    fn test_verify_on_read_disabled_skips_check() -> TestResult {
715        let (_dir, store) = make_store()?;
716        let data = b"trust me";
717        let hash = store.put_blob(data)?;
718
719        let mut store = store;
720        store.set_verify_on_read(false);
721        assert!(!store.verify_on_read());
722
723        // Corrupt the loose blob; with verification off the corrupted
724        // bytes come back without an error.
725        let blob_path = store.blob_path(&hash);
726        let mut corrupted = fs::read(&blob_path)?;
727        corrupted[0] = corrupted[0].wrapping_add(1);
728        fs::write(&blob_path, &corrupted)?;
729
730        let result = store.get_blob(&hash)?;
731        assert_eq!(result, corrupted);
732        Ok(())
733    }
734
735    #[cfg(feature = "zstd")]
736    #[test]
737    fn test_compressed_store() -> TestResult {
738        let dir = tempfile::tempdir()?;
739        let store = BlobStore::new(dir.path())?;
740
741        let data = b"this will be compressed";
742        let hash = store.put_blob(data)?;
743
744        // Verify the stored file is actually compressed.
745        let blob_path = store.blob_path(&hash);
746        let raw = fs::read(&blob_path)?;
747        assert!(is_zstd_compressed(&raw), "Blob should be Zstd-compressed");
748
749        // Verify round-trip.
750        let retrieved = store.get_blob(&hash)?;
751        assert_eq!(data.as_slice(), retrieved.as_slice());
752        Ok(())
753    }
754
755    #[cfg(feature = "zstd")]
756    #[test]
757    fn test_decompress_rejects_zip_bomb() -> TestResult {
758        use std::io::Write;
759
760        let dir = tempfile::tempdir()?;
761        let store = BlobStore::new(dir.path())?;
762
763        // Craft a Zstd frame that expands past MAX_DECOMPRESSED_SIZE
764        // without ever materializing the full payload in memory.
765        let mut encoder = zstd::Encoder::new(Vec::new(), 3)?;
766        let chunk = [0u8; 65536];
767        let total = compressor::MAX_DECOMPRESSED_SIZE + 1;
768        let mut written = 0usize;
769        while written < total {
770            encoder.write_all(&chunk)?;
771            written += chunk.len();
772        }
773        let frame = encoder.finish()?;
774
775        // Deposit the frame directly under an arbitrary address; get_blob
776        // decompresses before hashing, so the cap must trip first.
777        let addr = Hash::from_hex(&"7".repeat(64))?;
778        let path = store.blob_path(&addr);
779        fs::create_dir_all(path.parent().ok_or("address path has no parent")?)?;
780        fs::write(&path, &frame)?;
781
782        let result = store.get_blob(&addr);
783        assert!(
784            matches!(result, Err(CasError::DecompressionTooLarge { .. })),
785            "zip bomb must be rejected"
786        );
787        Ok(())
788    }
789
790    #[test]
791    fn test_blob_path_layout() -> TestResult {
792        let (_dir, store) = make_store()?;
793        let hash = hasher::hash_bytes(b"layout");
794        let path = store.blob_path(&hash);
795        let expected = store
796            .objects_dir()
797            .join(&hash.to_hex()[..2])
798            .join(&hash.to_hex()[2..]);
799        assert_eq!(path, expected);
800        Ok(())
801    }
802
803    #[test]
804    fn test_in_memory_store() -> TestResult {
805        let (_root, store) = BlobStore::open_in_memory()?;
806        let hash = store.put_blob(b"in-memory")?;
807        assert_eq!(store.get_blob(&hash)?, b"in-memory".to_vec());
808        Ok(())
809    }
810
811    mod proptests {
812        use super::*;
813        use proptest::prelude::*;
814
815        /// Convert any fallible step into a proptest failure. Used instead
816        /// of `unwrap` to keep the zero-unwrap gate absolute.
817        fn soft<T, E: std::fmt::Display>(r: Result<T, E>) -> Result<T, TestCaseError> {
818            r.map_err(|e| TestCaseError::fail(e.to_string()))
819        }
820
821        fn arb_bytes(max: usize) -> impl Strategy<Value = Vec<u8>> {
822            proptest::collection::vec(proptest::num::u8::ANY, 0..max)
823        }
824
825        proptest! {
826            #[test]
827            fn put_get_roundtrip(data in arb_bytes(1024)) {
828                let dir = soft(tempfile::tempdir())?;
829                let store = soft(BlobStore::new_uncompressed(dir.path()))?;
830                let hash = soft(store.put_blob(&data))?;
831                let retrieved = soft(store.get_blob(&hash))?;
832                prop_assert_eq!(data, retrieved);
833            }
834
835            #[test]
836            fn content_addressing(data1 in arb_bytes(512), data2 in arb_bytes(512)) {
837                let dir = soft(tempfile::tempdir())?;
838                let store = soft(BlobStore::new_uncompressed(dir.path()))?;
839
840                let hash1 = soft(store.put_blob(&data1))?;
841                let hash2 = soft(store.put_blob(&data2))?;
842
843                if data1 == data2 {
844                    prop_assert_eq!(hash1, hash2, "same data must produce same hash");
845                } else {
846                    prop_assert_ne!(hash1, hash2, "different data must produce different hashes");
847                }
848            }
849
850            #[test]
851            fn put_twice_idempotent(data in arb_bytes(1024)) {
852                let dir = soft(tempfile::tempdir())?;
853                let store = soft(BlobStore::new_uncompressed(dir.path()))?;
854
855                let hash1 = soft(store.put_blob(&data))?;
856                let hash2 = soft(store.put_blob(&data))?;
857                prop_assert_eq!(hash1, hash2);
858                prop_assert_eq!(soft(store.blob_count())?, 1);
859            }
860        }
861    }
862
863    mod pack_tests {
864        use super::*;
865
866        #[test]
867        fn test_get_blob_from_pack() -> TestResult {
868            let dir = tempfile::tempdir()?;
869            let store = BlobStore::new_uncompressed(dir.path())?;
870
871            let hash1 = store.put_blob(b"packed blob one")?;
872            let hash2 = store.put_blob(b"packed blob two")?;
873
874            let packed = store.repack(0)?;
875            assert_eq!(packed, 2);
876
877            assert_eq!(store.blob_count()?, 0);
878
879            let data1 = store.get_blob(&hash1)?;
880            assert_eq!(data1, b"packed blob one".to_vec());
881
882            let data2 = store.get_blob(&hash2)?;
883            assert_eq!(data2, b"packed blob two".to_vec());
884            Ok(())
885        }
886
887        #[test]
888        fn test_has_blob_checks_packs() -> TestResult {
889            let dir = tempfile::tempdir()?;
890            let store = BlobStore::new_uncompressed(dir.path())?;
891
892            let hash = store.put_blob(b"check me in packs")?;
893            store.repack(0)?;
894
895            assert!(store.has_blob(&hash));
896            let missing = Hash::from_hex(&"c".repeat(64))?;
897            assert!(!store.has_blob(&missing));
898            Ok(())
899        }
900
901        #[test]
902        fn test_get_blob_packed_not_found() -> TestResult {
903            let dir = tempfile::tempdir()?;
904            let store = BlobStore::new_uncompressed(dir.path())?;
905
906            let missing = Hash::from_hex(&"d".repeat(64))?;
907            let result = store.get_blob_packed(&missing);
908            assert!(matches!(result, Err(CasError::BlobNotFound(_))));
909            Ok(())
910        }
911
912        #[test]
913        fn test_list_blobs_packed() -> TestResult {
914            let dir = tempfile::tempdir()?;
915            let store = BlobStore::new_uncompressed(dir.path())?;
916
917            store.put_blob(b"alpha")?;
918            store.put_blob(b"beta")?;
919            store.repack(0)?;
920
921            let packed = store.list_blobs_packed()?;
922            assert_eq!(packed.len(), 2);
923            Ok(())
924        }
925
926        #[test]
927        fn test_repack_below_threshold() -> TestResult {
928            let dir = tempfile::tempdir()?;
929            let store = BlobStore::new_uncompressed(dir.path())?;
930
931            store.put_blob(b"only one")?;
932
933            let packed = store.repack(10)?;
934            assert_eq!(packed, 0);
935            assert_eq!(store.blob_count()?, 1);
936            Ok(())
937        }
938
939        #[test]
940        fn test_repack_at_threshold() -> TestResult {
941            let dir = tempfile::tempdir()?;
942            let store = BlobStore::new_uncompressed(dir.path())?;
943
944            store.put_blob(b"one")?;
945            store.put_blob(b"two")?;
946
947            let packed = store.repack(2)?;
948            assert_eq!(packed, 0);
949            assert_eq!(store.blob_count()?, 2);
950
951            let packed = store.repack(1)?;
952            assert_eq!(packed, 2);
953            assert_eq!(store.blob_count()?, 0);
954            Ok(())
955        }
956
957        #[test]
958        fn test_loose_priority_over_packed() -> TestResult {
959            let dir = tempfile::tempdir()?;
960            let store = BlobStore::new_uncompressed(dir.path())?;
961
962            let hash = store.put_blob(b"original data")?;
963            store.repack(0)?;
964
965            // Re-store the same hash as a loose blob.
966            let blob_path = store.blob_path(&hash);
967            if let Some(parent) = blob_path.parent() {
968                fs::create_dir_all(parent)?;
969            }
970            fs::write(&blob_path, b"original data")?;
971
972            let data = store.get_blob(&hash)?;
973            assert_eq!(data, b"original data".to_vec());
974
975            // Delete the loose blob; should still find in pack.
976            store.delete_blob(&hash)?;
977            let data = store.get_blob(&hash)?;
978            assert_eq!(data, b"original data".to_vec());
979            Ok(())
980        }
981
982        #[test]
983        fn test_has_blob_packed() -> TestResult {
984            let dir = tempfile::tempdir()?;
985            let store = BlobStore::new_uncompressed(dir.path())?;
986
987            let hash = store.put_blob(b"packed check")?;
988            assert!(!store.has_blob_packed(&hash));
989
990            store.repack(0)?;
991            assert!(store.has_blob_packed(&hash));
992            Ok(())
993        }
994
995        #[test]
996        fn test_repack_multiple_times() -> TestResult {
997            let dir = tempfile::tempdir()?;
998            let store = BlobStore::new_uncompressed(dir.path())?;
999
1000            store.put_blob(b"first batch one")?;
1001            store.put_blob(b"first batch two")?;
1002            store.repack(0)?;
1003
1004            store.put_blob(b"second batch")?;
1005            store.repack(0)?;
1006
1007            let all = store.list_blobs_packed()?;
1008            assert_eq!(all.len(), 3);
1009            Ok(())
1010        }
1011
1012        #[test]
1013        fn test_pack_cache_avoids_repeated_disk_reads() -> TestResult {
1014            let dir = tempfile::tempdir()?;
1015            let store = BlobStore::new_uncompressed(dir.path())?;
1016
1017            let hash = store.put_blob(b"cache me")?;
1018            store.repack(0)?;
1019
1020            // First access: loads cache from disk.
1021            assert!(store.has_blob_packed(&hash));
1022            // Cache should now be populated.
1023            assert!(
1024                pack_cache_loaded(&store)?,
1025                "pack cache should be populated after first access"
1026            );
1027
1028            // Second access: uses cached data (no disk I/O).
1029            assert!(store.has_blob_packed(&hash));
1030
1031            // Third access: also cached.
1032            let data = store.get_blob_packed(&hash)?;
1033            assert_eq!(data, b"cache me".to_vec());
1034            Ok(())
1035        }
1036
1037        #[test]
1038        fn test_invalidate_pack_cache() -> TestResult {
1039            let dir = tempfile::tempdir()?;
1040            let store = BlobStore::new_uncompressed(dir.path())?;
1041
1042            let hash = store.put_blob(b"invalidate test")?;
1043            store.repack(0)?;
1044
1045            // Populate cache.
1046            assert!(store.has_blob_packed(&hash));
1047            assert!(pack_cache_loaded(&store)?);
1048
1049            // Invalidate.
1050            store.invalidate_pack_cache();
1051            assert!(!pack_cache_loaded(&store)?);
1052
1053            // Next access reloads from disk.
1054            assert!(store.has_blob_packed(&hash));
1055            assert!(pack_cache_loaded(&store)?);
1056            Ok(())
1057        }
1058    }
1059}