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/// A stored object: its bytes and the content type to serve it with.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct BlobObject {
20    pub bytes: Vec<u8>,
21    pub content_type: String,
22}
23
24/// Blob store failures.
25#[derive(Debug, Clone, Error)]
26pub enum BlobError {
27    /// The store rejected or could not complete the operation.
28    #[error("blob operation failed: {0}")]
29    Operation(String),
30    /// The key is empty, absolute, or tries to escape its prefix (`..`).
31    #[error("invalid blob key: {0}")]
32    BadKey(String),
33    /// The adapter does not support this operation (e.g. a directory store has
34    /// no presigned URLs).
35    #[error("blob operation not supported: {0}")]
36    Unsupported(String),
37}
38
39/// A blob store. Keys are module-prefixed; the harness wraps this in a
40/// [`ScopedBlob`] per module so a module cannot name another's objects.
41#[async_trait]
42pub trait Blob: Send + Sync {
43    /// Stores `bytes` at `key` with `content_type`, replacing any existing
44    /// object.
45    async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError>;
46    /// Fetches the object at `key`, or `None` if there is none.
47    async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError>;
48    /// Removes the object at `key`. Idempotent: removing a missing key is `Ok`.
49    async fn delete(&self, key: &str) -> Result<(), BlobError>;
50    /// A URL that serves the object directly for `ttl`, skipping the Worker.
51    /// Adapters without presigned URLs (a directory store) return
52    /// [`BlobError::Unsupported`]; callers then serve the bytes through
53    /// [`get`](Blob::get).
54    async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError>;
55}
56
57/// Wraps a [`Blob`] so every key is prefixed with `<module>/` and no key can
58/// escape it. The harness applies this in `Ports::view_for`, so a module sees a
59/// store scoped to itself — the blob equivalent of the table-ownership check.
60pub struct ScopedBlob {
61    inner: Arc<dyn Blob>,
62    prefix: String,
63}
64
65impl ScopedBlob {
66    /// Scopes `inner` to `<module>/`.
67    #[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    /// Prefixes a caller key, refusing one that is empty, absolute, or walks
76    /// out of the prefix with `..`.
77    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    // A recording test fixture, not request state (ADR 0007). The scoped
115    // allow follows the policy in the workspace clippy.toml, as the fakes
116    // in `cratefield-testing` and the sibling tests in this crate do.
117    #![allow(clippy::disallowed_types)]
118
119    use super::*;
120    use std::sync::Mutex;
121
122    /// An in-memory blob store for testing the scoping.
123    #[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        // The underlying store sees the prefixed key.
158        assert!(mem.get("waitlist/clip.mp3").await.unwrap().is_some());
159        assert!(mem.get("clip.mp3").await.unwrap().is_none());
160        // And the scoped view reads it back by the bare key.
161        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}