cratefield_core/ports/
blob.rs1use std::sync::Arc;
12use std::time::Duration;
13
14use async_trait::async_trait;
15use thiserror::Error;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct BlobObject {
20 pub bytes: Vec<u8>,
21 pub content_type: String,
22}
23
24#[derive(Debug, Clone, Error)]
26pub enum BlobError {
27 #[error("blob operation failed: {0}")]
29 Operation(String),
30 #[error("invalid blob key: {0}")]
32 BadKey(String),
33 #[error("blob operation not supported: {0}")]
36 Unsupported(String),
37}
38
39#[async_trait]
42pub trait Blob: Send + Sync {
43 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError>;
46 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError>;
48 async fn delete(&self, key: &str) -> Result<(), BlobError>;
50 async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError>;
55}
56
57pub struct ScopedBlob {
61 inner: Arc<dyn Blob>,
62 prefix: String,
63}
64
65impl ScopedBlob {
66 #[must_use]
68 pub fn new(inner: Arc<dyn Blob>, module: &str) -> Self {
69 Self {
70 inner,
71 prefix: format!("{module}/"),
72 }
73 }
74
75 fn scope(&self, key: &str) -> Result<String, BlobError> {
78 if key.is_empty() {
79 return Err(BlobError::BadKey("a blob key cannot be empty".to_owned()));
80 }
81 if key.starts_with('/') {
82 return Err(BlobError::BadKey(format!("key `{key}` must be relative")));
83 }
84 if key
85 .split('/')
86 .any(|segment| segment == ".." || segment == ".")
87 {
88 return Err(BlobError::BadKey(format!(
89 "key `{key}` must not contain `.` or `..` segments"
90 )));
91 }
92 Ok(format!("{}{key}", self.prefix))
93 }
94}
95
96#[async_trait]
97impl Blob for ScopedBlob {
98 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
99 self.inner.put(&self.scope(key)?, bytes, content_type).await
100 }
101 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
102 self.inner.get(&self.scope(key)?).await
103 }
104 async fn delete(&self, key: &str) -> Result<(), BlobError> {
105 self.inner.delete(&self.scope(key)?).await
106 }
107 async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError> {
108 self.inner.signed_url(&self.scope(key)?, ttl).await
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 #![allow(clippy::disallowed_types)]
118
119 use super::*;
120 use std::sync::Mutex;
121
122 #[derive(Default)]
124 struct MemBlob {
125 objects: Mutex<std::collections::HashMap<String, BlobObject>>,
126 }
127
128 #[async_trait]
129 impl Blob for MemBlob {
130 async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
131 self.objects.lock().unwrap().insert(
132 key.to_owned(),
133 BlobObject {
134 bytes: bytes.to_vec(),
135 content_type: content_type.to_owned(),
136 },
137 );
138 Ok(())
139 }
140 async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
141 Ok(self.objects.lock().unwrap().get(key).cloned())
142 }
143 async fn delete(&self, key: &str) -> Result<(), BlobError> {
144 self.objects.lock().unwrap().remove(key);
145 Ok(())
146 }
147 async fn signed_url(&self, _key: &str, _ttl: Duration) -> Result<String, BlobError> {
148 Err(BlobError::Unsupported("memory store".to_owned()))
149 }
150 }
151
152 #[pollster::test]
153 async fn a_scoped_blob_prefixes_the_key() {
154 let mem = Arc::new(MemBlob::default());
155 let scoped = ScopedBlob::new(mem.clone(), "waitlist");
156 scoped.put("clip.mp3", b"x", "audio/mpeg").await.unwrap();
157 assert!(mem.get("waitlist/clip.mp3").await.unwrap().is_some());
159 assert!(mem.get("clip.mp3").await.unwrap().is_none());
160 assert!(scoped.get("clip.mp3").await.unwrap().is_some());
162 }
163
164 #[pollster::test]
165 async fn a_scoped_blob_refuses_an_escaping_key() {
166 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
167 for bad in ["", "/etc/passwd", "../secrets/x", "a/../../b", "."] {
168 assert!(
169 matches!(scoped.get(bad).await.unwrap_err(), BlobError::BadKey(_)),
170 "key `{bad}` should be refused"
171 );
172 }
173 }
174
175 #[pollster::test]
176 async fn delete_is_idempotent() {
177 let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
178 scoped.delete("missing").await.expect("no-op delete is ok");
179 }
180}