Skip to main content

umbral_core/
storage.rs

1//! Storage: the file-bytes backend abstraction and its ambient registry.
2//!
3//! ## What this is
4//!
5//! [`Storage`] is to file bytes what [`crate::db::DbPool`] is to database
6//! rows: a small, backend-agnostic seam the rest of the framework writes
7//! against without caring whether the bytes land on a local filesystem,
8//! S3, or anything else. A plugin (today `umbral-storage` with its
9//! `FsStorage`) provides the concrete impl and registers it as the
10//! ambient default; future `FileField` / `ImageField` and the admin
11//! resolve uploads through [`storage`] without knowing the backend.
12//!
13//! `umbral-core` defines the trait but never names a concrete impl — the
14//! filesystem backend lives in the `umbral-storage` plugin. This is the
15//! dependency-inversion rule from `CLAUDE.md`: dependencies point inward
16//! toward core, control flows outward through the trait. Cargo's ban on
17//! circular deps enforces that core can't reach back into the plugin.
18//!
19//! ## Why an ambient global
20//!
21//! The storage backend is registered once at boot and read ambiently,
22//! exactly like the DB pool (`crate::db`'s `DB_POOL`) and the template
23//! engine — "the one intentional global" family sanctioned in `CLAUDE.md`.
24//! A storage backend is a *backend service* (like the pool), not arbitrary
25//! shared state: threading an `Arc<dyn Storage>` through every field
26//! render, admin view, and upload handler would be the same boilerplate
27//! the pool's `OnceLock` was introduced to avoid. The set-once discipline
28//! (first registration wins; a second warns rather than panics) mirrors
29//! `crate::db::init` / `crate::settings::init`.
30//!
31//! The ORM-only rule (`CLAUDE.md`) governs *database rows*, not file
32//! bytes: `std::fs` / object-store I/O inside a `Storage` impl is the
33//! sanctioned path, not a raw-SQL workaround.
34
35use std::collections::HashMap;
36use std::sync::{Arc, Mutex, OnceLock};
37
38use async_trait::async_trait;
39
40/// Re-export of `async-trait` so a plugin implementing the
41/// `#[async_trait]` [`Storage`] trait can name the attribute without a
42/// direct `async-trait` dep. Surfaced on the facade as
43/// `umbral::storage::async_trait`. Mirrors the forms module's re-export.
44pub use async_trait::async_trait as async_trait_reexport;
45
46/// A boxed, pinned byte-stream — the streaming-upload/download currency of
47/// [`Storage::store_stream`] / [`Storage::retrieve_stream`].
48///
49/// Object-safe (it's a trait object behind a `Box`, so it survives through
50/// `Arc<dyn Storage>` dispatch) and `Send` so it can cross an `.await` on a
51/// multi-threaded runtime. Each item is a `bytes::Bytes` chunk or an
52/// [`std::io::Error`]; an error item aborts the stream.
53pub type ByteStream = std::pin::Pin<
54    Box<dyn futures_util::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>,
55>;
56
57/// The `ErrorKind` a [`cap_stream`] over-limit error carries, so a wrapper
58/// (e.g. `SizeLimitedStorage`) can recognise "the cap tripped" versus a
59/// genuine backend IO failure and map it to [`StorageError::TooLarge`].
60pub const CAP_EXCEEDED_KIND: std::io::ErrorKind = std::io::ErrorKind::Other;
61
62/// Sentinel string carried in a [`cap_stream`] over-limit error's message,
63/// so the cap can be distinguished from any other `ErrorKind::Other`.
64pub const CAP_EXCEEDED_MARKER: &str = "umbral-storage-cap-exceeded";
65
66/// Wrap `body` so it passes bytes through untouched until the cumulative
67/// byte count would exceed `max`, at which point it yields a single
68/// `Err(io::Error)` (kind [`CAP_EXCEEDED_KIND`], message [`CAP_EXCEEDED_MARKER`])
69/// and ends.
70///
71/// **This is the load-bearing security primitive for streaming uploads.**
72/// The cap is enforced *as bytes flow*, never from a declared length: a
73/// client that lies about (or omits) its `Content-Length` is still cut off
74/// the instant the real bytes cross `max`, so an oversized upload can never
75/// be fully written. A wrapper maps the marker error to
76/// [`StorageError::TooLarge`].
77pub fn cap_stream(body: ByteStream, max: u64) -> ByteStream {
78    use futures_util::StreamExt;
79    let mut seen: u64 = 0;
80    let mut tripped = false;
81    let capped = body.flat_map(move |item| {
82        // Once the cap has tripped, end the stream — don't forward more.
83        if tripped {
84            return futures_util::stream::iter(Vec::new());
85        }
86        match item {
87            Ok(chunk) => {
88                seen = seen.saturating_add(chunk.len() as u64);
89                if seen > max {
90                    tripped = true;
91                    let err = std::io::Error::new(CAP_EXCEEDED_KIND, CAP_EXCEEDED_MARKER);
92                    futures_util::stream::iter(vec![Err(err)])
93                } else {
94                    futures_util::stream::iter(vec![Ok(chunk)])
95                }
96            }
97            Err(e) => {
98                tripped = true;
99                futures_util::stream::iter(vec![Err(e)])
100            }
101        }
102    });
103    Box::pin(capped)
104}
105
106/// Is `e` the over-limit error produced by [`cap_stream`]? Used by a
107/// streaming wrapper to map the cap trip onto [`StorageError::TooLarge`]
108/// rather than a generic [`StorageError::Io`].
109pub fn is_cap_exceeded(e: &std::io::Error) -> bool {
110    e.kind() == CAP_EXCEEDED_KIND && e.to_string().contains(CAP_EXCEEDED_MARKER)
111}
112
113/// A storage backend for file bytes.
114///
115/// Implementors persist opaque byte blobs under a generated *key* and
116/// expose them at a public URL. The default impl ships in `umbral-storage`
117/// (`FsStorage`, filesystem-backed); an S3 backend slots in behind the
118/// same trait later (see `docs/decisions/2026-06-02-media-and-s3.md`).
119///
120/// Signed / auth-gated URLs are deliberately out of scope here: [`url`]
121/// returns a *public* URL only. Private media is a deferred v0.x feature.
122///
123/// [`url`]: Storage::url
124#[async_trait]
125pub trait Storage: Send + Sync {
126    /// Persist `bytes` under a freshly generated, collision-resistant key
127    /// derived from `filename`, returning the key plus its public URL.
128    ///
129    /// `content_type` is the MIME type the caller declares; backends may
130    /// record it (e.g. for an S3 object's `Content-Type`) but are not
131    /// required to validate it — the upload handler should validate
132    /// against an allow-list before calling this.
133    async fn store(
134        &self,
135        filename: &str,
136        content_type: &str,
137        bytes: &[u8],
138    ) -> Result<StoredFile, StorageError>;
139
140    /// Persist `bytes` at an **exact** key, rather than minting a new one.
141    ///
142    /// [`store`](Storage::store) generates a fresh collision-resistant key from
143    /// the filename, which is right for a user upload — you never want two users'
144    /// `avatar.png` to collide. But a *derived* object needs to land at a key the
145    /// caller computes: an image variant lives at `<original>__thumb.png` so its
146    /// URL is a pure function of the original's, with no extra column and no
147    /// second lookup.
148    ///
149    /// The default returns [`StorageError::Unsupported`] — additive, so an
150    /// existing backend keeps compiling; it just can't host derived objects until
151    /// it implements this. Overwrites an existing object at `key`: the caller
152    /// chose the key, so a re-run regenerating a variant is idempotent, not a
153    /// collision.
154    async fn store_at(
155        &self,
156        key: &str,
157        content_type: &str,
158        bytes: &[u8],
159    ) -> Result<StoredFile, StorageError> {
160        let _ = (key, content_type, bytes);
161        Err(StorageError::Unsupported(
162            "this storage backend cannot write at an exact key (store_at)".to_string(),
163        ))
164    }
165
166    /// Read back the bytes stored under `key`.
167    ///
168    /// Returns [`StorageError::NotFound`] if no object exists for `key`.
169    async fn retrieve(&self, key: &str) -> Result<Vec<u8>, StorageError>;
170
171    /// Streaming counterpart of [`store`](Storage::store): persist a
172    /// `body` byte-stream without buffering the whole payload in memory.
173    ///
174    /// **Additive, with a default impl** — an existing backend that does
175    /// not override this still works, just buffered: the default collects
176    /// the stream into a `Vec<u8>` (propagating any mid-stream IO error)
177    /// and delegates to [`store`](Storage::store). Override it to true-stream
178    /// to the backend (the filesystem impl writes chunk-by-chunk to disk).
179    ///
180    /// Size enforcement is a *decorator* concern, not this method's: wrap
181    /// `body` with [`cap_stream`] before calling so the cap is applied as
182    /// bytes flow, never trusting a declared `Content-Length`.
183    async fn store_stream(
184        &self,
185        filename: &str,
186        content_type: &str,
187        body: ByteStream,
188    ) -> Result<StoredFile, StorageError> {
189        // Default: buffer the stream, then delegate to the buffered `store`.
190        let mut bytes: Vec<u8> = Vec::new();
191        let mut body = body;
192        while let Some(chunk) = futures_util::StreamExt::next(&mut body).await {
193            let chunk = chunk.map_err(StorageError::Io)?;
194            bytes.extend_from_slice(&chunk);
195        }
196        self.store(filename, content_type, &bytes).await
197    }
198
199    /// Streaming counterpart of [`retrieve`](Storage::retrieve): read the
200    /// object back as a byte-stream without holding the whole blob.
201    ///
202    /// **Additive, with a default impl** — the default calls
203    /// [`retrieve`](Storage::retrieve) and wraps the resulting `Vec<u8>`
204    /// as a single-chunk stream. Override it to true-stream from the
205    /// backend (the filesystem impl streams the file off disk).
206    async fn retrieve_stream(&self, key: &str) -> Result<ByteStream, StorageError> {
207        let bytes = self.retrieve(key).await?;
208        let chunk: Result<bytes::Bytes, std::io::Error> = Ok(bytes::Bytes::from(bytes));
209        Ok(Box::pin(futures_util::stream::once(async move { chunk })))
210    }
211
212    /// Persist `bytes` at the *exact* `key` the caller supplies — the
213    /// deterministic-path sibling of [`store`](Storage::store), which
214    /// generates a collision-resistant key. Static asset collection needs
215    /// this: a CSS file collected to `css/app.css` must land at that key,
216    /// not a `uuid-app.css` one.
217    ///
218    /// **Additive, with a default impl** — but the default *cannot*
219    /// generically write-at-exact-key without backend knowledge (the
220    /// trait has no "write these bytes here" primitive beyond
221    /// [`store`](Storage::store), which owns its own key). So the default
222    /// returns [`StorageError::Unsupported`]. Backends that can honour an
223    /// exact key (the filesystem backend, the future `LocalStorage` /
224    /// `S3Storage`) override it; media's [`store`](Storage::store) stays
225    /// the key-generating path.
226    ///
227    /// `content_type` is recorded by backends that track it (e.g. an S3
228    /// object's `Content-Type`); the filesystem backend derives the
229    /// served type from the key's extension instead.
230    async fn put(
231        &self,
232        key: &str,
233        content_type: &str,
234        bytes: &[u8],
235    ) -> Result<StoredFile, StorageError> {
236        let _ = (key, content_type, bytes);
237        Err(StorageError::Unsupported(
238            "this Storage backend does not implement put(); override it to write at an exact key"
239                .to_string(),
240        ))
241    }
242
243    /// Streaming counterpart of [`put`](Storage::put): persist a `body`
244    /// byte-stream at the exact `key` without buffering the whole payload.
245    ///
246    /// **Additive, with a default impl** that mirrors the
247    /// [`store_stream`](Storage::store_stream)/[`store`](Storage::store)
248    /// relationship: it collects the stream into a `Vec<u8>` (propagating
249    /// any mid-stream IO error) and delegates to [`put`](Storage::put), so
250    /// a backend that overrides `put` gets a working `put_stream` for
251    /// free. Override it to true-stream to the backend.
252    async fn put_stream(
253        &self,
254        key: &str,
255        content_type: &str,
256        body: ByteStream,
257    ) -> Result<StoredFile, StorageError> {
258        let mut bytes: Vec<u8> = Vec::new();
259        let mut body = body;
260        while let Some(chunk) = futures_util::StreamExt::next(&mut body).await {
261            let chunk = chunk.map_err(StorageError::Io)?;
262            bytes.extend_from_slice(&chunk);
263        }
264        self.put(key, content_type, &bytes).await
265    }
266
267    /// Does an object exist under `key`?
268    ///
269    /// **Additive, with a default impl** — `Ok(self.retrieve(key).await.is_ok())`,
270    /// which works for any backend through [`retrieve`](Storage::retrieve).
271    /// Backends with a cheaper presence check (an S3 `HEAD`, a filesystem
272    /// `metadata` stat) override it to avoid reading the whole blob.
273    async fn exists(&self, key: &str) -> Result<bool, StorageError> {
274        Ok(self.retrieve(key).await.is_ok())
275    }
276
277    /// Remove the object stored under `key`. Idempotent at the backend's
278    /// discretion; deleting a missing key may succeed or return
279    /// [`StorageError::NotFound`].
280    async fn delete(&self, key: &str) -> Result<(), StorageError>;
281
282    /// The public URL a client can fetch the object at. Public-only;
283    /// signed URLs are deferred.
284    fn url(&self, key: &str) -> String;
285}
286
287/// The outcome of a successful [`Storage::store`]: the generated key and
288/// its public URL.
289#[derive(Debug, Clone)]
290pub struct StoredFile {
291    /// The backend-generated key the bytes live under. Stable for the
292    /// lifetime of the object; pass it back to [`Storage::retrieve`] /
293    /// [`Storage::delete`] / [`Storage::url`].
294    pub key: String,
295    /// The public URL the object is served at. Equal to
296    /// `storage.url(&key)`.
297    pub url: String,
298    /// The number of bytes actually written. For [`Storage::store`] this
299    /// equals `bytes.len()`; for [`Storage::store_stream`] it is the
300    /// cumulative count streamed to the backend (the truth a `media_file`
301    /// row records, since a stream has no trustworthy up-front length).
302    pub size: u64,
303}
304
305/// Errors a [`Storage`] operation can return.
306#[derive(Debug)]
307pub enum StorageError {
308    /// No ambient backend has been registered.
309    NoBackend,
310    /// No object exists under the given key.
311    NotFound,
312    /// The bytes exceeded the backend's configured size cap.
313    TooLarge {
314        /// The configured limit, in bytes.
315        limit: u64,
316        /// The actual size that was rejected, in bytes.
317        actual: u64,
318    },
319    /// The upload's type isn't on the configured allow-list, or its declared
320    /// type doesn't match its actual bytes (gaps3 #51).
321    ///
322    /// A client-declared `Content-Type` is trivially spoofed, so a policy that
323    /// only checks the declaration doesn't stop anything: renaming `evil.exe` to
324    /// `avatar.png` and claiming `image/png` would sail through. The bytes are
325    /// sniffed too.
326    UnsupportedType {
327        /// What the upload claimed to be (or what its bytes actually are, when
328        /// the two disagree).
329        content_type: String,
330        /// What this storage accepts.
331        allowed: Vec<String>,
332    },
333    /// An underlying I/O error (filesystem read/write, etc.).
334    Io(std::io::Error),
335    /// A backend-specific failure that doesn't map to the variants above
336    /// (e.g. an S3 API error, or a row-insert failure in a wrapper).
337    Backend(String),
338    /// The backend doesn't implement the requested operation — returned by
339    /// the default [`Storage::put`] impl for a backend that can't write at
340    /// an exact key. The message names what's missing.
341    Unsupported(String),
342}
343
344impl std::fmt::Display for StorageError {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        match self {
347            StorageError::NoBackend => write!(
348                f,
349                "storage: no backend registered; add StoragePlugin or call set_storage"
350            ),
351            StorageError::NotFound => write!(f, "storage: object not found"),
352            StorageError::TooLarge { limit, actual } => write!(
353                f,
354                "storage: object {actual}B exceeds configured cap of {limit}B"
355            ),
356            StorageError::UnsupportedType {
357                content_type,
358                allowed,
359            } => write!(
360                f,
361                "storage: `{content_type}` is not an accepted upload type (accepted: {})",
362                allowed.join(", ")
363            ),
364            StorageError::Io(e) => write!(f, "storage: io: {e}"),
365            StorageError::Backend(s) => write!(f, "storage: backend: {s}"),
366            StorageError::Unsupported(s) => write!(f, "storage: unsupported: {s}"),
367        }
368    }
369}
370
371impl std::error::Error for StorageError {
372    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
373        match self {
374            StorageError::Io(e) => Some(e),
375            _ => None,
376        }
377    }
378}
379
380impl From<std::io::Error> for StorageError {
381    fn from(e: std::io::Error) -> Self {
382        StorageError::Io(e)
383    }
384}
385
386/// The conventional name of the **media** (user-upload) storage instance,
387/// the `"default"` entry in the storage registry. The back-compat accessors
388/// ([`storage`], [`set_storage`], …) operate on this name.
389pub const DEFAULT: &str = "default";
390
391/// The conventional name of the **static-files** storage instance,
392/// the `"staticfiles"` entry in the storage registry, where `collectstatic`
393/// writes collected assets. Resolved independently of [`DEFAULT`].
394pub const STATICFILES: &str = "staticfiles";
395
396/// The ambient, **named** storage registry, published at boot.
397///
398/// The named storage map: a small map from a static name
399/// (`"default"` for media, `"staticfiles"` for collected assets) to its
400/// backend. Replaces the former single-global `OnceLock<Arc<dyn Storage>>`
401/// so media and static can resolve independent backends under one
402/// abstraction. Registration is boot-time, so a `Mutex<HashMap>` behind a
403/// `OnceLock` is the right shape; the set-once-*per-name* discipline
404/// (first-wins, warn-and-keep on a re-set) mirrors the old single global.
405///
406/// Same "one intentional global" family as `crate::db`'s pool registry
407/// and the settings handle.
408static STORAGES: OnceLock<Mutex<HashMap<&'static str, Arc<dyn Storage>>>> = OnceLock::new();
409
410/// Access the named registry, initialising the empty map on first use.
411fn registry() -> &'static Mutex<HashMap<&'static str, Arc<dyn Storage>>> {
412    STORAGES.get_or_init(|| Mutex::new(HashMap::new()))
413}
414
415/// Register the storage backend under `name` (e.g. [`DEFAULT`] for media,
416/// [`STATICFILES`] for collected static assets).
417///
418/// Set-once **per name**, first-wins: a second call for the *same* name
419/// logs a warning and keeps the originally registered backend (different
420/// names register independently). Returns `true` when this call won the
421/// registration for `name`, `false` when that name was already taken.
422pub fn set_storage_named(name: &'static str, s: Arc<dyn Storage>) -> bool {
423    let mut map = registry()
424        .lock()
425        .unwrap_or_else(std::sync::PoisonError::into_inner);
426    if map.contains_key(name) {
427        tracing::warn!(
428            name,
429            "umbral::storage::set_storage_named called more than once for the same name; \
430             keeping the first-registered backend and ignoring the new one"
431        );
432        false
433    } else {
434        map.insert(name, s);
435        true
436    }
437}
438
439/// Return the storage backend registered under `name`.
440///
441/// # Panics
442///
443/// Panics if no backend has been registered under `name`. Wire one by
444/// adding the plugin that owns that name (`StoragePlugin` for [`DEFAULT`])
445/// or by calling [`set_storage_named`] directly.
446pub fn storage_named(name: &str) -> Arc<dyn Storage> {
447    try_storage_named(name).unwrap_or_else(|_| {
448        panic!(
449            "no Storage backend registered under `{name}`; add the owning plugin \
450             (StoragePlugin for `default`) or call umbral::storage::set_storage_named"
451        )
452    })
453}
454
455/// Return the storage backend registered under `name`, or
456/// [`StorageError::NoBackend`] if none is.
457pub fn try_storage_named(name: &str) -> Result<Arc<dyn Storage>, StorageError> {
458    storage_opt_named(name).ok_or(StorageError::NoBackend)
459}
460
461/// Return the storage backend registered under `name` if one exists, else
462/// `None`. The non-panicking variant of [`storage_named`].
463pub fn storage_opt_named(name: &str) -> Option<Arc<dyn Storage>> {
464    let map = registry()
465        .lock()
466        .unwrap_or_else(std::sync::PoisonError::into_inner);
467    map.get(name).cloned()
468}
469
470/// Register the ambient **default** (media) storage backend — the
471/// back-compat alias for `set_storage_named(`[`DEFAULT`]`, s)`.
472///
473/// Set-once, first-wins: a second call logs a warning and keeps the
474/// originally registered backend, mirroring `crate::settings::init` and
475/// `crate::db::init_atomic_default` rather than panicking on a double
476/// set. Returns `true` when this call won the registration, `false` when
477/// a backend was already registered.
478///
479/// `umbral-storage`'s `StoragePlugin::on_ready` calls this so the ambient
480/// default is its `FsStorage`; an app can also call it directly to wire a
481/// custom backend before (or instead of) any storage plugin.
482pub fn set_storage(s: Arc<dyn Storage>) -> bool {
483    set_storage_named(DEFAULT, s)
484}
485
486/// Return the ambient **default** (media) storage backend — the
487/// back-compat alias for `storage_named(`[`DEFAULT`]`)`.
488///
489/// # Panics
490///
491/// Panics if no backend has been registered. Wire one by adding
492/// `StoragePlugin` (which registers its `FsStorage` in `on_ready`) or by
493/// calling [`set_storage`] directly.
494pub fn storage() -> Arc<dyn Storage> {
495    try_storage().expect(
496        "no Storage backend registered; add StoragePlugin or call umbral::storage::set_storage",
497    )
498}
499
500/// Return the ambient **default** (media) storage backend, or an explicit
501/// error if none is registered. Back-compat alias for
502/// `try_storage_named(`[`DEFAULT`]`)`.
503pub fn try_storage() -> Result<Arc<dyn Storage>, StorageError> {
504    try_storage_named(DEFAULT)
505}
506
507/// Return the ambient **default** (media) storage backend if registered,
508/// else `None`. Back-compat alias for `storage_opt_named(`[`DEFAULT`]`)`.
509///
510/// The non-panicking variant of [`storage`]. Useful for boot-time
511/// system checks (a future `FileField` check can warn when a model
512/// declares a file field but no `Storage` backend is wired) and for
513/// plugin code that runs before `on_ready`.
514pub fn storage_opt() -> Option<Arc<dyn Storage>> {
515    storage_opt_named(DEFAULT)
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use std::collections::HashMap as Map;
522    use std::sync::Mutex as StdMutex;
523
524    /// A minimal in-memory backend. `store` generates a key; `put` is left
525    /// at the trait default (returns `Unsupported`) so we can assert the
526    /// default path; `exists` is left at the trait default (via `retrieve`).
527    struct MemNoPut {
528        objects: StdMutex<Map<String, Vec<u8>>>,
529    }
530
531    impl MemNoPut {
532        fn new() -> Self {
533            Self {
534                objects: StdMutex::new(Map::new()),
535            }
536        }
537    }
538
539    #[async_trait]
540    impl Storage for MemNoPut {
541        async fn store(
542            &self,
543            filename: &str,
544            _content_type: &str,
545            bytes: &[u8],
546        ) -> Result<StoredFile, StorageError> {
547            let key = format!("k-{filename}");
548            self.objects
549                .lock()
550                .unwrap()
551                .insert(key.clone(), bytes.to_vec());
552            Ok(StoredFile {
553                url: self.url(&key),
554                key,
555                size: bytes.len() as u64,
556            })
557        }
558
559        async fn retrieve(&self, key: &str) -> Result<Vec<u8>, StorageError> {
560            self.objects
561                .lock()
562                .unwrap()
563                .get(key)
564                .cloned()
565                .ok_or(StorageError::NotFound)
566        }
567
568        async fn delete(&self, key: &str) -> Result<(), StorageError> {
569            self.objects.lock().unwrap().remove(key);
570            Ok(())
571        }
572
573        fn url(&self, key: &str) -> String {
574            format!("/mem/{key}")
575        }
576    }
577
578    /// Same backend but overriding `put` to write at the exact key.
579    struct MemWithPut {
580        objects: StdMutex<Map<String, Vec<u8>>>,
581    }
582
583    impl MemWithPut {
584        fn new() -> Self {
585            Self {
586                objects: StdMutex::new(Map::new()),
587            }
588        }
589    }
590
591    #[async_trait]
592    impl Storage for MemWithPut {
593        async fn store(
594            &self,
595            filename: &str,
596            ct: &str,
597            bytes: &[u8],
598        ) -> Result<StoredFile, StorageError> {
599            self.put(&format!("k-{filename}"), ct, bytes).await
600        }
601
602        async fn retrieve(&self, key: &str) -> Result<Vec<u8>, StorageError> {
603            self.objects
604                .lock()
605                .unwrap()
606                .get(key)
607                .cloned()
608                .ok_or(StorageError::NotFound)
609        }
610
611        async fn put(
612            &self,
613            key: &str,
614            _ct: &str,
615            bytes: &[u8],
616        ) -> Result<StoredFile, StorageError> {
617            self.objects
618                .lock()
619                .unwrap()
620                .insert(key.to_string(), bytes.to_vec());
621            Ok(StoredFile {
622                url: self.url(key),
623                key: key.to_string(),
624                size: bytes.len() as u64,
625            })
626        }
627
628        async fn delete(&self, key: &str) -> Result<(), StorageError> {
629            self.objects.lock().unwrap().remove(key);
630            Ok(())
631        }
632
633        fn url(&self, key: &str) -> String {
634            format!("/mem/{key}")
635        }
636    }
637
638    #[tokio::test]
639    async fn put_default_returns_unsupported() {
640        let s = MemNoPut::new();
641        let err = s.put("css/app.css", "text/css", b"x").await.unwrap_err();
642        match err {
643            StorageError::Unsupported(msg) => {
644                assert!(msg.contains("does not implement put"), "msg = {msg}");
645            }
646            other => panic!("expected Unsupported, got {other:?}"),
647        }
648    }
649
650    #[tokio::test]
651    async fn put_override_writes_at_exact_key() {
652        let s = MemWithPut::new();
653        let stored = s.put("css/app.css", "text/css", b"body{}").await.unwrap();
654        // The key is EXACTLY what we asked for — no generation.
655        assert_eq!(stored.key, "css/app.css");
656        assert_eq!(stored.size, 6);
657        // And it round-trips back at that exact key.
658        assert_eq!(s.retrieve("css/app.css").await.unwrap(), b"body{}");
659    }
660
661    #[tokio::test]
662    async fn exists_default_true_after_store_false_when_missing() {
663        let s = MemNoPut::new();
664        let stored = s.store("a.txt", "text/plain", b"hi").await.unwrap();
665        assert!(s.exists(&stored.key).await.unwrap());
666        assert!(!s.exists("nope").await.unwrap());
667    }
668
669    #[tokio::test]
670    async fn put_stream_default_delegates_to_put() {
671        let s = MemWithPut::new();
672        let body: ByteStream = Box::pin(futures_util::stream::once(async {
673            Ok(bytes::Bytes::from_static(b"streamed"))
674        }));
675        let stored = s
676            .put_stream("js/app.js", "text/javascript", body)
677            .await
678            .unwrap();
679        assert_eq!(stored.key, "js/app.js");
680        assert_eq!(s.retrieve("js/app.js").await.unwrap(), b"streamed");
681    }
682}