1use 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
29const BLOB_CACHE_CAPACITY: usize = 1024;
31
32#[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#[cfg(not(feature = "zstd"))]
44#[must_use]
45pub fn is_zstd_compressed(_data: &[u8]) -> bool {
46 false
47}
48
49pub struct BlobStore {
59 root: PathBuf,
61 compress: bool,
63 #[cfg_attr(not(feature = "zstd"), allow(dead_code))]
65 compression_level: i32,
66 verify_on_read: bool,
71 pack_cache: Mutex<Option<PackCache>>,
74 blob_cache: Mutex<Vec<(Hash, Vec<u8>)>>,
78 known_dirs: Mutex<HashSet<PathBuf>>,
82}
83
84impl BlobStore {
85 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 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 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 pub fn set_verify_on_read(&mut self, verify: bool) {
138 self.verify_on_read = verify;
139 }
140
141 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 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 if blob_path.exists() {
174 return Ok(hash);
175 }
176
177 if let Some(parent) = blob_path.parent() {
179 self.ensure_parent_dir(parent)?;
180 }
181
182 #[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 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 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 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 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 fn cache_blob(&self, hash: Hash, data: Vec<u8>) {
303 let Ok(mut cache) = self.blob_cache.lock() else {
306 return;
307 };
308 if cache.len() >= BLOB_CACHE_CAPACITY {
310 cache.pop();
311 }
312 cache.insert(0, (hash, data));
313 }
314
315 pub fn has_blob(&self, hash: &Hash) -> bool {
320 self.blob_path(hash).exists() || self.has_blob_packed(hash)
321 }
322
323 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 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 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 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 pub fn objects_dir(&self) -> PathBuf {
421 self.root.join("objects")
422 }
423
424 #[must_use]
429 pub fn root(&self) -> &std::path::Path {
430 &self.root
431 }
432
433 pub fn pack_dir(&self) -> PathBuf {
435 self.root.join("objects").join("pack")
436 }
437
438 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 #[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 pub fn invalidate_pack_cache(&self) {
476 if let Ok(mut guard) = self.pack_cache.lock() {
477 *guard = None;
478 }
479 }
480
481 pub fn get_blob_packed(&self, hash: &Hash) -> Result<Vec<u8>, CasError> {
483 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 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 pub fn list_blobs_packed(&self) -> Result<Vec<Hash>, CasError> {
505 self.with_pack_cache(PackCache::all_hashes)
506 }
507
508 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 let _ = self.delete_blob(hash);
536 }
537
538 self.invalidate_pack_cache();
540
541 Ok(loose_hashes.len())
542 }
543
544 fn blob_path(&self, hash: &Hash) -> PathBuf {
546 let hex = hash.to_hex();
547 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(store.has_blob_packed(&hash));
1022 assert!(
1024 pack_cache_loaded(&store)?,
1025 "pack cache should be populated after first access"
1026 );
1027
1028 assert!(store.has_blob_packed(&hash));
1030
1031 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 assert!(store.has_blob_packed(&hash));
1047 assert!(pack_cache_loaded(&store)?);
1048
1049 store.invalidate_pack_cache();
1051 assert!(!pack_cache_loaded(&store)?);
1052
1053 assert!(store.has_blob_packed(&hash));
1055 assert!(pack_cache_loaded(&store)?);
1056 Ok(())
1057 }
1058 }
1059}