Skip to main content

cratefield_core/ports/
blob.rs

1//! The `Blob` port (issue #105): small media stored outside the database —
2//! R2 on Workers, a directory or S3-compatible store when self-hosted.
3//!
4//! KV caps a value at 25 MB and is the wrong tool for media, and D1 `BLOB`
5//! columns are banned by the portable lint, so a coach's voice clip (the
6//! request in `yoginini-backend#4`) has nowhere to live. This port gives it
7//! one, keyed and content-typed, with the same ownership rule the tables have:
8//! a module only ever touches keys under its own `<module>/` prefix, enforced
9//! by the [`ScopedBlob`] the harness hands each module.
10
11use std::sync::Arc;
12use std::time::Duration;
13
14use async_trait::async_trait;
15use thiserror::Error;
16
17/// Hard ceiling on one stored object (issue #136). The port exists for
18/// *small* media — a coach's voice clip, an avatar — and every caller
19/// reaches it through [`ScopedBlob`] or an adapter that enforces this
20/// bound before allocating or writing. Ten MiB is well past any legit
21/// clip at conversational bitrates and far below what threatens a
22/// self-hosted process or an isolate that buffers the bytes.
23pub const MAX_BLOB_BYTES: usize = 10 * 1024 * 1024;
24
25/// The longest presign lifetime a store accepts: seven days, the S3 maximum.
26/// [`ScopedBlob`] refuses a longer one before any adapter sees it
27/// (issue #622).
28pub const MAX_PRESIGN_TTL: Duration = Duration::from_hours(7 * 24);
29
30/// The presign lifetime a caller gets when it does not choose one: one hour —
31/// long enough for a browser to fetch or upload, short enough that a leaked
32/// URL ages out (issue #622).
33pub const DEFAULT_PRESIGN_TTL: Duration = Duration::from_hours(1);
34
35/// Rejects a presign lifetime outside `1 s ..= MAX_PRESIGN_TTL` before any
36/// store sees it (issue #622): a sub-second TTL truncates to `X-Amz-Expires=0`
37/// — an already-dead URL — and [`BlobError`] is not `#[non_exhaustive]`, so
38/// this reuses [`BlobError::Operation`] rather than adding a variant.
39fn 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
50/// Rejects a write whose payload exceeds [`MAX_BLOB_BYTES`] (issue #136).
51/// Every [`Blob::put`] implementation MUST call this before touching
52/// storage; [`ScopedBlob`] (the module-facing wrapper) already does, so
53/// an adapter's own call is the second, defence-in-depth gate for
54/// runtimes-wired stores and direct users.
55///
56/// # Errors
57///
58/// [`BlobError::TooLarge`] when `bytes.len()` exceeds the bound.
59pub 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/// A stored object: its bytes and the content type to serve it with.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct BlobObject {
72    pub bytes: Vec<u8>,
73    pub content_type: String,
74}
75
76/// Blob store failures.
77#[derive(Debug, Clone, Error)]
78pub enum BlobError {
79    /// The store rejected or could not complete the operation.
80    #[error("blob operation failed: {0}")]
81    Operation(String),
82    /// The key is empty, absolute, or tries to escape its prefix (`..`).
83    #[error("invalid blob key: {0}")]
84    BadKey(String),
85    /// The object is bigger than [`MAX_BLOB_BYTES`] (issue #136).
86    #[error("blob rejected by size: {0}")]
87    TooLarge(String),
88    /// The adapter does not support this operation (e.g. a directory store has
89    /// no presigned URLs).
90    #[error("blob operation not supported: {0}")]
91    Unsupported(String),
92}
93
94/// A presigned upload: the URL to `PUT` to, the method, and the headers the
95/// client must send **exactly** — the signature covers each signed header, so
96/// a different value is refused by the store (issue #622).
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct PresignedPut {
99    /// The `https://…` URL, query string and all. A bearer credential until
100    /// it expires: never log it.
101    pub url: String,
102    /// Always `"PUT"`; carried so a caller need not hard-code it.
103    pub method: &'static str,
104    /// Headers the client must send with exactly these values (e.g.
105    /// `Content-Type`, `Content-Length`).
106    pub headers: Vec<(String, String)>,
107}
108
109/// A blob store. Keys are module-prefixed; the harness wraps this in a
110/// [`ScopedBlob`] per module so a module cannot name another's objects.
111#[async_trait]
112pub trait Blob: Send + Sync {
113    /// Stores `bytes` at `key` with `content_type`, replacing any existing
114    /// object. Implementations MUST reject a payload larger than
115    /// [`MAX_BLOB_BYTES`] with [`BlobError::TooLarge`] via
116    /// [`check_blob_size`], before writing (issue #136).
117    async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError>;
118    /// Fetches the object at `key`, or `None` if there is none.
119    async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError>;
120    /// Removes the object at `key`. Idempotent: removing a missing key is `Ok`.
121    async fn delete(&self, key: &str) -> Result<(), BlobError>;
122    /// A presigned `GET` for `key`, valid for `ttl`: a URL that serves the
123    /// object directly, skipping the Worker. Adapters without presigned URLs
124    /// (a directory store) return [`BlobError::Unsupported`]; callers then
125    /// serve the bytes through [`get`](Blob::get). The URL's query string is
126    /// a bearer credential: never log it.
127    async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError>;
128    /// A presigned `PUT` for `key` with `content_type`, valid for `ttl`.
129    ///
130    /// The signature cannot cover the body, but when `content_length` is
131    /// `Some` adapters sign `Content-Length` for that value, so a store that
132    /// honours it rejects any other size. The returned [`PresignedPut`] names
133    /// the headers the client must send exactly. Adapters without presigned
134    /// URLs return [`BlobError::Unsupported`] (the default). The URL's query
135    /// string is a bearer credential: never log it.
136    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
150/// Wraps a [`Blob`] so every key is prefixed with `<module>/` and no key can
151/// escape it. The harness applies this in `Ports::view_for`, so a module sees a
152/// store scoped to itself — the blob equivalent of the table-ownership check.
153pub struct ScopedBlob {
154    inner: Arc<dyn Blob>,
155    prefix: String,
156}
157
158impl ScopedBlob {
159    /// Scopes `inner` to `<module>/`.
160    #[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    /// Prefixes a caller key, refusing one that is empty, absolute, or walks
169    /// out of the prefix with `..`.
170    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    // A recording test fixture, not request state (ADR 0007). The scoped
224    // allow follows the policy in the workspace clippy.toml, as the fakes
225    // in `cratefield-testing` and the sibling tests in this crate do.
226    #![allow(clippy::disallowed_types)]
227
228    use super::*;
229    use std::sync::Mutex;
230
231    /// An in-memory blob store for testing the scoping.
232    #[derive(Default)]
233    struct MemBlob {
234        objects: Mutex<std::collections::HashMap<String, BlobObject>>,
235        /// The keys the presign methods were reached with, so a test can prove
236        /// a refused call never arrived.
237        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    /// Implements only the required [`Blob`] methods, so the provided
280    /// [`Blob::signed_put_url`] default is what a test exercises.
281    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        // The underlying store sees the prefixed key.
310        assert!(mem.get("waitlist/clip.mp3").await.unwrap().is_some());
311        assert!(mem.get("clip.mp3").await.unwrap().is_none());
312        // And the scoped view reads it back by the bare key.
313        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            // A sub-second TTL truncates to `X-Amz-Expires=0`, so it is
417            // refused alongside zero and over-long ones.
418            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        // The bound itself is allowed, and the store is reached.
431        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}