1use 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 const MAX_PRESIGN_TTL: Duration = Duration::from_hours(7 * 24);
29
30pub const DEFAULT_PRESIGN_TTL: Duration = Duration::from_hours(1);
34
35fn check_presign_ttl(ttl: Duration) -> Result<(), BlobError> {
40 if ttl < Duration::from_secs(1) || ttl > MAX_PRESIGN_TTL {
41 return Err(BlobError::Operation(format!(
42 "presign ttl must be between 1 and {} seconds, got {}",
43 MAX_PRESIGN_TTL.as_secs(),
44 ttl.as_secs(),
45 )));
46 }
47 Ok(())
48}
49
50pub fn check_blob_size(bytes: &[u8]) -> Result<(), BlobError> {
60 if bytes.len() > MAX_BLOB_BYTES {
61 return Err(BlobError::TooLarge(format!(
62 "blob of {} bytes exceeds the {MAX_BLOB_BYTES}-byte bound",
63 bytes.len()
64 )));
65 }
66 Ok(())
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct BlobObject {
72 pub bytes: Vec<u8>,
73 pub content_type: String,
74}
75
76#[derive(Debug, Clone, Error)]
78pub enum BlobError {
79 #[error("blob operation failed: {0}")]
81 Operation(String),
82 #[error("invalid blob key: {0}")]
84 BadKey(String),
85 #[error("blob rejected by size: {0}")]
87 TooLarge(String),
88 #[error("blob operation not supported: {0}")]
91 Unsupported(String),
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct PresignedPut {
99 pub url: String,
102 pub method: &'static str,
104 pub headers: Vec<(String, String)>,
107}
108
109#[async_trait]
112pub trait Blob: Send + Sync {
113 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError>;
118 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError>;
120 async fn delete(&self, key: &str) -> Result<(), BlobError>;
122 async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError>;
128 async fn signed_put_url(
137 &self,
138 key: &str,
139 content_type: &str,
140 content_length: Option<u64>,
141 ttl: Duration,
142 ) -> Result<PresignedPut, BlobError> {
143 let _ = (key, content_type, content_length, ttl);
144 Err(BlobError::Unsupported(
145 "this blob store has no presigned uploads".to_owned(),
146 ))
147 }
148}
149
150pub struct ScopedBlob {
154 inner: Arc<dyn Blob>,
155 prefix: String,
156}
157
158impl ScopedBlob {
159 #[must_use]
161 pub fn new(inner: Arc<dyn Blob>, module: &str) -> Self {
162 Self {
163 inner,
164 prefix: format!("{module}/"),
165 }
166 }
167
168 fn scope(&self, key: &str) -> Result<String, BlobError> {
171 if key.is_empty() {
172 return Err(BlobError::BadKey("a blob key cannot be empty".to_owned()));
173 }
174 if key.starts_with('/') {
175 return Err(BlobError::BadKey(format!("key `{key}` must be relative")));
176 }
177 if key
178 .split('/')
179 .any(|segment| segment == ".." || segment == ".")
180 {
181 return Err(BlobError::BadKey(format!(
182 "key `{key}` must not contain `.` or `..` segments"
183 )));
184 }
185 Ok(format!("{}{key}", self.prefix))
186 }
187}
188
189#[async_trait]
190impl Blob for ScopedBlob {
191 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
192 check_blob_size(bytes)?;
193 self.inner.put(&self.scope(key)?, bytes, content_type).await
194 }
195 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
196 self.inner.get(&self.scope(key)?).await
197 }
198 async fn delete(&self, key: &str) -> Result<(), BlobError> {
199 self.inner.delete(&self.scope(key)?).await
200 }
201 async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError> {
202 let key = self.scope(key)?;
203 check_presign_ttl(ttl)?;
204 self.inner.signed_url(&key, ttl).await
205 }
206 async fn signed_put_url(
207 &self,
208 key: &str,
209 content_type: &str,
210 content_length: Option<u64>,
211 ttl: Duration,
212 ) -> Result<PresignedPut, BlobError> {
213 let key = self.scope(key)?;
214 check_presign_ttl(ttl)?;
215 self.inner
216 .signed_put_url(&key, content_type, content_length, ttl)
217 .await
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 #![allow(clippy::disallowed_types)]
227
228 use super::*;
229 use std::sync::Mutex;
230
231 #[derive(Default)]
233 struct MemBlob {
234 objects: Mutex<std::collections::HashMap<String, BlobObject>>,
235 presigned: Mutex<Vec<String>>,
238 }
239
240 #[async_trait]
241 impl Blob for MemBlob {
242 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
243 self.objects.lock().unwrap().insert(
244 key.to_owned(),
245 BlobObject {
246 bytes: bytes.to_vec(),
247 content_type: content_type.to_owned(),
248 },
249 );
250 Ok(())
251 }
252 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
253 Ok(self.objects.lock().unwrap().get(key).cloned())
254 }
255 async fn delete(&self, key: &str) -> Result<(), BlobError> {
256 self.objects.lock().unwrap().remove(key);
257 Ok(())
258 }
259 async fn signed_url(&self, key: &str, _ttl: Duration) -> Result<String, BlobError> {
260 self.presigned.lock().unwrap().push(key.to_owned());
261 Ok(format!("mem://{key}"))
262 }
263 async fn signed_put_url(
264 &self,
265 key: &str,
266 content_type: &str,
267 _content_length: Option<u64>,
268 _ttl: Duration,
269 ) -> Result<PresignedPut, BlobError> {
270 self.presigned.lock().unwrap().push(key.to_owned());
271 Ok(PresignedPut {
272 url: format!("mem://{key}"),
273 method: "PUT",
274 headers: vec![("content-type".to_owned(), content_type.to_owned())],
275 })
276 }
277 }
278
279 struct BareBlob;
282
283 #[async_trait]
284 impl Blob for BareBlob {
285 async fn put(
286 &self,
287 _key: &str,
288 _bytes: &[u8],
289 _content_type: &str,
290 ) -> Result<(), BlobError> {
291 Ok(())
292 }
293 async fn get(&self, _key: &str) -> Result<Option<BlobObject>, BlobError> {
294 Ok(None)
295 }
296 async fn delete(&self, _key: &str) -> Result<(), BlobError> {
297 Ok(())
298 }
299 async fn signed_url(&self, _key: &str, _ttl: Duration) -> Result<String, BlobError> {
300 Err(BlobError::Unsupported("memory store".to_owned()))
301 }
302 }
303
304 #[pollster::test]
305 async fn a_scoped_blob_prefixes_the_key() {
306 let mem = Arc::new(MemBlob::default());
307 let scoped = ScopedBlob::new(mem.clone(), "waitlist");
308 scoped.put("clip.mp3", b"x", "audio/mpeg").await.unwrap();
309 assert!(mem.get("waitlist/clip.mp3").await.unwrap().is_some());
311 assert!(mem.get("clip.mp3").await.unwrap().is_none());
312 assert!(scoped.get("clip.mp3").await.unwrap().is_some());
314 }
315
316 #[pollster::test]
317 async fn a_scoped_blob_refuses_an_escaping_key() {
318 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
319 for bad in ["", "/etc/passwd", "../secrets/x", "a/../../b", "."] {
320 assert!(
321 matches!(scoped.get(bad).await.unwrap_err(), BlobError::BadKey(_)),
322 "key `{bad}` should be refused"
323 );
324 }
325 }
326
327 #[pollster::test]
328 async fn delete_is_idempotent() {
329 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
330 scoped.delete("missing").await.expect("no-op delete is ok");
331 }
332
333 #[pollster::test]
334 async fn an_oversized_put_is_refused_before_the_store_is_reached() {
335 let mem = Arc::new(MemBlob::default());
336 let scoped = ScopedBlob::new(mem.clone(), "cms");
337 let oversized = vec![0_u8; MAX_BLOB_BYTES + 1];
338 let err = scoped
339 .put("big.bin", &oversized, "application/octet-stream")
340 .await
341 .expect_err("one byte past the bound must be refused");
342 assert!(matches!(err, BlobError::TooLarge(_)), "got {err}");
343 assert!(
344 mem.get("cms/big.bin").await.unwrap().is_none(),
345 "the store never saw the rejected key"
346 );
347 }
348
349 #[pollster::test]
350 async fn a_put_at_exactly_the_bound_is_accepted() {
351 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
352 let exact = vec![0_u8; MAX_BLOB_BYTES];
353 scoped
354 .put("edge.bin", &exact, "application/octet-stream")
355 .await
356 .expect("the bound itself is allowed");
357 }
358
359 #[pollster::test]
360 async fn a_scoped_blob_prefixes_both_presign_keys() {
361 let mem = Arc::new(MemBlob::default());
362 let scoped = ScopedBlob::new(mem.clone(), "cms");
363 let get = scoped
364 .signed_url("clip.mp3", Duration::from_secs(60))
365 .await
366 .unwrap();
367 assert_eq!(get, "mem://cms/clip.mp3");
368 let put = scoped
369 .signed_put_url("clip.mp3", "audio/mpeg", Some(3), Duration::from_secs(60))
370 .await
371 .unwrap();
372 assert_eq!(put.url, "mem://cms/clip.mp3");
373 assert_eq!(
374 *mem.presigned.lock().unwrap(),
375 vec!["cms/clip.mp3".to_owned(), "cms/clip.mp3".to_owned()],
376 );
377 }
378
379 #[pollster::test]
380 async fn a_scoped_blob_refuses_an_escaping_presign_key_before_the_store() {
381 let mem = Arc::new(MemBlob::default());
382 let scoped = ScopedBlob::new(mem.clone(), "cms");
383 for bad in ["", "/etc/passwd", "../secrets/x"] {
384 assert!(
385 matches!(
386 scoped
387 .signed_url(bad, Duration::from_secs(60))
388 .await
389 .unwrap_err(),
390 BlobError::BadKey(_)
391 ),
392 "key `{bad}` should be refused"
393 );
394 assert!(
395 matches!(
396 scoped
397 .signed_put_url(bad, "image/png", None, Duration::from_secs(60))
398 .await
399 .unwrap_err(),
400 BlobError::BadKey(_)
401 ),
402 "key `{bad}` should be refused"
403 );
404 }
405 assert!(
406 mem.presigned.lock().unwrap().is_empty(),
407 "the store never saw a key"
408 );
409 }
410
411 #[pollster::test]
412 async fn a_scoped_blob_refuses_a_presign_ttl_outside_the_bound() {
413 let mem = Arc::new(MemBlob::default());
414 let scoped = ScopedBlob::new(mem.clone(), "cms");
415 for bad in [
416 Duration::from_millis(500),
419 Duration::ZERO,
420 MAX_PRESIGN_TTL + Duration::from_secs(1),
421 ] {
422 assert!(
423 matches!(
424 scoped.signed_url("clip.mp3", bad).await.unwrap_err(),
425 BlobError::Operation(_)
426 ),
427 "ttl {bad:?} should be refused"
428 );
429 }
430 scoped
432 .signed_url("clip.mp3", MAX_PRESIGN_TTL)
433 .await
434 .unwrap();
435 assert_eq!(mem.presigned.lock().unwrap().len(), 1);
436 }
437
438 #[pollster::test]
439 async fn the_default_signed_put_url_is_unsupported() {
440 let scoped = ScopedBlob::new(Arc::new(BareBlob), "cms");
441 assert!(matches!(
442 scoped
443 .signed_put_url("clip.mp3", "audio/mpeg", None, Duration::from_secs(60))
444 .await
445 .unwrap_err(),
446 BlobError::Unsupported(_)
447 ));
448 }
449}