1use async_trait::async_trait;
10use hashtree_core::store::{Store, StoreError, StoreStats};
11use hashtree_core::types::Hash;
12use std::collections::HashMap;
13use std::fs;
14use std::io::{Read, Seek, SeekFrom};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::RwLock;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20pub struct FsBlobStore {
27 base_path: PathBuf,
28 max_bytes: AtomicU64,
29 pins: RwLock<HashMap<String, u32>>,
31}
32
33impl FsBlobStore {
34 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
38 let base_path = path.as_ref().to_path_buf();
39 fs::create_dir_all(&base_path)?;
40
41 let pins = Self::load_pins(&base_path).unwrap_or_default();
43
44 Ok(Self {
45 base_path,
46 max_bytes: AtomicU64::new(0), pins: RwLock::new(pins),
48 })
49 }
50
51 pub fn with_max_bytes<P: AsRef<Path>>(path: P, max_bytes: u64) -> Result<Self, StoreError> {
53 let store = Self::new(path)?;
54 store.max_bytes.store(max_bytes, Ordering::Relaxed);
55 Ok(store)
56 }
57
58 fn pins_path(&self) -> PathBuf {
60 self.base_path.join("pins.json")
61 }
62
63 fn load_pins(base_path: &Path) -> Option<HashMap<String, u32>> {
65 let pins_path = base_path.join("pins.json");
66 let contents = fs::read_to_string(pins_path).ok()?;
67 serde_json::from_str(&contents).ok()
68 }
69
70 fn save_pins(&self) -> Result<(), StoreError> {
72 let pins = self.pins.read().unwrap();
73 let json = serde_json::to_string(&*pins)
74 .map_err(|e| StoreError::Other(format!("Failed to serialize pins: {}", e)))?;
75 fs::write(self.pins_path(), json)?;
76 Ok(())
77 }
78
79 fn blob_path_from_hex(&self, hash_hex: &str) -> PathBuf {
80 let (prefix, rest) = hash_hex.split_at(2);
81 let (subdir, filename) = rest.split_at(2);
82 self.base_path.join(prefix).join(subdir).join(filename)
83 }
84
85 fn legacy_blob_path(&self, hash: &Hash) -> PathBuf {
86 let hex = hex::encode(hash);
87 let (prefix, rest) = hex.split_at(2);
88 self.base_path.join(prefix).join(rest)
89 }
90
91 fn blob_path(&self, hash: &Hash) -> PathBuf {
95 self.blob_path_from_hex(&hex::encode(hash))
96 }
97
98 fn existing_blob_path(&self, hash: &Hash) -> Option<PathBuf> {
99 let primary = self.blob_path(hash);
100 if primary.exists() {
101 return Some(primary);
102 }
103
104 let legacy = self.legacy_blob_path(hash);
105 if legacy.exists() {
106 return Some(legacy);
107 }
108
109 None
110 }
111
112 fn hash_hex_for_blob_path(&self, path: &Path) -> Option<String> {
113 let relative = path.strip_prefix(&self.base_path).ok()?;
114 let mut hex = String::new();
115
116 for component in relative.iter() {
117 let part = component.to_str()?;
118 hex.push_str(part);
119 }
120
121 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
122 return None;
123 }
124
125 Some(hex.to_ascii_lowercase())
126 }
127
128 fn collect_blob_metadata_recursive(
129 &self,
130 dir: &Path,
131 blobs: &mut Vec<(PathBuf, String, fs::Metadata)>,
132 ) -> Result<(), StoreError> {
133 let entries = match fs::read_dir(dir) {
134 Ok(e) => e,
135 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
136 Err(e) => return Err(e.into()),
137 };
138
139 for entry in entries {
140 let entry = entry?;
141 let file_type = entry.file_type()?;
142 let path = entry.path();
143
144 if file_type.is_dir() {
145 self.collect_blob_metadata_recursive(&path, blobs)?;
146 continue;
147 }
148
149 if !file_type.is_file() {
150 continue;
151 }
152
153 if let Some(hex) = self.hash_hex_for_blob_path(&path) {
154 blobs.push((path, hex, entry.metadata()?));
155 }
156 }
157
158 Ok(())
159 }
160
161 fn collect_blob_metadata(&self) -> Result<Vec<(PathBuf, String, fs::Metadata)>, StoreError> {
162 let mut blobs = Vec::new();
163 self.collect_blob_metadata_recursive(&self.base_path, &mut blobs)?;
164 Ok(blobs)
165 }
166
167 pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
169 let path = self.blob_path(&hash);
170
171 if self.existing_blob_path(&hash).is_some() {
173 return Ok(false);
174 }
175
176 if let Some(parent) = path.parent() {
178 fs::create_dir_all(parent)?;
179 }
180
181 let temp_path = path.with_extension("tmp");
183 fs::write(&temp_path, data)?;
184 fs::rename(&temp_path, &path)?;
185
186 Ok(true)
187 }
188
189 pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
191 if let Some(path) = self.existing_blob_path(hash) {
192 Ok(Some(fs::read(&path)?))
193 } else {
194 Ok(None)
195 }
196 }
197
198 pub fn get_range_sync(
199 &self,
200 hash: &Hash,
201 start: u64,
202 end_inclusive: u64,
203 ) -> Result<Option<Vec<u8>>, StoreError> {
204 let Some(path) = self.existing_blob_path(hash) else {
205 return Ok(None);
206 };
207
208 let mut file = fs::File::open(path)?;
209 let len = file.metadata()?.len();
210 if len == 0 || start >= len || end_inclusive < start {
211 return Ok(Some(Vec::new()));
212 }
213
214 let actual_end = end_inclusive.min(len - 1);
215 let read_len = actual_end.saturating_sub(start).saturating_add(1);
216 let read_len = usize::try_from(read_len)
217 .map_err(|_| StoreError::Other("blob range is too large to read".to_string()))?;
218 let mut data = vec![0; read_len];
219 file.seek(SeekFrom::Start(start))?;
220 file.read_exact(&mut data)?;
221 Ok(Some(data))
222 }
223
224 pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
225 let Some(path) = self.existing_blob_path(hash) else {
226 return Ok(None);
227 };
228 Ok(Some(fs::metadata(path)?.len()))
229 }
230
231 pub fn touch_accessed_sync(&self, _hash: &Hash, _now: u64) -> Result<bool, StoreError> {
232 Ok(false)
233 }
234
235 pub fn touch_many_accessed_sync(
236 &self,
237 _hashes: &[Hash],
238 _now: u64,
239 ) -> Result<usize, StoreError> {
240 Ok(0)
241 }
242
243 pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
244 let Some(path) = self.existing_blob_path(hash) else {
245 return Ok(None);
246 };
247 let metadata = fs::metadata(path)?;
248 Ok(Some(system_time_to_unix_secs(
249 metadata.accessed().or_else(|_| metadata.modified())?,
250 )))
251 }
252
253 pub fn many_last_accessed_at_sync(
254 &self,
255 hashes: &[Hash],
256 ) -> Result<Vec<(Hash, u64)>, StoreError> {
257 let mut results = Vec::new();
258 for hash in hashes {
259 if let Some(last_accessed_at) = self.last_accessed_at_sync(hash)? {
260 results.push((*hash, last_accessed_at));
261 }
262 }
263 Ok(results)
264 }
265
266 pub fn exists(&self, hash: &Hash) -> bool {
268 self.existing_blob_path(hash).is_some()
269 }
270
271 pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
273 let primary = self.blob_path(hash);
274 let legacy = self.legacy_blob_path(hash);
275 let mut deleted = false;
276
277 for path in [primary, legacy] {
278 if path.exists() {
279 fs::remove_file(path)?;
280 deleted = true;
281 }
282 }
283
284 Ok(deleted)
285 }
286
287 pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
289 let mut hashes = Vec::new();
290
291 for (_, full_hex, _) in self.collect_blob_metadata()? {
292 if let Ok(bytes) = hex::decode(&full_hex) {
293 if bytes.len() == 32 {
294 let mut hash = [0u8; 32];
295 hash.copy_from_slice(&bytes);
296 hashes.push(hash);
297 }
298 }
299 }
300
301 Ok(hashes)
302 }
303
304 pub fn stats(&self) -> Result<FsStats, StoreError> {
306 let pins = self.pins.read().unwrap();
307 let mut count = 0usize;
308 let mut total_bytes = 0u64;
309 let mut pinned_count = 0usize;
310 let mut pinned_bytes = 0u64;
311
312 for (_, hex, metadata) in self.collect_blob_metadata()? {
313 let size = metadata.len();
314 count += 1;
315 total_bytes += size;
316
317 if pins.get(&hex).copied().unwrap_or(0) > 0 {
318 pinned_count += 1;
319 pinned_bytes += size;
320 }
321 }
322
323 Ok(FsStats {
324 count,
325 total_bytes,
326 pinned_count,
327 pinned_bytes,
328 })
329 }
330
331 fn collect_blobs_for_eviction(&self) -> Vec<(PathBuf, String, SystemTime, u64)> {
333 self.collect_blob_metadata()
334 .map(|blobs| {
335 blobs
336 .into_iter()
337 .map(|(path, hex, metadata)| {
338 let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
339 let size = metadata.len();
340 (path, hex, mtime, size)
341 })
342 .collect()
343 })
344 .unwrap_or_default()
345 }
346
347 fn evict_to_target(&self, target_bytes: u64) -> u64 {
349 let pins = self.pins.read().unwrap();
350
351 let mut blobs = self.collect_blobs_for_eviction();
353
354 blobs.retain(|(_, hex, _, _)| pins.get(hex).copied().unwrap_or(0) == 0);
356
357 blobs.sort_by_key(|(_, _, mtime, _)| *mtime);
359
360 drop(pins); let current_bytes: u64 = self
364 .collect_blobs_for_eviction()
365 .iter()
366 .map(|(_, _, _, size)| *size)
367 .sum();
368
369 if current_bytes <= target_bytes {
370 return 0;
371 }
372
373 let to_free = current_bytes - target_bytes;
374 let mut freed = 0u64;
375
376 for (path, _, _, size) in blobs {
377 if freed >= to_free {
378 break;
379 }
380 if fs::remove_file(&path).is_ok() {
381 freed += size;
382 }
383 }
384
385 freed
386 }
387}
388
389fn system_time_to_unix_secs(time: SystemTime) -> u64 {
390 time.duration_since(UNIX_EPOCH)
391 .unwrap_or_default()
392 .as_secs()
393}
394
395#[derive(Debug, Clone)]
397pub struct FsStats {
398 pub count: usize,
399 pub total_bytes: u64,
400 pub pinned_count: usize,
401 pub pinned_bytes: u64,
402}
403
404#[async_trait]
405impl Store for FsBlobStore {
406 async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
407 self.put_sync(hash, &data)
408 }
409
410 async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
411 self.get_sync(hash)
412 }
413
414 async fn get_range(
415 &self,
416 hash: &Hash,
417 start: u64,
418 end_inclusive: u64,
419 ) -> Result<Option<Vec<u8>>, StoreError> {
420 self.get_range_sync(hash, start, end_inclusive)
421 }
422
423 async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
424 self.blob_size_sync(hash)
425 }
426
427 async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
428 Ok(self.exists(hash))
429 }
430
431 async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
432 let hex = hex::encode(hash);
433 {
435 let mut pins = self.pins.write().unwrap();
436 pins.remove(&hex);
437 }
438 let _ = self.save_pins(); self.delete_sync(hash)
440 }
441
442 fn set_max_bytes(&self, max: u64) {
443 self.max_bytes.store(max, Ordering::Relaxed);
444 }
445
446 fn max_bytes(&self) -> Option<u64> {
447 let max = self.max_bytes.load(Ordering::Relaxed);
448 if max > 0 {
449 Some(max)
450 } else {
451 None
452 }
453 }
454
455 async fn stats(&self) -> StoreStats {
456 match self.stats() {
457 Ok(fs_stats) => StoreStats {
458 count: fs_stats.count as u64,
459 bytes: fs_stats.total_bytes,
460 pinned_count: fs_stats.pinned_count as u64,
461 pinned_bytes: fs_stats.pinned_bytes,
462 },
463 Err(_) => StoreStats::default(),
464 }
465 }
466
467 async fn evict_if_needed(&self) -> Result<u64, StoreError> {
468 let max = self.max_bytes.load(Ordering::Relaxed);
469 if max == 0 {
470 return Ok(0); }
472
473 let current = match self.stats() {
474 Ok(s) => s.total_bytes,
475 Err(_) => return Ok(0),
476 };
477
478 if current <= max {
479 return Ok(0);
480 }
481
482 let target = max * 9 / 10;
484 Ok(self.evict_to_target(target))
485 }
486
487 async fn pin(&self, hash: &Hash) -> Result<(), StoreError> {
488 let hex = hex::encode(hash);
489 {
490 let mut pins = self.pins.write().unwrap();
491 *pins.entry(hex).or_insert(0) += 1;
492 }
493 self.save_pins()
494 }
495
496 async fn unpin(&self, hash: &Hash) -> Result<(), StoreError> {
497 let hex = hex::encode(hash);
498 {
499 let mut pins = self.pins.write().unwrap();
500 if let Some(count) = pins.get_mut(&hex) {
501 if *count > 0 {
502 *count -= 1;
503 }
504 if *count == 0 {
505 pins.remove(&hex);
506 }
507 }
508 }
509 self.save_pins()
510 }
511
512 fn pin_count(&self, hash: &Hash) -> u32 {
513 let hex = hex::encode(hash);
514 self.pins.read().unwrap().get(&hex).copied().unwrap_or(0)
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use hashtree_core::sha256;
522 use tempfile::TempDir;
523
524 #[tokio::test]
525 async fn test_put_get() {
526 let temp = TempDir::new().unwrap();
527 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
528
529 let data = b"hello filesystem";
530 let hash = sha256(data);
531 store.put(hash, data.to_vec()).await.unwrap();
532
533 assert!(store.has(&hash).await.unwrap());
534 assert_eq!(store.get(&hash).await.unwrap(), Some(data.to_vec()));
535 }
536
537 #[tokio::test]
538 async fn test_get_missing() {
539 let temp = TempDir::new().unwrap();
540 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
541
542 let hash = [0u8; 32];
543 assert!(!store.has(&hash).await.unwrap());
544 assert_eq!(store.get(&hash).await.unwrap(), None);
545 }
546
547 #[tokio::test]
548 async fn test_delete() {
549 let temp = TempDir::new().unwrap();
550 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
551
552 let data = b"delete me";
553 let hash = sha256(data);
554 store.put(hash, data.to_vec()).await.unwrap();
555 assert!(store.has(&hash).await.unwrap());
556
557 assert!(store.delete(&hash).await.unwrap());
558 assert!(!store.has(&hash).await.unwrap());
559 assert!(!store.delete(&hash).await.unwrap());
560 }
561
562 #[tokio::test]
563 async fn test_deduplication() {
564 let temp = TempDir::new().unwrap();
565 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
566
567 let data = b"same content";
568 let hash = sha256(data);
569
570 assert!(store.put(hash, data.to_vec()).await.unwrap());
572 assert!(!store.put(hash, data.to_vec()).await.unwrap());
574
575 assert_eq!(store.list().unwrap().len(), 1);
576 }
577
578 #[tokio::test]
579 async fn test_list() {
580 let temp = TempDir::new().unwrap();
581 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
582
583 let d1 = b"one";
584 let d2 = b"two";
585 let d3 = b"three";
586 let h1 = sha256(d1);
587 let h2 = sha256(d2);
588 let h3 = sha256(d3);
589
590 store.put(h1, d1.to_vec()).await.unwrap();
591 store.put(h2, d2.to_vec()).await.unwrap();
592 store.put(h3, d3.to_vec()).await.unwrap();
593
594 let hashes = store.list().unwrap();
595 assert_eq!(hashes.len(), 3);
596 assert!(hashes.contains(&h1));
597 assert!(hashes.contains(&h2));
598 assert!(hashes.contains(&h3));
599 }
600
601 #[tokio::test]
602 async fn test_stats() {
603 let temp = TempDir::new().unwrap();
604 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
605
606 let d1 = b"hello";
607 let d2 = b"world";
608 let h1 = sha256(d1);
609 store.put(h1, d1.to_vec()).await.unwrap();
610 store.put(sha256(d2), d2.to_vec()).await.unwrap();
611
612 let stats = store.stats().unwrap();
613 assert_eq!(stats.count, 2);
614 assert_eq!(stats.total_bytes, 10);
615 assert_eq!(stats.pinned_count, 0);
616 assert_eq!(stats.pinned_bytes, 0);
617
618 store.pin(&h1).await.unwrap();
620 let stats = store.stats().unwrap();
621 assert_eq!(stats.pinned_count, 1);
622 assert_eq!(stats.pinned_bytes, 5);
623 }
624
625 #[tokio::test]
626 async fn test_directory_structure() {
627 let temp = TempDir::new().unwrap();
628 let blobs_path = temp.path().join("blobs");
629 let store = FsBlobStore::new(&blobs_path).unwrap();
630
631 let data = b"test data";
632 let hash = sha256(data);
633 let hex = hex::encode(hash);
634
635 store.put(hash, data.to_vec()).await.unwrap();
636
637 let prefix = &hex[..2];
639 let subdir = &hex[2..4];
640 let rest = &hex[4..];
641 let expected_path = blobs_path.join(prefix).join(subdir).join(rest);
642
643 assert!(
644 expected_path.exists(),
645 "Blob should be at {:?}",
646 expected_path
647 );
648 assert_eq!(fs::read(&expected_path).unwrap(), data);
649 }
650
651 #[test]
652 fn test_blob_path_format() {
653 let temp = TempDir::new().unwrap();
654 let store = FsBlobStore::new(temp.path()).unwrap();
655
656 let mut hash = [0u8; 32];
658 hash[0] = 0x00;
659 hash[1] = 0x11;
660 hash[2] = 0x22;
661
662 let path = store.blob_path(&hash);
663 let path_str = path.to_string_lossy();
664
665 assert!(
667 path_str.contains("/00/"),
668 "Path should contain /00/ directory: {}",
669 path_str
670 );
671 assert!(
673 path_str.contains("/11/"),
674 "Path should contain /11/ directory: {}",
675 path_str
676 );
677 assert!(path.file_name().unwrap().len() == 60);
679 }
680
681 #[tokio::test]
682 async fn test_legacy_single_level_layout_remains_readable() {
683 let temp = TempDir::new().unwrap();
684 let blobs_path = temp.path().join("blobs");
685 let store = FsBlobStore::new(&blobs_path).unwrap();
686
687 let data = b"legacy blob";
688 let hash = sha256(data);
689 let hex = hex::encode(hash);
690 let legacy_path = blobs_path.join(&hex[..2]).join(&hex[2..]);
691 fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
692 fs::write(&legacy_path, data).unwrap();
693
694 assert!(store.has(&hash).await.unwrap());
695 assert_eq!(store.get(&hash).await.unwrap(), Some(data.to_vec()));
696
697 let listed = store.list().unwrap();
698 assert_eq!(listed, vec![hash]);
699
700 let stats = store.stats().unwrap();
701 assert_eq!(stats.count, 1);
702 assert_eq!(stats.total_bytes, data.len() as u64);
703
704 assert!(!store.put(hash, data.to_vec()).await.unwrap());
705 assert!(store.delete(&hash).await.unwrap());
706 assert!(!legacy_path.exists());
707 }
708
709 #[tokio::test]
710 async fn test_empty_store_stats() {
711 let temp = TempDir::new().unwrap();
712 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
713
714 let stats = store.stats().unwrap();
715 assert_eq!(stats.count, 0);
716 assert_eq!(stats.total_bytes, 0);
717 }
718
719 #[tokio::test]
720 async fn test_empty_store_list() {
721 let temp = TempDir::new().unwrap();
722 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
723
724 let hashes = store.list().unwrap();
725 assert!(hashes.is_empty());
726 }
727
728 #[tokio::test]
729 async fn test_pin_and_unpin() {
730 let temp = TempDir::new().unwrap();
731 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
732
733 let data = b"pin me";
734 let hash = sha256(data);
735 store.put(hash, data.to_vec()).await.unwrap();
736
737 assert!(!store.is_pinned(&hash));
739 assert_eq!(store.pin_count(&hash), 0);
740
741 store.pin(&hash).await.unwrap();
743 assert!(store.is_pinned(&hash));
744 assert_eq!(store.pin_count(&hash), 1);
745
746 store.unpin(&hash).await.unwrap();
748 assert!(!store.is_pinned(&hash));
749 assert_eq!(store.pin_count(&hash), 0);
750 }
751
752 #[tokio::test]
753 async fn test_pin_ref_counting() {
754 let temp = TempDir::new().unwrap();
755 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
756
757 let data = b"multi pin";
758 let hash = sha256(data);
759 store.put(hash, data.to_vec()).await.unwrap();
760
761 store.pin(&hash).await.unwrap();
763 store.pin(&hash).await.unwrap();
764 store.pin(&hash).await.unwrap();
765 assert_eq!(store.pin_count(&hash), 3);
766
767 store.unpin(&hash).await.unwrap();
769 assert_eq!(store.pin_count(&hash), 2);
770 assert!(store.is_pinned(&hash));
771
772 store.unpin(&hash).await.unwrap();
774 store.unpin(&hash).await.unwrap();
775 assert_eq!(store.pin_count(&hash), 0);
776 }
777
778 #[tokio::test]
779 async fn test_pins_persist_across_reload() {
780 let temp = TempDir::new().unwrap();
781 let blobs_path = temp.path().join("blobs");
782
783 let data = b"persist me";
784 let hash = sha256(data);
785
786 {
788 let store = FsBlobStore::new(&blobs_path).unwrap();
789 store.put(hash, data.to_vec()).await.unwrap();
790 store.pin(&hash).await.unwrap();
791 store.pin(&hash).await.unwrap();
792 assert_eq!(store.pin_count(&hash), 2);
793 }
794
795 {
797 let store = FsBlobStore::new(&blobs_path).unwrap();
798 assert_eq!(store.pin_count(&hash), 2);
799 assert!(store.is_pinned(&hash));
800 }
801 }
802
803 #[tokio::test]
804 async fn test_max_bytes() {
805 let temp = TempDir::new().unwrap();
806 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
807
808 assert!(store.max_bytes().is_none());
809
810 store.set_max_bytes(1000);
811 assert_eq!(store.max_bytes(), Some(1000));
812
813 store.set_max_bytes(0);
814 assert!(store.max_bytes().is_none());
815 }
816
817 #[tokio::test]
818 async fn test_with_max_bytes() {
819 let temp = TempDir::new().unwrap();
820 let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 500).unwrap();
821 assert_eq!(store.max_bytes(), Some(500));
822 }
823
824 #[tokio::test]
825 async fn test_eviction_respects_pins() {
826 let temp = TempDir::new().unwrap();
827 let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 20).unwrap();
829
830 let d1 = b"aaaaa"; let d2 = b"bbbbb";
833 let d3 = b"ccccc";
834 let h1 = sha256(d1);
835 let h2 = sha256(d2);
836 let h3 = sha256(d3);
837
838 store.put(h1, d1.to_vec()).await.unwrap();
839 std::thread::sleep(std::time::Duration::from_millis(10)); store.put(h2, d2.to_vec()).await.unwrap();
841 std::thread::sleep(std::time::Duration::from_millis(10));
842 store.put(h3, d3.to_vec()).await.unwrap();
843
844 store.pin(&h1).await.unwrap();
846
847 let d4 = b"ddddd";
849 let h4 = sha256(d4);
850 std::thread::sleep(std::time::Duration::from_millis(10));
851 store.put(h4, d4.to_vec()).await.unwrap();
852
853 let d5 = b"eeeee";
855 let h5 = sha256(d5);
856 std::thread::sleep(std::time::Duration::from_millis(10));
857 store.put(h5, d5.to_vec()).await.unwrap();
858
859 let freed = store.evict_if_needed().await.unwrap();
861 assert!(freed > 0, "Should have freed some bytes");
862
863 assert!(store.has(&h1).await.unwrap(), "Pinned item should exist");
865 assert!(
867 !store.has(&h2).await.unwrap(),
868 "Oldest unpinned should be evicted"
869 );
870 assert!(store.has(&h5).await.unwrap(), "Newest should exist");
872 }
873
874 #[tokio::test]
875 async fn test_no_eviction_when_under_limit() {
876 let temp = TempDir::new().unwrap();
877 let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 1000).unwrap();
878
879 let data = b"small";
880 let hash = sha256(data);
881 store.put(hash, data.to_vec()).await.unwrap();
882
883 let freed = store.evict_if_needed().await.unwrap();
884 assert_eq!(freed, 0);
885 assert!(store.has(&hash).await.unwrap());
886 }
887
888 #[tokio::test]
889 async fn test_no_eviction_without_limit() {
890 let temp = TempDir::new().unwrap();
891 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
892
893 for i in 0..10u8 {
894 let data = vec![i; 100];
895 let hash = sha256(&data);
896 store.put(hash, data).await.unwrap();
897 }
898
899 let freed = store.evict_if_needed().await.unwrap();
900 assert_eq!(freed, 0);
901 assert_eq!(store.list().unwrap().len(), 10);
902 }
903
904 #[tokio::test]
905 async fn test_delete_removes_pin() {
906 let temp = TempDir::new().unwrap();
907 let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
908
909 let data = b"delete pinned";
910 let hash = sha256(data);
911 store.put(hash, data.to_vec()).await.unwrap();
912 store.pin(&hash).await.unwrap();
913 assert!(store.is_pinned(&hash));
914
915 store.delete(&hash).await.unwrap();
916 assert_eq!(store.pin_count(&hash), 0);
917 }
918}