cratefield_core/ports/
blob.rs1use std::sync::Arc;
12use std::time::Duration;
13
14use async_trait::async_trait;
15use thiserror::Error;
16
17pub const MAX_BLOB_BYTES: usize = 10 * 1024 * 1024;
24
25pub fn check_blob_size(bytes: &[u8]) -> Result<(), BlobError> {
35 if bytes.len() > MAX_BLOB_BYTES {
36 return Err(BlobError::TooLarge(format!(
37 "blob of {} bytes exceeds the {MAX_BLOB_BYTES}-byte bound",
38 bytes.len()
39 )));
40 }
41 Ok(())
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct BlobObject {
47 pub bytes: Vec<u8>,
48 pub content_type: String,
49}
50
51#[derive(Debug, Clone, Error)]
53pub enum BlobError {
54 #[error("blob operation failed: {0}")]
56 Operation(String),
57 #[error("invalid blob key: {0}")]
59 BadKey(String),
60 #[error("blob rejected by size: {0}")]
62 TooLarge(String),
63 #[error("blob operation not supported: {0}")]
66 Unsupported(String),
67}
68
69#[async_trait]
72pub trait Blob: Send + Sync {
73 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError>;
78 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError>;
80 async fn delete(&self, key: &str) -> Result<(), BlobError>;
82 async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError>;
87}
88
89pub struct ScopedBlob {
93 inner: Arc<dyn Blob>,
94 prefix: String,
95}
96
97impl ScopedBlob {
98 #[must_use]
100 pub fn new(inner: Arc<dyn Blob>, module: &str) -> Self {
101 Self {
102 inner,
103 prefix: format!("{module}/"),
104 }
105 }
106
107 fn scope(&self, key: &str) -> Result<String, BlobError> {
110 if key.is_empty() {
111 return Err(BlobError::BadKey("a blob key cannot be empty".to_owned()));
112 }
113 if key.starts_with('/') {
114 return Err(BlobError::BadKey(format!("key `{key}` must be relative")));
115 }
116 if key
117 .split('/')
118 .any(|segment| segment == ".." || segment == ".")
119 {
120 return Err(BlobError::BadKey(format!(
121 "key `{key}` must not contain `.` or `..` segments"
122 )));
123 }
124 Ok(format!("{}{key}", self.prefix))
125 }
126}
127
128#[async_trait]
129impl Blob for ScopedBlob {
130 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
131 check_blob_size(bytes)?;
132 self.inner.put(&self.scope(key)?, bytes, content_type).await
133 }
134 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
135 self.inner.get(&self.scope(key)?).await
136 }
137 async fn delete(&self, key: &str) -> Result<(), BlobError> {
138 self.inner.delete(&self.scope(key)?).await
139 }
140 async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError> {
141 self.inner.signed_url(&self.scope(key)?, ttl).await
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 #![allow(clippy::disallowed_types)]
151
152 use super::*;
153 use std::sync::Mutex;
154
155 #[derive(Default)]
157 struct MemBlob {
158 objects: Mutex<std::collections::HashMap<String, BlobObject>>,
159 }
160
161 #[async_trait]
162 impl Blob for MemBlob {
163 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
164 self.objects.lock().unwrap().insert(
165 key.to_owned(),
166 BlobObject {
167 bytes: bytes.to_vec(),
168 content_type: content_type.to_owned(),
169 },
170 );
171 Ok(())
172 }
173 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
174 Ok(self.objects.lock().unwrap().get(key).cloned())
175 }
176 async fn delete(&self, key: &str) -> Result<(), BlobError> {
177 self.objects.lock().unwrap().remove(key);
178 Ok(())
179 }
180 async fn signed_url(&self, _key: &str, _ttl: Duration) -> Result<String, BlobError> {
181 Err(BlobError::Unsupported("memory store".to_owned()))
182 }
183 }
184
185 #[pollster::test]
186 async fn a_scoped_blob_prefixes_the_key() {
187 let mem = Arc::new(MemBlob::default());
188 let scoped = ScopedBlob::new(mem.clone(), "waitlist");
189 scoped.put("clip.mp3", b"x", "audio/mpeg").await.unwrap();
190 assert!(mem.get("waitlist/clip.mp3").await.unwrap().is_some());
192 assert!(mem.get("clip.mp3").await.unwrap().is_none());
193 assert!(scoped.get("clip.mp3").await.unwrap().is_some());
195 }
196
197 #[pollster::test]
198 async fn a_scoped_blob_refuses_an_escaping_key() {
199 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
200 for bad in ["", "/etc/passwd", "../secrets/x", "a/../../b", "."] {
201 assert!(
202 matches!(scoped.get(bad).await.unwrap_err(), BlobError::BadKey(_)),
203 "key `{bad}` should be refused"
204 );
205 }
206 }
207
208 #[pollster::test]
209 async fn delete_is_idempotent() {
210 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
211 scoped.delete("missing").await.expect("no-op delete is ok");
212 }
213
214 #[pollster::test]
215 async fn an_oversized_put_is_refused_before_the_store_is_reached() {
216 let mem = Arc::new(MemBlob::default());
217 let scoped = ScopedBlob::new(mem.clone(), "cms");
218 let oversized = vec![0_u8; MAX_BLOB_BYTES + 1];
219 let err = scoped
220 .put("big.bin", &oversized, "application/octet-stream")
221 .await
222 .expect_err("one byte past the bound must be refused");
223 assert!(matches!(err, BlobError::TooLarge(_)), "got {err}");
224 assert!(
225 mem.get("cms/big.bin").await.unwrap().is_none(),
226 "the store never saw the rejected key"
227 );
228 }
229
230 #[pollster::test]
231 async fn a_put_at_exactly_the_bound_is_accepted() {
232 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
233 let exact = vec![0_u8; MAX_BLOB_BYTES];
234 scoped
235 .put("edge.bin", &exact, "application/octet-stream")
236 .await
237 .expect("the bound itself is allowed");
238 }
239}