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}