Skip to main content

ferro_blob_store/
memory.rs

1// SPDX-License-Identifier: Apache-2.0
2//! In-memory [`BlobStore`] reference implementation.
3
4use std::collections::HashMap;
5use std::sync::{Arc, RwLock};
6
7use async_trait::async_trait;
8use bytes::Bytes;
9
10use crate::{BlobStore, BlobStoreError, Digest, DigestAlgo, Result};
11
12/// `Arc<RwLock<HashMap<Digest, Bytes>>>` reference implementation.
13///
14/// Cheap to clone; multiple handles share the same backing map. Useful
15/// for tests, ephemeral caches, and a baseline for performance
16/// comparison against custom backends.
17#[derive(Debug, Clone, Default)]
18pub struct InMemoryBlobStore {
19    inner: Arc<RwLock<HashMap<Digest, Bytes>>>,
20}
21
22impl InMemoryBlobStore {
23    /// Construct an empty store.
24    #[must_use]
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Number of stored blobs.
30    #[must_use]
31    pub fn len(&self) -> usize {
32        self.inner.read().expect("poisoned").len()
33    }
34
35    /// `true` when the store holds no blobs.
36    #[must_use]
37    pub fn is_empty(&self) -> bool {
38        self.inner.read().expect("poisoned").is_empty()
39    }
40
41    /// Drop every entry.
42    pub fn clear(&self) {
43        self.inner.write().expect("poisoned").clear();
44    }
45}
46
47#[async_trait]
48impl BlobStore for InMemoryBlobStore {
49    async fn put(&self, digest: &Digest, bytes: Bytes) -> Result<()> {
50        let computed = match digest.algo() {
51            DigestAlgo::Sha256 => Digest::sha256_of(&bytes),
52            DigestAlgo::Sha512 => Digest::sha512_of(&bytes),
53        };
54        if &computed != digest {
55            return Err(BlobStoreError::DigestMismatch {
56                expected: digest.to_string(),
57                computed: computed.to_string(),
58            });
59        }
60        self.inner
61            .write()
62            .expect("poisoned")
63            .insert(digest.clone(), bytes);
64        Ok(())
65    }
66
67    async fn get(&self, digest: &Digest) -> Result<Bytes> {
68        self.inner
69            .read()
70            .expect("poisoned")
71            .get(digest)
72            .cloned()
73            .ok_or_else(|| BlobStoreError::NotFound(digest.to_string()))
74    }
75
76    async fn contains(&self, digest: &Digest) -> Result<bool> {
77        Ok(self.inner.read().expect("poisoned").contains_key(digest))
78    }
79
80    async fn delete(&self, digest: &Digest) -> Result<()> {
81        self.inner.write().expect("poisoned").remove(digest);
82        Ok(())
83    }
84
85    async fn list(&self) -> Result<Vec<Digest>> {
86        Ok(self
87            .inner
88            .read()
89            .expect("poisoned")
90            .keys()
91            .cloned()
92            .collect())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[tokio::test]
101    async fn put_get_round_trip() {
102        let store = InMemoryBlobStore::new();
103        let body = Bytes::from_static(b"hello");
104        let d = Digest::sha256_of(&body);
105        store.put(&d, body.clone()).await.unwrap();
106        assert_eq!(store.get(&d).await.unwrap(), body);
107    }
108
109    #[tokio::test]
110    async fn put_rejects_digest_mismatch() {
111        let store = InMemoryBlobStore::new();
112        let real_body = Bytes::from_static(b"hello");
113        let lying_digest = Digest::sha256_of(b"goodbye");
114        let err = store.put(&lying_digest, real_body).await.unwrap_err();
115        assert!(matches!(err, BlobStoreError::DigestMismatch { .. }));
116    }
117
118    #[tokio::test]
119    async fn contains_and_list() {
120        let store = InMemoryBlobStore::new();
121        let body = Bytes::from_static(b"x");
122        let d = Digest::sha256_of(&body);
123        assert!(!store.contains(&d).await.unwrap());
124        store.put(&d, body).await.unwrap();
125        assert!(store.contains(&d).await.unwrap());
126        let listed = store.list().await.unwrap();
127        assert_eq!(listed, vec![d]);
128    }
129
130    #[tokio::test]
131    async fn get_returns_not_found() {
132        let store = InMemoryBlobStore::new();
133        let d = Digest::sha256_of(b"missing");
134        let err = store.get(&d).await.unwrap_err();
135        assert!(matches!(err, BlobStoreError::NotFound(_)));
136    }
137
138    #[tokio::test]
139    async fn delete_missing_is_ok() {
140        let store = InMemoryBlobStore::new();
141        let d = Digest::sha256_of(b"never-stored");
142        store.delete(&d).await.unwrap();
143    }
144
145    #[tokio::test]
146    async fn clone_shares_storage() {
147        let a = InMemoryBlobStore::new();
148        let b = a.clone();
149        let body = Bytes::from_static(b"shared");
150        let d = Digest::sha256_of(&body);
151        a.put(&d, body.clone()).await.unwrap();
152        assert_eq!(b.get(&d).await.unwrap(), body);
153        assert_eq!(b.len(), 1);
154        b.clear();
155        assert!(a.is_empty());
156    }
157}