Skip to main content

hashtree_fs/
lib.rs

1//! Filesystem-based content-addressed blob storage.
2//!
3//! Stores blobs in a directory structure similar to git:
4//! `{base_path}/{first 2 chars of hash}/{next 2 chars}/{remaining hash chars}`
5//!
6//! For example, a blob with hash `abcdef123...` would be stored at:
7//! `~/.hashtree/blobs/ab/cd/ef123...`
8
9use 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::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::RwLock;
17use std::time::SystemTime;
18
19/// Filesystem-backed blob store implementing hashtree's Store trait.
20///
21/// Stores blobs in a two-level sharded directory structure using
22/// the first 4 hex characters of the hash as directory prefixes.
23/// Legacy single-level shard paths remain readable.
24/// Supports storage limits with mtime-based FIFO eviction and pinning.
25pub struct FsBlobStore {
26    base_path: PathBuf,
27    max_bytes: AtomicU64,
28    /// Pin counts stored in memory, persisted to pins.json
29    pins: RwLock<HashMap<String, u32>>,
30}
31
32impl FsBlobStore {
33    /// Create a new filesystem blob store at the given path.
34    ///
35    /// Creates the directory if it doesn't exist.
36    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
37        let base_path = path.as_ref().to_path_buf();
38        fs::create_dir_all(&base_path)?;
39
40        // Load existing pins from disk
41        let pins = Self::load_pins(&base_path).unwrap_or_default();
42
43        Ok(Self {
44            base_path,
45            max_bytes: AtomicU64::new(0), // 0 = unlimited
46            pins: RwLock::new(pins),
47        })
48    }
49
50    /// Create a new store with a maximum size limit
51    pub fn with_max_bytes<P: AsRef<Path>>(path: P, max_bytes: u64) -> Result<Self, StoreError> {
52        let store = Self::new(path)?;
53        store.max_bytes.store(max_bytes, Ordering::Relaxed);
54        Ok(store)
55    }
56
57    /// Path to pins.json file
58    fn pins_path(&self) -> PathBuf {
59        self.base_path.join("pins.json")
60    }
61
62    /// Load pins from disk
63    fn load_pins(base_path: &Path) -> Option<HashMap<String, u32>> {
64        let pins_path = base_path.join("pins.json");
65        let contents = fs::read_to_string(pins_path).ok()?;
66        serde_json::from_str(&contents).ok()
67    }
68
69    /// Save pins to disk
70    fn save_pins(&self) -> Result<(), StoreError> {
71        let pins = self.pins.read().unwrap();
72        let json = serde_json::to_string(&*pins)
73            .map_err(|e| StoreError::Other(format!("Failed to serialize pins: {}", e)))?;
74        fs::write(self.pins_path(), json)?;
75        Ok(())
76    }
77
78    fn blob_path_from_hex(&self, hash_hex: &str) -> PathBuf {
79        let (prefix, rest) = hash_hex.split_at(2);
80        let (subdir, filename) = rest.split_at(2);
81        self.base_path.join(prefix).join(subdir).join(filename)
82    }
83
84    fn legacy_blob_path(&self, hash: &Hash) -> PathBuf {
85        let hex = hex::encode(hash);
86        let (prefix, rest) = hex.split_at(2);
87        self.base_path.join(prefix).join(rest)
88    }
89
90    /// Get the file path for a given hash.
91    ///
92    /// Format: `{base_path}/{first 2 hex chars}/{next 2 hex chars}/{remaining 60 hex chars}`
93    fn blob_path(&self, hash: &Hash) -> PathBuf {
94        self.blob_path_from_hex(&hex::encode(hash))
95    }
96
97    fn existing_blob_path(&self, hash: &Hash) -> Option<PathBuf> {
98        let primary = self.blob_path(hash);
99        if primary.exists() {
100            return Some(primary);
101        }
102
103        let legacy = self.legacy_blob_path(hash);
104        if legacy.exists() {
105            return Some(legacy);
106        }
107
108        None
109    }
110
111    fn hash_hex_for_blob_path(&self, path: &Path) -> Option<String> {
112        let relative = path.strip_prefix(&self.base_path).ok()?;
113        let mut hex = String::new();
114
115        for component in relative.iter() {
116            let part = component.to_str()?;
117            hex.push_str(part);
118        }
119
120        if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
121            return None;
122        }
123
124        Some(hex.to_ascii_lowercase())
125    }
126
127    fn collect_blob_metadata_recursive(
128        &self,
129        dir: &Path,
130        blobs: &mut Vec<(PathBuf, String, fs::Metadata)>,
131    ) -> Result<(), StoreError> {
132        let entries = match fs::read_dir(dir) {
133            Ok(e) => e,
134            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
135            Err(e) => return Err(e.into()),
136        };
137
138        for entry in entries {
139            let entry = entry?;
140            let file_type = entry.file_type()?;
141            let path = entry.path();
142
143            if file_type.is_dir() {
144                self.collect_blob_metadata_recursive(&path, blobs)?;
145                continue;
146            }
147
148            if !file_type.is_file() {
149                continue;
150            }
151
152            if let Some(hex) = self.hash_hex_for_blob_path(&path) {
153                blobs.push((path, hex, entry.metadata()?));
154            }
155        }
156
157        Ok(())
158    }
159
160    fn collect_blob_metadata(&self) -> Result<Vec<(PathBuf, String, fs::Metadata)>, StoreError> {
161        let mut blobs = Vec::new();
162        self.collect_blob_metadata_recursive(&self.base_path, &mut blobs)?;
163        Ok(blobs)
164    }
165
166    /// Sync put operation.
167    pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
168        let path = self.blob_path(&hash);
169
170        // Check if already exists
171        if self.existing_blob_path(&hash).is_some() {
172            return Ok(false);
173        }
174
175        // Create parent directory if needed
176        if let Some(parent) = path.parent() {
177            fs::create_dir_all(parent)?;
178        }
179
180        // Write atomically using temp file + rename
181        let temp_path = path.with_extension("tmp");
182        fs::write(&temp_path, data)?;
183        fs::rename(&temp_path, &path)?;
184
185        Ok(true)
186    }
187
188    /// Sync get operation.
189    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
190        if let Some(path) = self.existing_blob_path(hash) {
191            Ok(Some(fs::read(&path)?))
192        } else {
193            Ok(None)
194        }
195    }
196
197    /// Check if a hash exists.
198    pub fn exists(&self, hash: &Hash) -> bool {
199        self.existing_blob_path(hash).is_some()
200    }
201
202    /// Sync delete operation.
203    pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
204        let primary = self.blob_path(hash);
205        let legacy = self.legacy_blob_path(hash);
206        let mut deleted = false;
207
208        for path in [primary, legacy] {
209            if path.exists() {
210                fs::remove_file(path)?;
211                deleted = true;
212            }
213        }
214
215        Ok(deleted)
216    }
217
218    /// List all hashes in the store.
219    pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
220        let mut hashes = Vec::new();
221
222        for (_, full_hex, _) in self.collect_blob_metadata()? {
223            if let Ok(bytes) = hex::decode(&full_hex) {
224                if bytes.len() == 32 {
225                    let mut hash = [0u8; 32];
226                    hash.copy_from_slice(&bytes);
227                    hashes.push(hash);
228                }
229            }
230        }
231
232        Ok(hashes)
233    }
234
235    /// Get storage statistics.
236    pub fn stats(&self) -> Result<FsStats, StoreError> {
237        let pins = self.pins.read().unwrap();
238        let mut count = 0usize;
239        let mut total_bytes = 0u64;
240        let mut pinned_count = 0usize;
241        let mut pinned_bytes = 0u64;
242
243        for (_, hex, metadata) in self.collect_blob_metadata()? {
244            let size = metadata.len();
245            count += 1;
246            total_bytes += size;
247
248            if pins.get(&hex).copied().unwrap_or(0) > 0 {
249                pinned_count += 1;
250                pinned_bytes += size;
251            }
252        }
253
254        Ok(FsStats {
255            count,
256            total_bytes,
257            pinned_count,
258            pinned_bytes,
259        })
260    }
261
262    /// Collect all blobs with their mtime and size for eviction
263    fn collect_blobs_for_eviction(&self) -> Vec<(PathBuf, String, SystemTime, u64)> {
264        self.collect_blob_metadata()
265            .map(|blobs| {
266                blobs
267                    .into_iter()
268                    .map(|(path, hex, metadata)| {
269                        let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
270                        let size = metadata.len();
271                        (path, hex, mtime, size)
272                    })
273                    .collect()
274            })
275            .unwrap_or_default()
276    }
277
278    /// Evict unpinned blobs until storage is under target_bytes
279    fn evict_to_target(&self, target_bytes: u64) -> u64 {
280        let pins = self.pins.read().unwrap();
281
282        // Collect all blobs
283        let mut blobs = self.collect_blobs_for_eviction();
284
285        // Filter to unpinned only
286        blobs.retain(|(_, hex, _, _)| pins.get(hex).copied().unwrap_or(0) == 0);
287
288        // Sort by mtime (oldest first)
289        blobs.sort_by_key(|(_, _, mtime, _)| *mtime);
290
291        drop(pins); // Release lock before deleting
292
293        // Calculate current total
294        let current_bytes: u64 = self
295            .collect_blobs_for_eviction()
296            .iter()
297            .map(|(_, _, _, size)| *size)
298            .sum();
299
300        if current_bytes <= target_bytes {
301            return 0;
302        }
303
304        let to_free = current_bytes - target_bytes;
305        let mut freed = 0u64;
306
307        for (path, _, _, size) in blobs {
308            if freed >= to_free {
309                break;
310            }
311            if fs::remove_file(&path).is_ok() {
312                freed += size;
313            }
314        }
315
316        freed
317    }
318}
319
320/// Storage statistics.
321#[derive(Debug, Clone)]
322pub struct FsStats {
323    pub count: usize,
324    pub total_bytes: u64,
325    pub pinned_count: usize,
326    pub pinned_bytes: u64,
327}
328
329#[async_trait]
330impl Store for FsBlobStore {
331    async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
332        self.put_sync(hash, &data)
333    }
334
335    async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
336        self.get_sync(hash)
337    }
338
339    async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
340        Ok(self.exists(hash))
341    }
342
343    async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
344        let hex = hex::encode(hash);
345        // Remove pin entry if exists
346        {
347            let mut pins = self.pins.write().unwrap();
348            pins.remove(&hex);
349        }
350        let _ = self.save_pins(); // Best effort
351        self.delete_sync(hash)
352    }
353
354    fn set_max_bytes(&self, max: u64) {
355        self.max_bytes.store(max, Ordering::Relaxed);
356    }
357
358    fn max_bytes(&self) -> Option<u64> {
359        let max = self.max_bytes.load(Ordering::Relaxed);
360        if max > 0 {
361            Some(max)
362        } else {
363            None
364        }
365    }
366
367    async fn stats(&self) -> StoreStats {
368        match self.stats() {
369            Ok(fs_stats) => StoreStats {
370                count: fs_stats.count as u64,
371                bytes: fs_stats.total_bytes,
372                pinned_count: fs_stats.pinned_count as u64,
373                pinned_bytes: fs_stats.pinned_bytes,
374            },
375            Err(_) => StoreStats::default(),
376        }
377    }
378
379    async fn evict_if_needed(&self) -> Result<u64, StoreError> {
380        let max = self.max_bytes.load(Ordering::Relaxed);
381        if max == 0 {
382            return Ok(0); // No limit set
383        }
384
385        let current = match self.stats() {
386            Ok(s) => s.total_bytes,
387            Err(_) => return Ok(0),
388        };
389
390        if current <= max {
391            return Ok(0);
392        }
393
394        // Evict to 90% of max
395        let target = max * 9 / 10;
396        Ok(self.evict_to_target(target))
397    }
398
399    async fn pin(&self, hash: &Hash) -> Result<(), StoreError> {
400        let hex = hex::encode(hash);
401        {
402            let mut pins = self.pins.write().unwrap();
403            *pins.entry(hex).or_insert(0) += 1;
404        }
405        self.save_pins()
406    }
407
408    async fn unpin(&self, hash: &Hash) -> Result<(), StoreError> {
409        let hex = hex::encode(hash);
410        {
411            let mut pins = self.pins.write().unwrap();
412            if let Some(count) = pins.get_mut(&hex) {
413                if *count > 0 {
414                    *count -= 1;
415                }
416                if *count == 0 {
417                    pins.remove(&hex);
418                }
419            }
420        }
421        self.save_pins()
422    }
423
424    fn pin_count(&self, hash: &Hash) -> u32 {
425        let hex = hex::encode(hash);
426        self.pins.read().unwrap().get(&hex).copied().unwrap_or(0)
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use hashtree_core::sha256;
434    use tempfile::TempDir;
435
436    #[tokio::test]
437    async fn test_put_get() {
438        let temp = TempDir::new().unwrap();
439        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
440
441        let data = b"hello filesystem";
442        let hash = sha256(data);
443        store.put(hash, data.to_vec()).await.unwrap();
444
445        assert!(store.has(&hash).await.unwrap());
446        assert_eq!(store.get(&hash).await.unwrap(), Some(data.to_vec()));
447    }
448
449    #[tokio::test]
450    async fn test_get_missing() {
451        let temp = TempDir::new().unwrap();
452        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
453
454        let hash = [0u8; 32];
455        assert!(!store.has(&hash).await.unwrap());
456        assert_eq!(store.get(&hash).await.unwrap(), None);
457    }
458
459    #[tokio::test]
460    async fn test_delete() {
461        let temp = TempDir::new().unwrap();
462        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
463
464        let data = b"delete me";
465        let hash = sha256(data);
466        store.put(hash, data.to_vec()).await.unwrap();
467        assert!(store.has(&hash).await.unwrap());
468
469        assert!(store.delete(&hash).await.unwrap());
470        assert!(!store.has(&hash).await.unwrap());
471        assert!(!store.delete(&hash).await.unwrap());
472    }
473
474    #[tokio::test]
475    async fn test_deduplication() {
476        let temp = TempDir::new().unwrap();
477        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
478
479        let data = b"same content";
480        let hash = sha256(data);
481
482        // First put returns true (newly stored)
483        assert!(store.put(hash, data.to_vec()).await.unwrap());
484        // Second put returns false (already existed)
485        assert!(!store.put(hash, data.to_vec()).await.unwrap());
486
487        assert_eq!(store.list().unwrap().len(), 1);
488    }
489
490    #[tokio::test]
491    async fn test_list() {
492        let temp = TempDir::new().unwrap();
493        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
494
495        let d1 = b"one";
496        let d2 = b"two";
497        let d3 = b"three";
498        let h1 = sha256(d1);
499        let h2 = sha256(d2);
500        let h3 = sha256(d3);
501
502        store.put(h1, d1.to_vec()).await.unwrap();
503        store.put(h2, d2.to_vec()).await.unwrap();
504        store.put(h3, d3.to_vec()).await.unwrap();
505
506        let hashes = store.list().unwrap();
507        assert_eq!(hashes.len(), 3);
508        assert!(hashes.contains(&h1));
509        assert!(hashes.contains(&h2));
510        assert!(hashes.contains(&h3));
511    }
512
513    #[tokio::test]
514    async fn test_stats() {
515        let temp = TempDir::new().unwrap();
516        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
517
518        let d1 = b"hello";
519        let d2 = b"world";
520        let h1 = sha256(d1);
521        store.put(h1, d1.to_vec()).await.unwrap();
522        store.put(sha256(d2), d2.to_vec()).await.unwrap();
523
524        let stats = store.stats().unwrap();
525        assert_eq!(stats.count, 2);
526        assert_eq!(stats.total_bytes, 10);
527        assert_eq!(stats.pinned_count, 0);
528        assert_eq!(stats.pinned_bytes, 0);
529
530        // Pin one item and check stats
531        store.pin(&h1).await.unwrap();
532        let stats = store.stats().unwrap();
533        assert_eq!(stats.pinned_count, 1);
534        assert_eq!(stats.pinned_bytes, 5);
535    }
536
537    #[tokio::test]
538    async fn test_directory_structure() {
539        let temp = TempDir::new().unwrap();
540        let blobs_path = temp.path().join("blobs");
541        let store = FsBlobStore::new(&blobs_path).unwrap();
542
543        let data = b"test data";
544        let hash = sha256(data);
545        let hex = hex::encode(hash);
546
547        store.put(hash, data.to_vec()).await.unwrap();
548
549        // Verify the file exists at the correct path
550        let prefix = &hex[..2];
551        let subdir = &hex[2..4];
552        let rest = &hex[4..];
553        let expected_path = blobs_path.join(prefix).join(subdir).join(rest);
554
555        assert!(
556            expected_path.exists(),
557            "Blob should be at {:?}",
558            expected_path
559        );
560        assert_eq!(fs::read(&expected_path).unwrap(), data);
561    }
562
563    #[test]
564    fn test_blob_path_format() {
565        let temp = TempDir::new().unwrap();
566        let store = FsBlobStore::new(temp.path()).unwrap();
567
568        // Hash: 0x00112233...
569        let mut hash = [0u8; 32];
570        hash[0] = 0x00;
571        hash[1] = 0x11;
572        hash[2] = 0x22;
573
574        let path = store.blob_path(&hash);
575        let path_str = path.to_string_lossy();
576
577        // Should have "00" as directory prefix
578        assert!(
579            path_str.contains("/00/"),
580            "Path should contain /00/ directory: {}",
581            path_str
582        );
583        // Should also include a second shard level based on the next byte.
584        assert!(
585            path_str.contains("/11/"),
586            "Path should contain /11/ directory: {}",
587            path_str
588        );
589        // File name should be remaining 60 chars
590        assert!(path.file_name().unwrap().len() == 60);
591    }
592
593    #[tokio::test]
594    async fn test_legacy_single_level_layout_remains_readable() {
595        let temp = TempDir::new().unwrap();
596        let blobs_path = temp.path().join("blobs");
597        let store = FsBlobStore::new(&blobs_path).unwrap();
598
599        let data = b"legacy blob";
600        let hash = sha256(data);
601        let hex = hex::encode(hash);
602        let legacy_path = blobs_path.join(&hex[..2]).join(&hex[2..]);
603        fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
604        fs::write(&legacy_path, data).unwrap();
605
606        assert!(store.has(&hash).await.unwrap());
607        assert_eq!(store.get(&hash).await.unwrap(), Some(data.to_vec()));
608
609        let listed = store.list().unwrap();
610        assert_eq!(listed, vec![hash]);
611
612        let stats = store.stats().unwrap();
613        assert_eq!(stats.count, 1);
614        assert_eq!(stats.total_bytes, data.len() as u64);
615
616        assert!(!store.put(hash, data.to_vec()).await.unwrap());
617        assert!(store.delete(&hash).await.unwrap());
618        assert!(!legacy_path.exists());
619    }
620
621    #[tokio::test]
622    async fn test_empty_store_stats() {
623        let temp = TempDir::new().unwrap();
624        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
625
626        let stats = store.stats().unwrap();
627        assert_eq!(stats.count, 0);
628        assert_eq!(stats.total_bytes, 0);
629    }
630
631    #[tokio::test]
632    async fn test_empty_store_list() {
633        let temp = TempDir::new().unwrap();
634        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
635
636        let hashes = store.list().unwrap();
637        assert!(hashes.is_empty());
638    }
639
640    #[tokio::test]
641    async fn test_pin_and_unpin() {
642        let temp = TempDir::new().unwrap();
643        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
644
645        let data = b"pin me";
646        let hash = sha256(data);
647        store.put(hash, data.to_vec()).await.unwrap();
648
649        // Initially not pinned
650        assert!(!store.is_pinned(&hash));
651        assert_eq!(store.pin_count(&hash), 0);
652
653        // Pin
654        store.pin(&hash).await.unwrap();
655        assert!(store.is_pinned(&hash));
656        assert_eq!(store.pin_count(&hash), 1);
657
658        // Unpin
659        store.unpin(&hash).await.unwrap();
660        assert!(!store.is_pinned(&hash));
661        assert_eq!(store.pin_count(&hash), 0);
662    }
663
664    #[tokio::test]
665    async fn test_pin_ref_counting() {
666        let temp = TempDir::new().unwrap();
667        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
668
669        let data = b"multi pin";
670        let hash = sha256(data);
671        store.put(hash, data.to_vec()).await.unwrap();
672
673        // Pin multiple times
674        store.pin(&hash).await.unwrap();
675        store.pin(&hash).await.unwrap();
676        store.pin(&hash).await.unwrap();
677        assert_eq!(store.pin_count(&hash), 3);
678
679        // Unpin once
680        store.unpin(&hash).await.unwrap();
681        assert_eq!(store.pin_count(&hash), 2);
682        assert!(store.is_pinned(&hash));
683
684        // Unpin remaining
685        store.unpin(&hash).await.unwrap();
686        store.unpin(&hash).await.unwrap();
687        assert_eq!(store.pin_count(&hash), 0);
688    }
689
690    #[tokio::test]
691    async fn test_pins_persist_across_reload() {
692        let temp = TempDir::new().unwrap();
693        let blobs_path = temp.path().join("blobs");
694
695        let data = b"persist me";
696        let hash = sha256(data);
697
698        // Create store and pin
699        {
700            let store = FsBlobStore::new(&blobs_path).unwrap();
701            store.put(hash, data.to_vec()).await.unwrap();
702            store.pin(&hash).await.unwrap();
703            store.pin(&hash).await.unwrap();
704            assert_eq!(store.pin_count(&hash), 2);
705        }
706
707        // Reload store
708        {
709            let store = FsBlobStore::new(&blobs_path).unwrap();
710            assert_eq!(store.pin_count(&hash), 2);
711            assert!(store.is_pinned(&hash));
712        }
713    }
714
715    #[tokio::test]
716    async fn test_max_bytes() {
717        let temp = TempDir::new().unwrap();
718        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
719
720        assert!(store.max_bytes().is_none());
721
722        store.set_max_bytes(1000);
723        assert_eq!(store.max_bytes(), Some(1000));
724
725        store.set_max_bytes(0);
726        assert!(store.max_bytes().is_none());
727    }
728
729    #[tokio::test]
730    async fn test_with_max_bytes() {
731        let temp = TempDir::new().unwrap();
732        let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 500).unwrap();
733        assert_eq!(store.max_bytes(), Some(500));
734    }
735
736    #[tokio::test]
737    async fn test_eviction_respects_pins() {
738        let temp = TempDir::new().unwrap();
739        // 20 byte limit
740        let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 20).unwrap();
741
742        // Add items (5 bytes each = 15 total)
743        let d1 = b"aaaaa"; // oldest - will be pinned
744        let d2 = b"bbbbb";
745        let d3 = b"ccccc";
746        let h1 = sha256(d1);
747        let h2 = sha256(d2);
748        let h3 = sha256(d3);
749
750        store.put(h1, d1.to_vec()).await.unwrap();
751        std::thread::sleep(std::time::Duration::from_millis(10)); // Ensure different mtime
752        store.put(h2, d2.to_vec()).await.unwrap();
753        std::thread::sleep(std::time::Duration::from_millis(10));
754        store.put(h3, d3.to_vec()).await.unwrap();
755
756        // Pin the oldest
757        store.pin(&h1).await.unwrap();
758
759        // Add more to exceed limit (15 + 5 = 20, at limit)
760        let d4 = b"ddddd";
761        let h4 = sha256(d4);
762        std::thread::sleep(std::time::Duration::from_millis(10));
763        store.put(h4, d4.to_vec()).await.unwrap();
764
765        // Add one more to exceed (20 + 5 = 25 > 20)
766        let d5 = b"eeeee";
767        let h5 = sha256(d5);
768        std::thread::sleep(std::time::Duration::from_millis(10));
769        store.put(h5, d5.to_vec()).await.unwrap();
770
771        // Evict
772        let freed = store.evict_if_needed().await.unwrap();
773        assert!(freed > 0, "Should have freed some bytes");
774
775        // Pinned item should still exist
776        assert!(store.has(&h1).await.unwrap(), "Pinned item should exist");
777        // Oldest unpinned (h2) should be evicted
778        assert!(
779            !store.has(&h2).await.unwrap(),
780            "Oldest unpinned should be evicted"
781        );
782        // Newest should exist
783        assert!(store.has(&h5).await.unwrap(), "Newest should exist");
784    }
785
786    #[tokio::test]
787    async fn test_no_eviction_when_under_limit() {
788        let temp = TempDir::new().unwrap();
789        let store = FsBlobStore::with_max_bytes(temp.path().join("blobs"), 1000).unwrap();
790
791        let data = b"small";
792        let hash = sha256(data);
793        store.put(hash, data.to_vec()).await.unwrap();
794
795        let freed = store.evict_if_needed().await.unwrap();
796        assert_eq!(freed, 0);
797        assert!(store.has(&hash).await.unwrap());
798    }
799
800    #[tokio::test]
801    async fn test_no_eviction_without_limit() {
802        let temp = TempDir::new().unwrap();
803        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
804
805        for i in 0..10u8 {
806            let data = vec![i; 100];
807            let hash = sha256(&data);
808            store.put(hash, data).await.unwrap();
809        }
810
811        let freed = store.evict_if_needed().await.unwrap();
812        assert_eq!(freed, 0);
813        assert_eq!(store.list().unwrap().len(), 10);
814    }
815
816    #[tokio::test]
817    async fn test_delete_removes_pin() {
818        let temp = TempDir::new().unwrap();
819        let store = FsBlobStore::new(temp.path().join("blobs")).unwrap();
820
821        let data = b"delete pinned";
822        let hash = sha256(data);
823        store.put(hash, data.to_vec()).await.unwrap();
824        store.pin(&hash).await.unwrap();
825        assert!(store.is_pinned(&hash));
826
827        store.delete(&hash).await.unwrap();
828        assert_eq!(store.pin_count(&hash), 0);
829    }
830}