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::fmt;
12use std::pin::Pin;
13use std::sync::Arc;
14use std::task::{Context, Poll};
15use std::time::Duration;
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use futures_core::Stream;
20use thiserror::Error;
21
22use crate::stream::{BoxStream, ResponseStream, StreamError};
23
24/// Hard ceiling on one stored object (issue #136). The port exists for
25/// *small* media — a coach's voice clip, an avatar — and every caller
26/// reaches it through [`ScopedBlob`] or an adapter that enforces this
27/// bound before allocating or writing. Ten MiB is well past any legit
28/// clip at conversational bitrates and far below what threatens a
29/// self-hosted process or an isolate that buffers the bytes.
30///
31/// Objects above this bound take a separate, explicitly opted-in path:
32/// the streamed [`Blob::put_stream`] and the multipart upload methods
33/// (issue #586), bounded instead by the module's declared
34/// [`Module::max_blob_object_bytes`](crate::Module::max_blob_object_bytes).
35pub const MAX_BLOB_BYTES: usize = 10 * 1024 * 1024;
36
37/// The harness-wide absolute cap on the large-object limit a module may
38/// declare (issue #586). Five GiB is a harness-chosen ceiling — large
39/// enough to stream a video for a person who uploaded one, and well under
40/// R2's ~5 TiB single-object maximum — so no module can declare its way
41/// past a bound the harness is willing to enforce.
42pub const MAX_LARGE_BLOB_BYTES: u64 = 5 * 1024 * 1024 * 1024;
43
44/// The most parts one multipart upload may have (issue #586): S3's and
45/// R2's own limit. [`Blob::complete_multipart`] refuses more, and part
46/// numbers are `1 ..= MAX_MULTIPART_PARTS`.
47pub const MAX_MULTIPART_PARTS: u16 = 10_000;
48
49/// The smallest a non-final multipart part may be (issue #586): R2's
50/// rule. Every part except the last one listed must be at least this,
51/// and R2 additionally requires all non-final parts to be the same size;
52/// [`ScopedBlob`] leaves that to the adapter, which alone sees the parts
53/// as they are written.
54pub const MIN_MULTIPART_PART_BYTES: u64 = 5 * 1024 * 1024;
55
56/// The most objects one [`Blob::list`] call may return (issue #586):
57/// R2's list cap. [`ScopedBlob`] clamps a caller's `limit` into
58/// `1 ..= MAX_LIST_LIMIT` before any adapter sees it.
59pub const MAX_LIST_LIMIT: usize = 1000;
60
61/// The longest presign lifetime a store accepts: seven days, the S3 maximum.
62/// [`ScopedBlob`] refuses a longer one before any adapter sees it
63/// (issue #622).
64pub const MAX_PRESIGN_TTL: Duration = Duration::from_hours(7 * 24);
65
66/// The presign lifetime a caller gets when it does not choose one: one hour —
67/// long enough for a browser to fetch or upload, short enough that a leaked
68/// URL ages out (issue #622).
69pub const DEFAULT_PRESIGN_TTL: Duration = Duration::from_hours(1);
70
71/// Rejects a presign lifetime outside `1 s ..= MAX_PRESIGN_TTL` before any
72/// store sees it (issue #622): a sub-second TTL truncates to `X-Amz-Expires=0`
73/// — an already-dead URL — and [`BlobError`] is not `#[non_exhaustive]`, so
74/// this reuses [`BlobError::Operation`] rather than adding a variant.
75fn check_presign_ttl(ttl: Duration) -> Result<(), BlobError> {
76    if ttl < Duration::from_secs(1) || ttl > MAX_PRESIGN_TTL {
77        return Err(BlobError::Operation(format!(
78            "presign ttl must be between 1 and {} seconds, got {}",
79            MAX_PRESIGN_TTL.as_secs(),
80            ttl.as_secs(),
81        )));
82    }
83    Ok(())
84}
85
86/// Rejects a write whose payload exceeds [`MAX_BLOB_BYTES`] (issue #136).
87/// Every [`Blob::put`] implementation MUST call this before touching
88/// storage; [`ScopedBlob`] (the module-facing wrapper) already does, so
89/// an adapter's own call is the second, defence-in-depth gate for
90/// runtimes-wired stores and direct users.
91///
92/// # Errors
93///
94/// [`BlobError::TooLarge`] when `bytes.len()` exceeds the bound.
95pub fn check_blob_size(bytes: &[u8]) -> Result<(), BlobError> {
96    if bytes.len() > MAX_BLOB_BYTES {
97        return Err(BlobError::TooLarge(format!(
98            "blob of {} bytes exceeds the {MAX_BLOB_BYTES}-byte bound",
99            bytes.len()
100        )));
101    }
102    Ok(())
103}
104
105/// A stored object: its bytes and the content type to serve it with.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct BlobObject {
108    pub bytes: Vec<u8>,
109    pub content_type: String,
110}
111
112/// What a store knows about an object *without* reading its bytes
113/// (issue #586): the metadata a [`Blob::head`] call returns, and one row
114/// of a [`BlobPage`].
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct BlobMeta {
117    /// The key, module-relative when it came through a [`ScopedBlob`].
118    pub key: String,
119    /// The object's size in bytes.
120    pub size: u64,
121    /// The content type it was stored with.
122    pub content_type: String,
123}
124
125/// One page of a [`Blob::list`] (issue #586). `cursor` is `None` on the
126/// last page and otherwise an opaque token to hand straight back to the
127/// next `list` call — the module never parses it.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct BlobPage {
130    /// The objects on this page, in lexicographic key order.
131    pub objects: Vec<BlobMeta>,
132    /// The token for the next page, or `None` when this is the last one.
133    pub cursor: Option<String>,
134}
135
136/// A streamed object body (issue #586): what [`Blob::get_stream`] returns
137/// instead of buffering the bytes. `size` and `content_type` are known
138/// before a single chunk is read, so a handler can set response headers
139/// from them.
140pub struct BlobStream {
141    /// The content type the object was stored with.
142    pub content_type: String,
143    /// The object's size in bytes, known up front.
144    pub size: u64,
145    /// The body chunks. A chunk error is a mid-stream transport failure.
146    pub body: BoxStream<'static, Result<Bytes, StreamError>>,
147}
148
149impl BlobStream {
150    /// Turns the body into a [`ResponseStream`], so a handler on a route
151    /// its module declared in
152    /// [`Module::streaming_routes`](crate::Module::streaming_routes) can
153    /// return an object without buffering it first (issue #586).
154    #[must_use]
155    pub fn into_response_stream(self) -> ResponseStream {
156        ResponseStream::new(self.body)
157    }
158}
159
160// Hand-written because `BoxStream` is not `Debug`; the body is named as a
161// placeholder rather than dumped.
162impl fmt::Debug for BlobStream {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        f.debug_struct("BlobStream")
165            .field("content_type", &self.content_type)
166            .field("size", &self.size)
167            .field("body", &"<stream>")
168            .finish()
169    }
170}
171
172/// Identifies one multipart upload in flight (issue #586). Opaque: an
173/// adapter mints it in [`Blob::create_multipart`] and a module only ever
174/// hands it back to the other multipart methods.
175#[derive(Debug, Clone, PartialEq, Eq, Hash)]
176pub struct UploadId(String);
177
178impl UploadId {
179    /// Wraps an adapter-minted id.
180    #[must_use]
181    pub fn new(id: impl Into<String>) -> Self {
182        Self(id.into())
183    }
184
185    /// The id as a string, for an adapter that has to name it in a URL or
186    /// a store request.
187    #[must_use]
188    pub fn as_str(&self) -> &str {
189        &self.0
190    }
191}
192
193/// One part that was uploaded, as S3 and R2 report it: its number and the
194/// `ETag` the store assigned (issue #586). A module collects these from
195/// [`Blob::upload_part`] and passes them, in ascending part order, to
196/// [`Blob::complete_multipart`].
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct PartReceipt {
199    /// The part's 1-based number, `1 ..= MAX_MULTIPART_PARTS`.
200    pub part_number: u16,
201    /// The store's `ETag` for the part.
202    pub etag: String,
203}
204
205/// A multipart upload the module started but has not completed or aborted
206/// (issue #586): what [`Blob::list_multipart_uploads`] reports, so a
207/// module can find and abort the ones it abandoned.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct PendingUpload {
210    /// The target key, module-relative when it came through a
211    /// [`ScopedBlob`].
212    pub key: String,
213    /// The upload's id.
214    pub upload_id: UploadId,
215}
216
217/// Rejects a multipart part number outside `1 ..= MAX_MULTIPART_PARTS`
218/// (issue #586) before any store sees it.
219///
220/// # Errors
221///
222/// [`BlobError::Operation`] when `n` is zero or past the last part — the
223/// number is a caller mistake, not a bad key, and [`BlobError`] is not
224/// `#[non_exhaustive]`, so [`BlobError::Operation`] carries it.
225pub fn check_part_number(n: u16) -> Result<(), BlobError> {
226    if n == 0 || n > MAX_MULTIPART_PARTS {
227        return Err(BlobError::Operation(format!(
228            "multipart part number must be between 1 and {MAX_MULTIPART_PARTS}, got {n}"
229        )));
230    }
231    Ok(())
232}
233
234/// Wraps a byte-chunk stream so that the chunk which would push the total
235/// past `max_bytes` is replaced by [`StreamError::TooLarge`] (it is not
236/// delivered), the source is dropped, and every later poll answers `None`
237/// (issue #586). The tool an adapter reaches for to implement
238/// [`Blob::put_stream`] and [`Blob::upload_part`] without buffering, and
239/// the same enforcement, one layer down, that [`ScopedBlob`] applies.
240///
241/// Written out by hand because core depends on `futures-core` only — no
242/// `futures-util` `StreamExt`.
243#[must_use]
244pub fn limit_stream(
245    body: BoxStream<'static, Result<Bytes, StreamError>>,
246    max_bytes: u64,
247) -> BoxStream<'static, Result<Bytes, StreamError>> {
248    Box::pin(LimitStream {
249        inner: Some(body),
250        delivered: 0,
251        max: max_bytes,
252    })
253}
254
255/// Maps a mid-stream failure to a [`BlobError`] (issue #586): a ceiling
256/// breach is [`BlobError::TooLarge`], a transport failure is
257/// [`BlobError::Operation`] with the detail for logs.
258#[must_use]
259pub fn blob_error_from_stream(error: StreamError) -> BlobError {
260    error.into()
261}
262
263impl From<StreamError> for BlobError {
264    fn from(error: StreamError) -> Self {
265        match error {
266            StreamError::TooLarge => {
267                Self::TooLarge("the streamed body exceeded its ceiling".to_owned())
268            }
269            StreamError::Transport(detail) => {
270                Self::Operation(format!("blob stream transport error: {detail}"))
271            }
272        }
273    }
274}
275
276/// The [`limit_stream`] enforcer: counts delivered bytes and refuses the
277/// one chunk that crosses `max`.
278struct LimitStream {
279    inner: Option<BoxStream<'static, Result<Bytes, StreamError>>>,
280    delivered: u64,
281    max: u64,
282}
283
284impl Stream for LimitStream {
285    type Item = Result<Bytes, StreamError>;
286
287    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
288        let this = self.get_mut();
289        let polled = match this.inner.as_mut() {
290            Some(stream) => stream.as_mut().poll_next(cx),
291            None => return Poll::Ready(None),
292        };
293        match polled {
294            Poll::Ready(Some(Ok(chunk))) => {
295                if this.delivered.saturating_add(chunk.len() as u64) > this.max {
296                    // Drop the source and fuse: nothing further is read.
297                    this.inner = None;
298                    Poll::Ready(Some(Err(StreamError::TooLarge)))
299                } else {
300                    this.delivered += chunk.len() as u64;
301                    Poll::Ready(Some(Ok(chunk)))
302                }
303            }
304            Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
305            Poll::Ready(None) => {
306                this.inner = None;
307                Poll::Ready(None)
308            }
309            Poll::Pending => Poll::Pending,
310        }
311    }
312}
313
314/// Blob store failures.
315#[derive(Debug, Clone, Error)]
316pub enum BlobError {
317    /// The store rejected or could not complete the operation.
318    #[error("blob operation failed: {0}")]
319    Operation(String),
320    /// The key is empty, absolute, or tries to escape its prefix (`..`).
321    #[error("invalid blob key: {0}")]
322    BadKey(String),
323    /// The object is bigger than [`MAX_BLOB_BYTES`] (issue #136).
324    #[error("blob rejected by size: {0}")]
325    TooLarge(String),
326    /// The adapter does not support this operation (e.g. a directory store has
327    /// no presigned URLs).
328    #[error("blob operation not supported: {0}")]
329    Unsupported(String),
330}
331
332/// A presigned upload: the URL to `PUT` to, the method, and the headers the
333/// client must send **exactly** — the signature covers each signed header, so
334/// a different value is refused by the store (issue #622).
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct PresignedPut {
337    /// The `https://…` URL, query string and all. A bearer credential until
338    /// it expires: never log it.
339    pub url: String,
340    /// Always `"PUT"`; carried so a caller need not hard-code it.
341    pub method: &'static str,
342    /// Headers the client must send with exactly these values (e.g.
343    /// `Content-Type`, `Content-Length`).
344    pub headers: Vec<(String, String)>,
345}
346
347/// A blob store. Keys are module-prefixed; the harness wraps this in a
348/// [`ScopedBlob`] per module so a module cannot name another's objects.
349#[async_trait]
350pub trait Blob: Send + Sync {
351    /// Stores `bytes` at `key` with `content_type`, replacing any existing
352    /// object. Implementations MUST reject a payload larger than
353    /// [`MAX_BLOB_BYTES`] with [`BlobError::TooLarge`] via
354    /// [`check_blob_size`], before writing (issue #136).
355    async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError>;
356    /// Fetches the object at `key`, or `None` if there is none.
357    async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError>;
358    /// Removes the object at `key`. Idempotent: removing a missing key is `Ok`.
359    async fn delete(&self, key: &str) -> Result<(), BlobError>;
360    /// A presigned `GET` for `key`, valid for `ttl`: a URL that serves the
361    /// object directly, skipping the Worker. Adapters without presigned URLs
362    /// (a directory store) return [`BlobError::Unsupported`]; callers then
363    /// serve the bytes through [`get`](Blob::get). The URL's query string is
364    /// a bearer credential: never log it.
365    async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError>;
366    /// A presigned `PUT` for `key` with `content_type`, valid for `ttl`.
367    ///
368    /// The signature cannot cover the body, but when `content_length` is
369    /// `Some` adapters sign `Content-Length` for that value, so a store that
370    /// honours it rejects any other size. The returned [`PresignedPut`] names
371    /// the headers the client must send exactly. Adapters without presigned
372    /// URLs return [`BlobError::Unsupported`] (the default). The URL's query
373    /// string is a bearer credential: never log it.
374    async fn signed_put_url(
375        &self,
376        key: &str,
377        content_type: &str,
378        content_length: Option<u64>,
379        ttl: Duration,
380    ) -> Result<PresignedPut, BlobError> {
381        let _ = (key, content_type, content_length, ttl);
382        Err(BlobError::Unsupported(
383            "this blob store has no presigned uploads".to_owned(),
384        ))
385    }
386
387    /// Stores a streamed body at `key` (issue #586), for an object larger
388    /// than the [`MAX_BLOB_BYTES`] buffered path allows, and returns the
389    /// number of bytes written. `max_bytes` is the caller's ceiling;
390    /// adapters MUST enforce it (typically by wrapping `body` in
391    /// [`limit_stream`]) and, once it is passed, answer
392    /// [`BlobError::TooLarge`] and leave **no** object behind.
393    ///
394    /// The module-facing [`ScopedBlob`] lowers `max_bytes` to the
395    /// module's declared ceiling before an adapter sees it. Unlike
396    /// [`put`](Blob::put), which stays capped at [`MAX_BLOB_BYTES`], this
397    /// path may carry an object up to
398    /// [`MAX_LARGE_BLOB_BYTES`]. The default answers
399    /// [`BlobError::Unsupported`], so an adapter that has not implemented
400    /// streaming compiles and behaves unchanged.
401    async fn put_stream(
402        &self,
403        key: &str,
404        body: BoxStream<'static, Result<Bytes, StreamError>>,
405        content_type: &str,
406        max_bytes: u64,
407    ) -> Result<u64, BlobError> {
408        let _ = (key, body, content_type, max_bytes);
409        Err(BlobError::Unsupported(
410            "this blob store has no streamed puts".to_owned(),
411        ))
412    }
413
414    /// Fetches the object at `key` as a stream (issue #586), or `None` if
415    /// there is none. The size and content type are known before the body
416    /// is read, so a handler can set headers and hand the body on without
417    /// buffering it.
418    ///
419    /// The default answers [`BlobError::Unsupported`].
420    async fn get_stream(&self, key: &str) -> Result<Option<BlobStream>, BlobError> {
421        let _ = key;
422        Err(BlobError::Unsupported(
423            "this blob store has no streamed gets".to_owned(),
424        ))
425    }
426
427    /// Reports the object's metadata without reading its bytes (issue
428    /// #586), or `None` if there is none.
429    ///
430    /// The default answers [`BlobError::Unsupported`].
431    async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
432        let _ = key;
433        Err(BlobError::Unsupported(
434            "this blob store has no head".to_owned(),
435        ))
436    }
437
438    /// Lists stored objects whose key starts with `prefix` (issue #586),
439    /// newest page at a time. Keys are returned in lexicographic order;
440    /// `cursor` is the opaque token from the previous [`BlobPage`], or
441    /// `None` to start at the beginning. Adapters cap `limit` at
442    /// [`MAX_LIST_LIMIT`].
443    ///
444    /// The default answers [`BlobError::Unsupported`].
445    async fn list(
446        &self,
447        prefix: &str,
448        cursor: Option<&str>,
449        limit: usize,
450    ) -> Result<BlobPage, BlobError> {
451        let _ = (prefix, cursor, limit);
452        Err(BlobError::Unsupported(
453            "this blob store has no listings".to_owned(),
454        ))
455    }
456
457    /// Starts a multipart upload for `key` with `content_type` (issue
458    /// #586) and returns its id. Parts are then written with
459    /// [`upload_part`](Blob::upload_part) and the object is made visible
460    /// by [`complete_multipart`](Blob::complete_multipart); an upload the
461    /// module abandons must be [`abort_multipart`](Blob::abort_multipart)ed,
462    /// or reclaimed by an R2 lifecycle rule, or its parts linger.
463    ///
464    /// The default answers [`BlobError::Unsupported`].
465    async fn create_multipart(&self, key: &str, content_type: &str) -> Result<UploadId, BlobError> {
466        let _ = (key, content_type);
467        Err(BlobError::Unsupported(
468            "this blob store has no multipart uploads".to_owned(),
469        ))
470    }
471
472    /// Uploads one part of a multipart upload (issue #586),
473    /// `1 ..= MAX_MULTIPART_PARTS`, and returns its [`PartReceipt`].
474    /// `max_part_bytes` is the caller's ceiling, enforced like
475    /// [`put_stream`](Blob::put_stream)'s. Every part but the last listed
476    /// in [`complete_multipart`](Blob::complete_multipart) must be at
477    /// least [`MIN_MULTIPART_PART_BYTES`]; adapters may enforce it at
478    /// complete, when the last part is known.
479    ///
480    /// The default answers [`BlobError::Unsupported`].
481    async fn upload_part(
482        &self,
483        key: &str,
484        upload_id: &UploadId,
485        part_number: u16,
486        body: BoxStream<'static, Result<Bytes, StreamError>>,
487        max_part_bytes: u64,
488    ) -> Result<PartReceipt, BlobError> {
489        let _ = (key, upload_id, part_number, body, max_part_bytes);
490        Err(BlobError::Unsupported(
491            "this blob store has no multipart uploads".to_owned(),
492        ))
493    }
494
495    /// Assembles the parts listed in `parts` (in ascending part order)
496    /// into the object at `key` and ends the upload (issue #586). A part
497    /// whose `ETag` does not match what was uploaded is refused; an
498    /// unknown upload id is [`BlobError::Operation`].
499    ///
500    /// The default answers [`BlobError::Unsupported`].
501    async fn complete_multipart(
502        &self,
503        key: &str,
504        upload_id: &UploadId,
505        parts: &[PartReceipt],
506    ) -> Result<(), BlobError> {
507        let _ = (key, upload_id, parts);
508        Err(BlobError::Unsupported(
509            "this blob store has no multipart uploads".to_owned(),
510        ))
511    }
512
513    /// Abandons a multipart upload (issue #586), discarding its parts. An
514    /// unknown upload id is [`BlobError::Operation`].
515    ///
516    /// The default answers [`BlobError::Unsupported`].
517    async fn abort_multipart(&self, key: &str, upload_id: &UploadId) -> Result<(), BlobError> {
518        let _ = (key, upload_id);
519        Err(BlobError::Unsupported(
520            "this blob store has no multipart uploads".to_owned(),
521        ))
522    }
523
524    /// Lists the module's multipart uploads in flight whose key starts
525    /// with `prefix` (issue #586), so it can abort the ones it abandoned.
526    ///
527    /// The default answers [`BlobError::Unsupported`].
528    async fn list_multipart_uploads(&self, prefix: &str) -> Result<Vec<PendingUpload>, BlobError> {
529        let _ = prefix;
530        Err(BlobError::Unsupported(
531            "this blob store has no multipart uploads".to_owned(),
532        ))
533    }
534}
535
536/// Wraps a [`Blob`] so every key is prefixed with `<module>/` and no key can
537/// escape it. The harness applies this in `Ports::view_for`, so a module sees a
538/// store scoped to itself — the blob equivalent of the table-ownership check.
539pub struct ScopedBlob {
540    inner: Arc<dyn Blob>,
541    prefix: String,
542    /// The ceiling this view enforces on the streamed and multipart write
543    /// paths (issue #586), lower than the store's own. Defaults to the
544    /// buffered [`MAX_BLOB_BYTES`], so a view that says nothing changes
545    /// nothing.
546    max_object_bytes: u64,
547}
548
549impl ScopedBlob {
550    /// Scopes `inner` to `<module>/`.
551    #[must_use]
552    pub fn new(inner: Arc<dyn Blob>, module: &str) -> Self {
553        Self {
554            inner,
555            prefix: format!("{module}/"),
556            max_object_bytes: MAX_BLOB_BYTES as u64,
557        }
558    }
559
560    /// Sets the per-object ceiling this view enforces on
561    /// [`Blob::put_stream`], [`Blob::upload_part`] and
562    /// [`Blob::complete_multipart`] (issue #586). Clamped to
563    /// [`MAX_LARGE_BLOB_BYTES`], so no module can declare its way past
564    /// what the store itself accepts. The harness sets it from
565    /// [`Module::max_blob_object_bytes`](crate::Module::max_blob_object_bytes)
566    /// in `Ports::view_for`.
567    #[must_use]
568    pub fn with_max_object_bytes(mut self, max: u64) -> Self {
569        self.max_object_bytes = max.min(MAX_LARGE_BLOB_BYTES);
570        self
571    }
572
573    /// Prefixes a caller key, refusing one that is empty, absolute, or walks
574    /// out of the prefix with `..`.
575    fn scope(&self, key: &str) -> Result<String, BlobError> {
576        if key.is_empty() {
577            return Err(BlobError::BadKey("a blob key cannot be empty".to_owned()));
578        }
579        if key.starts_with('/') {
580            return Err(BlobError::BadKey(format!("key `{key}` must be relative")));
581        }
582        if key
583            .split('/')
584            .any(|segment| segment == ".." || segment == ".")
585        {
586            return Err(BlobError::BadKey(format!(
587                "key `{key}` must not contain `.` or `..` segments"
588            )));
589        }
590        Ok(format!("{}{key}", self.prefix))
591    }
592
593    /// Prefixes a caller-supplied *listing* prefix. Like [`Self::scope`],
594    /// but an empty prefix is allowed and means "every key this module
595    /// owns" — a listing names a namespace, not one key (issue #586).
596    fn scope_prefix(&self, prefix: &str) -> Result<String, BlobError> {
597        if prefix.is_empty() {
598            return Ok(self.prefix.clone());
599        }
600        if prefix.starts_with('/') {
601            return Err(BlobError::BadKey(format!(
602                "prefix `{prefix}` must be relative"
603            )));
604        }
605        if prefix
606            .split('/')
607            .any(|segment| segment == ".." || segment == ".")
608        {
609            return Err(BlobError::BadKey(format!(
610                "prefix `{prefix}` must not contain `.` or `..` segments"
611            )));
612        }
613        Ok(format!("{}{prefix}", self.prefix))
614    }
615
616    /// Strips this view's module prefix from a key the store returned, or
617    /// `None` when the key is not under it — the defence-in-depth filter
618    /// that keeps a listing or a `head` from ever surfacing another
619    /// module's object (issue #586).
620    fn unscope(&self, key: &str) -> Option<String> {
621        key.strip_prefix(&self.prefix).map(str::to_owned)
622    }
623}
624
625#[async_trait]
626impl Blob for ScopedBlob {
627    async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
628        check_blob_size(bytes)?;
629        self.inner.put(&self.scope(key)?, bytes, content_type).await
630    }
631    async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
632        self.inner.get(&self.scope(key)?).await
633    }
634    async fn delete(&self, key: &str) -> Result<(), BlobError> {
635        self.inner.delete(&self.scope(key)?).await
636    }
637    async fn signed_url(&self, key: &str, ttl: Duration) -> Result<String, BlobError> {
638        let key = self.scope(key)?;
639        check_presign_ttl(ttl)?;
640        self.inner.signed_url(&key, ttl).await
641    }
642    async fn signed_put_url(
643        &self,
644        key: &str,
645        content_type: &str,
646        content_length: Option<u64>,
647        ttl: Duration,
648    ) -> Result<PresignedPut, BlobError> {
649        let key = self.scope(key)?;
650        check_presign_ttl(ttl)?;
651        self.inner
652            .signed_put_url(&key, content_type, content_length, ttl)
653            .await
654    }
655    async fn put_stream(
656        &self,
657        key: &str,
658        body: BoxStream<'static, Result<Bytes, StreamError>>,
659        content_type: &str,
660        max_bytes: u64,
661    ) -> Result<u64, BlobError> {
662        let key = self.scope(key)?;
663        let effective = max_bytes.min(self.max_object_bytes);
664        self.inner
665            .put_stream(&key, body, content_type, effective)
666            .await
667    }
668    async fn get_stream(&self, key: &str) -> Result<Option<BlobStream>, BlobError> {
669        let key = self.scope(key)?;
670        self.inner.get_stream(&key).await
671    }
672    async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
673        let scoped = self.scope(key)?;
674        Ok(self.inner.head(&scoped).await?.map(|mut meta| {
675            // We scoped the key ourselves, so report the caller's original
676            // key rather than trying to unscope whatever the store echoed
677            // back (which need not carry our prefix).
678            key.clone_into(&mut meta.key);
679            meta
680        }))
681    }
682    async fn list(
683        &self,
684        prefix: &str,
685        cursor: Option<&str>,
686        limit: usize,
687    ) -> Result<BlobPage, BlobError> {
688        let scoped = self.scope_prefix(prefix)?;
689        let limit = limit.clamp(1, MAX_LIST_LIMIT);
690        let page = self.inner.list(&scoped, cursor, limit).await?;
691        let objects = page
692            .objects
693            .into_iter()
694            .filter_map(|mut meta| {
695                meta.key = self.unscope(&meta.key)?;
696                Some(meta)
697            })
698            .collect();
699        Ok(BlobPage {
700            objects,
701            cursor: page.cursor,
702        })
703    }
704    async fn create_multipart(&self, key: &str, content_type: &str) -> Result<UploadId, BlobError> {
705        let key = self.scope(key)?;
706        self.inner.create_multipart(&key, content_type).await
707    }
708    async fn upload_part(
709        &self,
710        key: &str,
711        upload_id: &UploadId,
712        part_number: u16,
713        body: BoxStream<'static, Result<Bytes, StreamError>>,
714        max_part_bytes: u64,
715    ) -> Result<PartReceipt, BlobError> {
716        check_part_number(part_number)?;
717        let key = self.scope(key)?;
718        let effective = max_part_bytes.min(self.max_object_bytes);
719        self.inner
720            .upload_part(&key, upload_id, part_number, body, effective)
721            .await
722    }
723    async fn complete_multipart(
724        &self,
725        key: &str,
726        upload_id: &UploadId,
727        parts: &[PartReceipt],
728    ) -> Result<(), BlobError> {
729        if parts.is_empty() {
730            return Err(BlobError::Operation(
731                "a multipart upload needs at least one part".to_owned(),
732            ));
733        }
734        if parts.len() > usize::from(MAX_MULTIPART_PARTS) {
735            return Err(BlobError::Operation(format!(
736                "a multipart upload takes at most {MAX_MULTIPART_PARTS} parts"
737            )));
738        }
739        let key = self.scope(key)?;
740        self.inner
741            .complete_multipart(&key, upload_id, parts)
742            .await?;
743        // The adapter assembled the object; the ceiling is enforced here,
744        // on the finished size, because no single part knows it.
745        match self.inner.head(&key).await? {
746            Some(meta) if meta.size > self.max_object_bytes => {
747                self.inner.delete(&key).await?;
748                Err(BlobError::TooLarge(format!(
749                    "completed object of {} bytes exceeds the {}-byte bound",
750                    meta.size, self.max_object_bytes
751                )))
752            }
753            Some(_) => Ok(()),
754            // The adapter reported the completion succeeded but the object
755            // is not there: fail closed rather than trust an absent size.
756            None => Err(BlobError::Operation(
757                "completed object not found".to_owned(),
758            )),
759        }
760    }
761    async fn abort_multipart(&self, key: &str, upload_id: &UploadId) -> Result<(), BlobError> {
762        let key = self.scope(key)?;
763        self.inner.abort_multipart(&key, upload_id).await
764    }
765    async fn list_multipart_uploads(&self, prefix: &str) -> Result<Vec<PendingUpload>, BlobError> {
766        let scoped = self.scope_prefix(prefix)?;
767        let uploads = self.inner.list_multipart_uploads(&scoped).await?;
768        Ok(uploads
769            .into_iter()
770            .filter_map(|mut upload| {
771                upload.key = self.unscope(&upload.key)?;
772                Some(upload)
773            })
774            .collect())
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    // A recording test fixture, not request state (ADR 0007). The scoped
781    // allow follows the policy in the workspace clippy.toml, as the fakes
782    // in `cratefield-testing` and the sibling tests in this crate do.
783    #![allow(clippy::disallowed_types)]
784
785    use super::*;
786    use std::sync::Mutex;
787
788    /// An in-memory blob store for testing the scoping.
789    #[derive(Default)]
790    struct MemBlob {
791        objects: Mutex<std::collections::HashMap<String, BlobObject>>,
792        /// The keys the presign methods were reached with, so a test can prove
793        /// a refused call never arrived.
794        presigned: Mutex<Vec<String>>,
795    }
796
797    #[async_trait]
798    impl Blob for MemBlob {
799        async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
800            self.objects.lock().unwrap().insert(
801                key.to_owned(),
802                BlobObject {
803                    bytes: bytes.to_vec(),
804                    content_type: content_type.to_owned(),
805                },
806            );
807            Ok(())
808        }
809        async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
810            Ok(self.objects.lock().unwrap().get(key).cloned())
811        }
812        async fn delete(&self, key: &str) -> Result<(), BlobError> {
813            self.objects.lock().unwrap().remove(key);
814            Ok(())
815        }
816        async fn signed_url(&self, key: &str, _ttl: Duration) -> Result<String, BlobError> {
817            self.presigned.lock().unwrap().push(key.to_owned());
818            Ok(format!("mem://{key}"))
819        }
820        async fn signed_put_url(
821            &self,
822            key: &str,
823            content_type: &str,
824            _content_length: Option<u64>,
825            _ttl: Duration,
826        ) -> Result<PresignedPut, BlobError> {
827            self.presigned.lock().unwrap().push(key.to_owned());
828            Ok(PresignedPut {
829                url: format!("mem://{key}"),
830                method: "PUT",
831                headers: vec![("content-type".to_owned(), content_type.to_owned())],
832            })
833        }
834        async fn put_stream(
835            &self,
836            key: &str,
837            body: BoxStream<'static, Result<Bytes, StreamError>>,
838            content_type: &str,
839            max_bytes: u64,
840        ) -> Result<u64, BlobError> {
841            let mut limited = limit_stream(body, max_bytes);
842            let mut bytes = Vec::new();
843            while let Some(chunk) = std::future::poll_fn(|cx| limited.as_mut().poll_next(cx)).await
844            {
845                bytes.extend_from_slice(&chunk.map_err(blob_error_from_stream)?);
846            }
847            let written = bytes.len() as u64;
848            self.objects.lock().unwrap().insert(
849                key.to_owned(),
850                BlobObject {
851                    bytes,
852                    content_type: content_type.to_owned(),
853                },
854            );
855            Ok(written)
856        }
857        async fn head(&self, key: &str) -> Result<Option<BlobMeta>, BlobError> {
858            Ok(self
859                .objects
860                .lock()
861                .unwrap()
862                .get(key)
863                .map(|object| BlobMeta {
864                    key: key.to_owned(),
865                    size: object.bytes.len() as u64,
866                    content_type: object.content_type.clone(),
867                }))
868        }
869        async fn list(
870            &self,
871            prefix: &str,
872            cursor: Option<&str>,
873            limit: usize,
874        ) -> Result<BlobPage, BlobError> {
875            let objects = self.objects.lock().unwrap();
876            let mut keys: Vec<String> = objects
877                .keys()
878                .filter(|key| key.starts_with(prefix))
879                .cloned()
880                .collect();
881            keys.sort();
882            let start = cursor.map_or(0, |cursor| {
883                keys.partition_point(|key| key.as_str() <= cursor)
884            });
885            let take = (keys.len() - start).min(limit);
886            let selected = &keys[start..start + take];
887            let objects = selected
888                .iter()
889                .map(|key| {
890                    let object = &objects[key];
891                    BlobMeta {
892                        key: key.clone(),
893                        size: object.bytes.len() as u64,
894                        content_type: object.content_type.clone(),
895                    }
896                })
897                .collect();
898            let cursor = if start + take < keys.len() {
899                selected.last().cloned()
900            } else {
901                None
902            };
903            Ok(BlobPage { objects, cursor })
904        }
905    }
906
907    /// A `Stream` over pre-built chunks, for the `limit_stream` and
908    /// streamed-write tests. Written out because core has no `futures-util`.
909    struct Chunks(std::vec::IntoIter<Result<Bytes, StreamError>>);
910
911    impl Stream for Chunks {
912        type Item = Result<Bytes, StreamError>;
913
914        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
915            Poll::Ready(self.get_mut().0.next())
916        }
917    }
918
919    /// Type-erases `chunks` into the body shape the blob port takes.
920    fn chunk_stream(chunks: Vec<Vec<u8>>) -> BoxStream<'static, Result<Bytes, StreamError>> {
921        Box::pin(Chunks(
922            chunks
923                .into_iter()
924                .map(|chunk| Ok(Bytes::from(chunk)))
925                .collect::<Vec<_>>()
926                .into_iter(),
927        ))
928    }
929
930    /// Implements only the required [`Blob`] methods, so the provided
931    /// [`Blob::signed_put_url`] default is what a test exercises.
932    struct BareBlob;
933
934    #[async_trait]
935    impl Blob for BareBlob {
936        async fn put(
937            &self,
938            _key: &str,
939            _bytes: &[u8],
940            _content_type: &str,
941        ) -> Result<(), BlobError> {
942            Ok(())
943        }
944        async fn get(&self, _key: &str) -> Result<Option<BlobObject>, BlobError> {
945            Ok(None)
946        }
947        async fn delete(&self, _key: &str) -> Result<(), BlobError> {
948            Ok(())
949        }
950        async fn signed_url(&self, _key: &str, _ttl: Duration) -> Result<String, BlobError> {
951            Err(BlobError::Unsupported("memory store".to_owned()))
952        }
953    }
954
955    #[pollster::test]
956    async fn a_scoped_blob_prefixes_the_key() {
957        let mem = Arc::new(MemBlob::default());
958        let scoped = ScopedBlob::new(mem.clone(), "waitlist");
959        scoped.put("clip.mp3", b"x", "audio/mpeg").await.unwrap();
960        // The underlying store sees the prefixed key.
961        assert!(mem.get("waitlist/clip.mp3").await.unwrap().is_some());
962        assert!(mem.get("clip.mp3").await.unwrap().is_none());
963        // And the scoped view reads it back by the bare key.
964        assert!(scoped.get("clip.mp3").await.unwrap().is_some());
965    }
966
967    #[pollster::test]
968    async fn a_scoped_blob_refuses_an_escaping_key() {
969        let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
970        for bad in ["", "/etc/passwd", "../secrets/x", "a/../../b", "."] {
971            assert!(
972                matches!(scoped.get(bad).await.unwrap_err(), BlobError::BadKey(_)),
973                "key `{bad}` should be refused"
974            );
975        }
976    }
977
978    #[pollster::test]
979    async fn delete_is_idempotent() {
980        let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
981        scoped.delete("missing").await.expect("no-op delete is ok");
982    }
983
984    #[pollster::test]
985    async fn an_oversized_put_is_refused_before_the_store_is_reached() {
986        let mem = Arc::new(MemBlob::default());
987        let scoped = ScopedBlob::new(mem.clone(), "cms");
988        let oversized = vec![0_u8; MAX_BLOB_BYTES + 1];
989        let err = scoped
990            .put("big.bin", &oversized, "application/octet-stream")
991            .await
992            .expect_err("one byte past the bound must be refused");
993        assert!(matches!(err, BlobError::TooLarge(_)), "got {err}");
994        assert!(
995            mem.get("cms/big.bin").await.unwrap().is_none(),
996            "the store never saw the rejected key"
997        );
998    }
999
1000    #[pollster::test]
1001    async fn a_put_at_exactly_the_bound_is_accepted() {
1002        let scoped = ScopedBlob::new(Arc::new(MemBlob::default()), "cms");
1003        let exact = vec![0_u8; MAX_BLOB_BYTES];
1004        scoped
1005            .put("edge.bin", &exact, "application/octet-stream")
1006            .await
1007            .expect("the bound itself is allowed");
1008    }
1009
1010    #[pollster::test]
1011    async fn a_scoped_blob_prefixes_both_presign_keys() {
1012        let mem = Arc::new(MemBlob::default());
1013        let scoped = ScopedBlob::new(mem.clone(), "cms");
1014        let get = scoped
1015            .signed_url("clip.mp3", Duration::from_secs(60))
1016            .await
1017            .unwrap();
1018        assert_eq!(get, "mem://cms/clip.mp3");
1019        let put = scoped
1020            .signed_put_url("clip.mp3", "audio/mpeg", Some(3), Duration::from_secs(60))
1021            .await
1022            .unwrap();
1023        assert_eq!(put.url, "mem://cms/clip.mp3");
1024        assert_eq!(
1025            *mem.presigned.lock().unwrap(),
1026            vec!["cms/clip.mp3".to_owned(), "cms/clip.mp3".to_owned()],
1027        );
1028    }
1029
1030    #[pollster::test]
1031    async fn a_scoped_blob_refuses_an_escaping_presign_key_before_the_store() {
1032        let mem = Arc::new(MemBlob::default());
1033        let scoped = ScopedBlob::new(mem.clone(), "cms");
1034        for bad in ["", "/etc/passwd", "../secrets/x"] {
1035            assert!(
1036                matches!(
1037                    scoped
1038                        .signed_url(bad, Duration::from_secs(60))
1039                        .await
1040                        .unwrap_err(),
1041                    BlobError::BadKey(_)
1042                ),
1043                "key `{bad}` should be refused"
1044            );
1045            assert!(
1046                matches!(
1047                    scoped
1048                        .signed_put_url(bad, "image/png", None, Duration::from_secs(60))
1049                        .await
1050                        .unwrap_err(),
1051                    BlobError::BadKey(_)
1052                ),
1053                "key `{bad}` should be refused"
1054            );
1055        }
1056        assert!(
1057            mem.presigned.lock().unwrap().is_empty(),
1058            "the store never saw a key"
1059        );
1060    }
1061
1062    #[pollster::test]
1063    async fn a_scoped_blob_refuses_a_presign_ttl_outside_the_bound() {
1064        let mem = Arc::new(MemBlob::default());
1065        let scoped = ScopedBlob::new(mem.clone(), "cms");
1066        for bad in [
1067            // A sub-second TTL truncates to `X-Amz-Expires=0`, so it is
1068            // refused alongside zero and over-long ones.
1069            Duration::from_millis(500),
1070            Duration::ZERO,
1071            MAX_PRESIGN_TTL + Duration::from_secs(1),
1072        ] {
1073            assert!(
1074                matches!(
1075                    scoped.signed_url("clip.mp3", bad).await.unwrap_err(),
1076                    BlobError::Operation(_)
1077                ),
1078                "ttl {bad:?} should be refused"
1079            );
1080        }
1081        // The bound itself is allowed, and the store is reached.
1082        scoped
1083            .signed_url("clip.mp3", MAX_PRESIGN_TTL)
1084            .await
1085            .unwrap();
1086        assert_eq!(mem.presigned.lock().unwrap().len(), 1);
1087    }
1088
1089    #[pollster::test]
1090    async fn the_default_signed_put_url_is_unsupported() {
1091        let scoped = ScopedBlob::new(Arc::new(BareBlob), "cms");
1092        assert!(matches!(
1093            scoped
1094                .signed_put_url("clip.mp3", "audio/mpeg", None, Duration::from_secs(60))
1095                .await
1096                .unwrap_err(),
1097            BlobError::Unsupported(_)
1098        ));
1099    }
1100
1101    #[test]
1102    fn the_ceiling_defaults_to_the_small_bound_and_clamps_to_the_large_one() {
1103        let default = ScopedBlob::new(Arc::new(BareBlob), "cms");
1104        assert_eq!(
1105            default.max_object_bytes, MAX_BLOB_BYTES as u64,
1106            "a view that says nothing keeps the buffered bound"
1107        );
1108        let raised = ScopedBlob::new(Arc::new(BareBlob), "cms").with_max_object_bytes(u64::MAX);
1109        assert_eq!(
1110            raised.max_object_bytes, MAX_LARGE_BLOB_BYTES,
1111            "a declaration cannot outrun the harness-wide ceiling"
1112        );
1113        let lowered = ScopedBlob::new(Arc::new(BareBlob), "cms").with_max_object_bytes(4096);
1114        assert_eq!(lowered.max_object_bytes, 4096, "lowering is honoured");
1115    }
1116
1117    #[test]
1118    fn check_part_number_bounds_the_one_based_range() {
1119        assert!(check_part_number(1).is_ok());
1120        assert!(check_part_number(MAX_MULTIPART_PARTS).is_ok());
1121        assert!(matches!(
1122            check_part_number(0).unwrap_err(),
1123            BlobError::Operation(_)
1124        ));
1125        assert!(matches!(
1126            check_part_number(MAX_MULTIPART_PARTS + 1).unwrap_err(),
1127            BlobError::Operation(_)
1128        ));
1129    }
1130
1131    #[pollster::test]
1132    async fn limit_stream_refuses_the_crossing_chunk_and_fuses() {
1133        let mut stream = limit_stream(
1134            chunk_stream(vec![b"12345".to_vec(), b"67890".to_vec(), b"x".to_vec()]),
1135            8,
1136        );
1137        let first = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
1138        assert_eq!(first.unwrap().unwrap(), Bytes::from_static(b"12345"));
1139        // 5 + 5 crosses 8: refused, not delivered.
1140        let second = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
1141        assert!(matches!(second.unwrap(), Err(StreamError::TooLarge)));
1142        let third = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
1143        assert!(third.is_none(), "fused after the refusal");
1144    }
1145
1146    #[pollster::test]
1147    async fn list_strips_the_prefix_and_hides_other_modules() {
1148        let mem = Arc::new(MemBlob::default());
1149        mem.put("cms/a.txt", b"a", "text/plain").await.unwrap();
1150        mem.put("cms/b.txt", b"b", "text/plain").await.unwrap();
1151        mem.put("cms2/c.txt", b"c", "text/plain").await.unwrap();
1152        let scoped = ScopedBlob::new(mem, "cms");
1153
1154        let page = scoped.list("", None, 10).await.unwrap();
1155        let keys: Vec<&str> = page.objects.iter().map(|meta| meta.key.as_str()).collect();
1156        assert_eq!(keys, ["a.txt", "b.txt"], "stripped, ordered, scoped");
1157        assert!(page.cursor.is_none());
1158
1159        let narrowed = scoped.list("a", None, 10).await.unwrap();
1160        assert_eq!(narrowed.objects.len(), 1, "the prefix narrows the listing");
1161        assert_eq!(narrowed.objects[0].key, "a.txt");
1162
1163        assert!(matches!(
1164            scoped.list("/abs", None, 10).await.unwrap_err(),
1165            BlobError::BadKey(_)
1166        ));
1167        assert!(matches!(
1168            scoped.list("../x", None, 10).await.unwrap_err(),
1169            BlobError::BadKey(_)
1170        ));
1171    }
1172
1173    #[pollster::test]
1174    async fn put_stream_uses_the_smaller_of_the_call_and_the_ceiling() {
1175        let mem = Arc::new(MemBlob::default());
1176        let scoped = ScopedBlob::new(mem.clone(), "cms").with_max_object_bytes(8);
1177        let err = scoped
1178            .put_stream(
1179                "clip.bin",
1180                chunk_stream(vec![vec![0_u8; 10]]),
1181                "application/octet-stream",
1182                1 << 30,
1183            )
1184            .await
1185            .unwrap_err();
1186        assert!(matches!(err, BlobError::TooLarge(_)), "got {err:?}");
1187        assert!(
1188            mem.get("cms/clip.bin").await.unwrap().is_none(),
1189            "the ceiling left no object behind"
1190        );
1191
1192        let written = scoped
1193            .put_stream(
1194                "clip.bin",
1195                chunk_stream(vec![vec![1_u8; 8]]),
1196                "application/octet-stream",
1197                1 << 30,
1198            )
1199            .await
1200            .unwrap();
1201        assert_eq!(written, 8, "the effective ceiling is the view's own");
1202        assert!(mem.get("cms/clip.bin").await.unwrap().is_some());
1203    }
1204}