Skip to main content

shape_runtime/
blob_store.rs

1//! Blob store abstraction for content-addressed function blobs.
2//!
3//! A `BlobStore` maps `[u8; 32]` content hashes to raw byte blobs. Two
4//! implementations are provided:
5//!
6//! - `MemoryBlobStore` -- in-memory, suitable for testing and ephemeral use.
7//! - `FsBlobStore` -- filesystem-based with a git-style two-level directory
8//!   layout (`~/.shape/blobs/ab/cd1234...ef.blob`).
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13/// Content-addressed blob storage.
14pub trait BlobStore: Send + Sync {
15    /// Retrieve the blob for the given content hash, or `None` if absent.
16    fn get(&self, hash: &[u8; 32]) -> Option<Vec<u8>>;
17
18    /// Store a blob under the given content hash. Returns `true` if the blob
19    /// was newly inserted, `false` if it already existed.
20    fn put(&self, hash: [u8; 32], data: Vec<u8>) -> bool;
21
22    /// Check whether a blob exists for the given hash.
23    fn contains(&self, hash: &[u8; 32]) -> bool;
24}
25
26// ---------------------------------------------------------------------------
27// MemoryBlobStore
28// ---------------------------------------------------------------------------
29
30/// In-memory blob store for testing and ephemeral use.
31pub struct MemoryBlobStore {
32    blobs: parking_lot::RwLock<HashMap<[u8; 32], Vec<u8>>>,
33}
34
35impl MemoryBlobStore {
36    pub fn new() -> Self {
37        Self {
38            blobs: parking_lot::RwLock::new(HashMap::new()),
39        }
40    }
41}
42
43impl Default for MemoryBlobStore {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl BlobStore for MemoryBlobStore {
50    fn get(&self, hash: &[u8; 32]) -> Option<Vec<u8>> {
51        self.blobs.read().get(hash).cloned()
52    }
53
54    fn put(&self, hash: [u8; 32], data: Vec<u8>) -> bool {
55        use std::collections::hash_map::Entry;
56        match self.blobs.write().entry(hash) {
57            Entry::Occupied(_) => false,
58            Entry::Vacant(e) => {
59                e.insert(data);
60                true
61            }
62        }
63    }
64
65    fn contains(&self, hash: &[u8; 32]) -> bool {
66        self.blobs.read().contains_key(hash)
67    }
68}
69
70// ---------------------------------------------------------------------------
71// FsBlobStore
72// ---------------------------------------------------------------------------
73
74/// Filesystem-based blob store with git-style two-level directory layout.
75///
76/// Blobs are stored as `<root>/<first-2-hex-chars>/<remaining-hex>.blob`.
77/// For example, hash `abcd12...ef` is stored at `<root>/ab/cd12...ef.blob`.
78pub struct FsBlobStore {
79    root: PathBuf,
80}
81
82impl FsBlobStore {
83    /// Create (or open) a filesystem blob store rooted at `root`.
84    ///
85    /// The root directory is created if it does not exist.
86    pub fn new(root: PathBuf) -> std::io::Result<Self> {
87        std::fs::create_dir_all(&root)?;
88        Ok(Self { root })
89    }
90
91    /// Compute the path for a given content hash.
92    fn blob_path(&self, hash: &[u8; 32]) -> PathBuf {
93        let hex = hex::encode(hash);
94        self.root
95            .join(&hex[..2])
96            .join(format!("{}.blob", &hex[2..]))
97    }
98}
99
100impl BlobStore for FsBlobStore {
101    fn get(&self, hash: &[u8; 32]) -> Option<Vec<u8>> {
102        let path = self.blob_path(hash);
103        std::fs::read(&path).ok()
104    }
105
106    fn put(&self, hash: [u8; 32], data: Vec<u8>) -> bool {
107        let path = self.blob_path(&hash);
108        if path.exists() {
109            return false;
110        }
111        if let Some(parent) = path.parent() {
112            if std::fs::create_dir_all(parent).is_err() {
113                return false;
114            }
115        }
116        std::fs::write(&path, &data).is_ok()
117    }
118
119    fn contains(&self, hash: &[u8; 32]) -> bool {
120        self.blob_path(hash).exists()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_memory_blob_store_put_get() {
130        let store = MemoryBlobStore::new();
131        let hash = [0xAB; 32];
132        let data = vec![1, 2, 3, 4, 5];
133
134        assert!(!store.contains(&hash));
135        assert!(store.put(hash, data.clone()));
136        assert!(store.contains(&hash));
137        assert_eq!(store.get(&hash), Some(data));
138    }
139
140    #[test]
141    fn test_memory_blob_store_duplicate_put() {
142        let store = MemoryBlobStore::new();
143        let hash = [0xCD; 32];
144
145        assert!(store.put(hash, vec![1, 2]));
146        assert!(!store.put(hash, vec![3, 4]));
147        // Original data preserved
148        assert_eq!(store.get(&hash), Some(vec![1, 2]));
149    }
150
151    #[test]
152    fn test_memory_blob_store_missing_key() {
153        let store = MemoryBlobStore::new();
154        assert_eq!(store.get(&[0xFF; 32]), None);
155        assert!(!store.contains(&[0xFF; 32]));
156    }
157
158    #[test]
159    fn test_fs_blob_store_put_get() {
160        let tmp = tempfile::tempdir().expect("temp dir");
161        let store = FsBlobStore::new(tmp.path().to_path_buf()).expect("create store");
162
163        let hash = [0x12; 32];
164        let data = vec![10, 20, 30];
165
166        assert!(!store.contains(&hash));
167        assert!(store.put(hash, data.clone()));
168        assert!(store.contains(&hash));
169        assert_eq!(store.get(&hash), Some(data));
170    }
171
172    #[test]
173    fn test_fs_blob_store_duplicate_put() {
174        let tmp = tempfile::tempdir().expect("temp dir");
175        let store = FsBlobStore::new(tmp.path().to_path_buf()).expect("create store");
176
177        let hash = [0x34; 32];
178        assert!(store.put(hash, vec![1]));
179        assert!(!store.put(hash, vec![2]));
180        assert_eq!(store.get(&hash), Some(vec![1]));
181    }
182
183    #[test]
184    fn test_fs_blob_store_path_layout() {
185        let tmp = tempfile::tempdir().expect("temp dir");
186        let store = FsBlobStore::new(tmp.path().to_path_buf()).expect("create store");
187
188        let mut hash = [0u8; 32];
189        hash[0] = 0xAB;
190        hash[1] = 0xCD;
191        // Rest are zeros
192
193        let path = store.blob_path(&hash);
194        let path_str = path.to_string_lossy();
195
196        // Should start with the 2-char prefix directory
197        assert!(
198            path_str.contains("/ab/"),
199            "expected /ab/ in path, got: {}",
200            path_str
201        );
202        assert!(
203            path_str.ends_with(".blob"),
204            "expected .blob suffix, got: {}",
205            path_str
206        );
207    }
208
209    #[test]
210    fn test_fs_blob_store_missing_key() {
211        let tmp = tempfile::tempdir().expect("temp dir");
212        let store = FsBlobStore::new(tmp.path().to_path_buf()).expect("create store");
213        assert_eq!(store.get(&[0xFF; 32]), None);
214        assert!(!store.contains(&[0xFF; 32]));
215    }
216}