Skip to main content

autumn_web/storage/
mod.rs

1//! Pluggable file storage backends for Autumn applications.
2//!
3//! This module provides a [`BlobStore`] trait abstraction with one
4//! built-in backend:
5//!
6//! - **[`Local`](local::LocalBlobStore)** — writes to a configurable root
7//!   directory and serves bytes through an autumn-mounted route at
8//!   `[storage.local].mount_path` (default `/_blobs`). URLs are signed
9//!   with HMAC-SHA256 and time-bounded.
10//!
11//! For S3-compatible storage (AWS S3, Cloudflare R2, `MinIO`, `DigitalOcean`
12//! Spaces, Wasabi) add the `autumn-storage-s3` crate and call
13//! `.with_blob_store(S3BlobStore::from_config(&config.storage.s3).await?)`
14//! on your [`AppBuilder`](crate::app::AppBuilder).
15//!
16//! ## Quick start
17//!
18//! ```rust,no_run
19//! use autumn_web::storage::{Blob, BlobStore, BlobStoreError};
20//!
21//! async fn upload<S: BlobStore + ?Sized>(store: &S, key: &str, bytes: bytes::Bytes)
22//!     -> Result<Blob, BlobStoreError>
23//! {
24//!     store.put(key, "image/png", bytes).await
25//! }
26//! ```
27//!
28//! ## Profile-aware defaults
29//!
30//! | Profile | Default backend | Notes |
31//! |---------|-----------------|-------|
32//! | `dev`   | `Local` rooted at `target/blobs/` | Always-on with the `storage` feature |
33//! | `prod`  | Fail-fast on `local` unless `storage.allow_local_in_production = true` | Force explicit acknowledgement of multi-replica risk |
34//!
35//! ## Configuration
36//!
37//! ```toml
38//! [storage]
39//! backend = "local"        # "local" | "s3" | "disabled"
40//! default_provider = "default"
41//!
42//! [storage.local]
43//! root = "target/blobs"
44//! mount_path = "/_blobs"
45//!
46//! [storage.s3]
47//! bucket = "my-app-uploads"
48//! region = "us-east-1"
49//! endpoint = "https://s3.amazonaws.com"
50//! access_key_id_env = "AWS_ACCESS_KEY_ID"
51//! secret_access_key_env = "AWS_SECRET_ACCESS_KEY"
52//! force_path_style = false
53//! ```
54
55use std::pin::Pin;
56use std::sync::Arc;
57use std::time::Duration;
58
59use bytes::Bytes;
60use futures::Stream;
61use thiserror::Error;
62
63pub mod blob;
64pub mod config;
65pub mod direct_upload;
66pub mod local;
67pub mod migrations;
68
69#[cfg(feature = "maud")]
70pub mod form_helper;
71
72#[cfg(feature = "variants")]
73pub mod variant;
74
75pub use blob::{Blob, BlobMeta};
76pub use config::{
77    StorageBackend, StorageBackendConfigError, StorageBackendPlan, StorageConfig,
78    StorageLocalConfig, StorageS3Config, StorageVariantsConfig,
79};
80pub use direct_upload::{PresignPutResult, complete_direct_upload};
81pub use local::LocalBlobStore;
82
83#[cfg(feature = "variants")]
84pub use variant::{Transform, VariantBudget, VariantError, VariantHandle};
85
86/// Boxed future returned by [`BlobStore`] methods.
87///
88/// Pinning the future as a trait object keeps [`BlobStore`] dyn-safe so
89/// applications can hold an `Arc<dyn BlobStore>` and swap backends at
90/// runtime.
91pub type BlobFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, BlobStoreError>> + Send + 'a>>;
92
93/// Stream of byte chunks accepted by [`BlobStore::put_stream`].
94///
95/// Each item is a `bytes::Bytes` chunk; errors propagate as
96/// [`BlobStoreError`]. The lifetime parameter lets callers borrow the
97/// chunk source from their own stack (e.g. an in-flight multipart
98/// extractor) without forcing a `'static` bound.
99pub type ByteStream<'a> = Pin<Box<dyn Stream<Item = Result<Bytes, BlobStoreError>> + Send + 'a>>;
100
101/// Errors returned by [`BlobStore`] operations.
102#[derive(Debug, Error)]
103#[non_exhaustive]
104pub enum BlobStoreError {
105    /// The requested key was not found.
106    #[error("blob not found: {0}")]
107    NotFound(String),
108
109    /// Authentication or authorization failed against the backend.
110    #[error("permission denied: {0}")]
111    PermissionDenied(String),
112
113    /// Invalid input — most often a malformed or unsafe key.
114    #[error("invalid input: {0}")]
115    InvalidInput(String),
116
117    /// Caller exceeded a backend size limit (per-file or per-request).
118    /// Maps to HTTP `413 Payload Too Large`.
119    #[error("payload too large: {0}")]
120    PayloadTooLarge(String),
121
122    /// I/O failure (filesystem, network, transport).
123    #[error("io error: {0}")]
124    Io(String),
125
126    /// The configured backend doesn't support this operation.
127    #[error("operation not supported: {0}")]
128    Unsupported(String),
129
130    /// A signed URL could not be verified or has expired.
131    #[error("signature error: {0}")]
132    Signature(String),
133
134    /// Backend-specific error reported as a string for portability.
135    #[error("backend error: {0}")]
136    Backend(String),
137}
138
139impl BlobStoreError {
140    /// Wrap an `io::Error` for the I/O variant.
141    #[must_use]
142    pub fn io(err: impl std::fmt::Display) -> Self {
143        Self::Io(err.to_string())
144    }
145
146    /// Convenience constructor for [`BlobStoreError::Backend`].
147    #[must_use]
148    pub fn backend(err: impl std::fmt::Display) -> Self {
149        Self::Backend(err.to_string())
150    }
151}
152
153impl BlobStoreError {
154    /// HTTP status code that best fits this error variant.
155    #[must_use]
156    pub const fn status(&self) -> http::StatusCode {
157        match self {
158            Self::NotFound(_) => http::StatusCode::NOT_FOUND,
159            // `Signature` is an auth failure (the URL was tampered with
160            // or has expired), not a malformed-input error — map it
161            // alongside `PermissionDenied` so handlers using `?` get
162            // 403 consistently with what `local::serve_router` returns
163            // directly when verifying a presigned URL.
164            Self::PermissionDenied(_) | Self::Signature(_) => http::StatusCode::FORBIDDEN,
165            Self::InvalidInput(_) => http::StatusCode::BAD_REQUEST,
166            Self::PayloadTooLarge(_) => http::StatusCode::PAYLOAD_TOO_LARGE,
167            Self::Unsupported(_) => http::StatusCode::NOT_IMPLEMENTED,
168            Self::Io(_) | Self::Backend(_) => http::StatusCode::INTERNAL_SERVER_ERROR,
169        }
170    }
171
172    /// Promote into an [`AutumnError`](crate::AutumnError) carrying the
173    /// status from [`BlobStoreError::status`].
174    ///
175    /// Handlers using `?` get a 500 by default via the blanket
176    /// `impl From<E: Error> for AutumnError`; call this instead to
177    /// preserve the precise status (404 for missing blobs, 403 for
178    /// signature failures, etc.).
179    #[must_use]
180    pub fn into_autumn_error(self) -> crate::AutumnError {
181        let status = self.status();
182        crate::AutumnError::internal_server_error(self).with_status(status)
183    }
184}
185
186/// Pluggable file-storage backend.
187///
188/// Implement this trait to add new backends. The built-in backend is
189/// [`LocalBlobStore`]. S3-compatible storage is provided by the
190/// `autumn-storage-s3` crate.
191///
192/// The trait is **dyn-safe** so apps can hold `Arc<dyn BlobStore>` and
193/// swap backends at runtime — for example, choosing local in tests and
194/// S3 in production via configuration.
195pub trait BlobStore: Send + Sync + 'static {
196    /// Stable identifier for the configured provider, recorded on every
197    /// [`Blob`] so applications can detect cross-store mismatches.
198    fn provider_id(&self) -> &str;
199
200    /// Store `bytes` under `key`, returning a [`Blob`] handle.
201    fn put<'a>(&'a self, key: &'a str, content_type: &'a str, bytes: Bytes)
202    -> BlobFuture<'a, Blob>;
203
204    /// Stream `data` under `key`, returning a [`Blob`] handle.
205    ///
206    /// Use this for files larger than memory.
207    fn put_stream<'a>(
208        &'a self,
209        key: &'a str,
210        content_type: &'a str,
211        data: ByteStream<'a>,
212    ) -> BlobFuture<'a, Blob>;
213
214    /// Read the bytes for `key` into memory.
215    fn get<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Bytes>;
216
217    /// Stream an object's bytes without buffering the whole object in memory.
218    ///
219    /// Use this for serving large objects (see
220    /// [`Download::from_blob`](crate::download::Download::from_blob)) so a
221    /// 100 MB file does not materialize entirely in RAM.
222    ///
223    /// The default implementation buffers via [`get`](BlobStore::get) and
224    /// yields a single chunk; backends that can truly stream (like the
225    /// [`LocalBlobStore`]) override this to read the object incrementally.
226    /// The returned stream is `'static` so callers can detach it from the
227    /// store's borrow and hand it to a response body.
228    ///
229    /// The S3 backend (`autumn-storage-s3`, a separate crate) uses the
230    /// buffering default; overriding it there is tracked separately.
231    fn get_stream<'a>(&'a self, key: &'a str) -> BlobFuture<'a, ByteStream<'static>> {
232        Box::pin(async move {
233            let bytes = self.get(key).await?;
234            let stream: ByteStream<'static> =
235                Box::pin(futures::stream::once(async move { Ok(bytes) }));
236            Ok(stream)
237        })
238    }
239
240    /// Stream only the inclusive byte range `[start, end]` of an object.
241    ///
242    /// Powers HTTP `Range` requests (see
243    /// [`Download::into_response_ranged`](crate::download::Download::into_response_ranged)):
244    /// a client seeking within a large video asks for a slice, and the store
245    /// returns just those bytes.
246    ///
247    /// `start` and `end` are inclusive and are assumed to have been validated
248    /// against the object's size by the caller (via
249    /// [`autumn_web::range`](crate::range)).
250    ///
251    /// The default implementation buffers the whole object via
252    /// [`get`](BlobStore::get) and slices it — used by backends that cannot do
253    /// a true ranged read (e.g. the S3 backend until it is overridden there).
254    /// The [`LocalBlobStore`] overrides this to `seek` + `take` so a slice of a
255    /// large file never materializes the whole object in memory.
256    fn get_range<'a>(
257        &'a self,
258        key: &'a str,
259        start: u64,
260        end: u64,
261    ) -> BlobFuture<'a, ByteStream<'static>> {
262        Box::pin(async move {
263            let bytes = self.get(key).await?;
264            let lo = usize::try_from(start)
265                .unwrap_or(usize::MAX)
266                .min(bytes.len());
267            let hi = usize::try_from(end)
268                .unwrap_or(usize::MAX)
269                .saturating_add(1)
270                .min(bytes.len());
271            let slice = bytes.slice(lo..hi.max(lo));
272            let stream: ByteStream<'static> =
273                Box::pin(futures::stream::once(async move { Ok(slice) }));
274            Ok(stream)
275        })
276    }
277
278    /// Delete the blob at `key`. No-op when the key does not exist.
279    fn delete<'a>(&'a self, key: &'a str) -> BlobFuture<'a, ()>;
280
281    /// Return metadata for `key` if it exists.
282    fn head<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Option<BlobMeta>>;
283
284    /// Build a time-bounded URL that serves the blob's bytes.
285    ///
286    /// On the [`LocalBlobStore`] this is an HMAC-signed link to the
287    /// mounted serving route. On S3 backends it is a real S3 presigned
288    /// URL.
289    fn presigned_url<'a>(&'a self, key: &'a str, expires_in: Duration) -> BlobFuture<'a, String>;
290
291    /// Build a time-bounded presigned envelope the browser can use to PUT
292    /// bytes directly to the storage backend, bypassing the Autumn app
293    /// process.
294    ///
295    /// Returns a [`PresignPutResult`] with the URL, HTTP method, and any
296    /// headers the browser must include in the upload request.
297    ///
298    /// Backends that do not support direct PUT uploads return
299    /// [`BlobStoreError::Unsupported`]. Callers that want a graceful fallback
300    /// should check for that variant and fall back to the through-app upload
301    /// path via [`BlobStore::put_stream`].
302    ///
303    /// A signed-URL leak does **not** allow the holder to bind the blob to
304    /// any model: the completion step (recording the [`Blob`] in the
305    /// database) is always the application's own CSRF- and session-protected
306    /// route, not a framework-issued token.
307    fn presign_put<'a>(
308        &'a self,
309        key: &'a str,
310        content_type: &'a str,
311        expires_in: Duration,
312    ) -> BlobFuture<'a, PresignPutResult> {
313        let _ = (key, content_type, expires_in);
314        Box::pin(async {
315            Err(BlobStoreError::Unsupported(
316                "this storage backend does not support direct browser uploads via \
317                 presigned PUT; use the through-app upload path \
318                 (MultipartField::save_to_blob_store) instead"
319                    .into(),
320            ))
321        })
322    }
323}
324
325/// Type alias for a runtime-installed shared [`BlobStore`].
326///
327/// Applications obtain this from [`AppState`](crate::AppState) via
328/// [`BlobStoreState::store`].
329pub type SharedBlobStore = Arc<dyn BlobStore>;
330
331/// Wrapper installed on [`AppState`](crate::AppState) so handlers can
332/// pull the configured store back out via
333/// [`AppState::extension::<BlobStoreState>()`](crate::AppState::extension).
334#[derive(Clone)]
335pub struct BlobStoreState {
336    inner: SharedBlobStore,
337}
338
339impl BlobStoreState {
340    /// Wrap a runtime-built blob store.
341    #[must_use]
342    pub fn new(store: SharedBlobStore) -> Self {
343        Self { inner: store }
344    }
345
346    /// Borrow the underlying [`BlobStore`] handle.
347    #[must_use]
348    pub fn store(&self) -> &SharedBlobStore {
349        &self.inner
350    }
351}
352
353/// Validate a user-supplied object key.
354///
355/// Keys must be non-empty, must not contain `..` segments, must not be
356/// absolute paths, and must not contain NUL bytes. This protects the
357/// [`LocalBlobStore`] from path-traversal and gives S3 backends a
358/// consistent input contract.
359///
360/// # Errors
361///
362/// Returns [`BlobStoreError::InvalidInput`] when the key is rejected.
363pub fn validate_key(key: &str) -> Result<(), BlobStoreError> {
364    check_basic_formatting(key)?;
365    check_windows_paths(key)?;
366    for segment in key.split('/') {
367        validate_segment(segment)?;
368    }
369    check_reserved_suffixes(key)?;
370    check_case_folding(key)?;
371    Ok(())
372}
373
374fn check_basic_formatting(key: &str) -> Result<(), BlobStoreError> {
375    if key.is_empty() {
376        return Err(BlobStoreError::InvalidInput("blob key is empty".into()));
377    }
378    if key.contains('\0') {
379        return Err(BlobStoreError::InvalidInput(
380            "blob key contains NUL byte".into(),
381        ));
382    }
383    if key.starts_with('/') || key.starts_with('\\') {
384        return Err(BlobStoreError::InvalidInput(
385            "blob key must be relative".into(),
386        ));
387    }
388    // Backslashes alias to forward slashes on Windows (`a\b` and `a/b`
389    // resolve to the same filesystem path), so two distinct logical
390    // keys would collide. Reject backslashes entirely so the canonical
391    // separator is always `/` regardless of the host platform.
392    if key.contains('\\') {
393        return Err(BlobStoreError::InvalidInput(
394            "blob key contains a backslash; use `/` as the segment separator".into(),
395        ));
396    }
397    Ok(())
398}
399
400fn check_windows_paths(key: &str) -> Result<(), BlobStoreError> {
401    // Windows drive-letter forms (`C:\…`, `C:/…`, `\\?\…`, `\\server\share\…`)
402    // would be treated as absolute by `Path::join` on Windows, silently
403    // escaping the storage root regardless of whether the host happens
404    // to be Linux. Reject them up-front so the same key contract holds
405    // on every platform.
406    let bytes = key.as_bytes();
407    let drive_letter = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
408    if drive_letter {
409        return Err(BlobStoreError::InvalidInput(
410            "blob key looks like a Windows drive-letter path".into(),
411        ));
412    }
413    if key.starts_with("\\\\") || key.starts_with("//") {
414        return Err(BlobStoreError::InvalidInput(
415            "blob key looks like a UNC / network path".into(),
416        ));
417    }
418    Ok(())
419}
420
421fn validate_segment(segment: &str) -> Result<(), BlobStoreError> {
422    if segment == ".." {
423        return Err(BlobStoreError::InvalidInput(
424            "blob key contains traversal segment".into(),
425        ));
426    }
427    // `.` and empty segments collapse on the filesystem (`a/./b`,
428    // `a//b`, and `a/b` all resolve to the same path on POSIX) and
429    // are normalized away by most HTTP clients before they reach
430    // the serving route. Either way, two distinct logical keys
431    // would alias and the HMAC signature would no longer match
432    // the path the client actually requests. Reject them here so
433    // every key is canonical at the point it's persisted or signed.
434    if segment == "." {
435        return Err(BlobStoreError::InvalidInput(
436            "blob key contains a `.` segment".into(),
437        ));
438    }
439    if segment.is_empty() {
440        return Err(BlobStoreError::InvalidInput(
441            "blob key contains an empty segment".into(),
442        ));
443    }
444    // Windows silently strips trailing `.` and trailing space from
445    // filenames (`foo.png.` → `foo.png`, `con ` → `con`). That
446    // would let two distinct logical keys alias the same on-disk
447    // file on Windows, and could bypass the reserved-name guard
448    // below (a segment of `con ` passes the literal `==` check
449    // but normalizes to `con` once Windows touches it).
450    if segment.ends_with('.') || segment.ends_with(' ') {
451        return Err(BlobStoreError::InvalidInput(format!(
452            "blob key segment {segment:?} ends with `.` or space; Windows normalizes \
453             these and would alias the segment with its stripped form"
454        )));
455    }
456    // Windows rejects these characters in filenames; a key
457    // containing any of them passes Linux/S3 but errors with
458    // I/O on Windows. Same portability rationale as the
459    // uppercase check above. Control chars (< 0x20) are also
460    // rejected on Windows and rarely meaningful as path
461    // components anyway.
462    if segment.bytes().any(|b| {
463        matches!(
464            b,
465            b'<' | b'>' | b':' | b'"' | b'|' | b'?' | b'*' | 0x01..=0x1F
466        )
467    }) {
468        return Err(BlobStoreError::InvalidInput(
469            "blob key contains a Windows-reserved filename character (`<`, `>`, \
470             `:`, `\"`, `|`, `?`, `*`, or a control byte) — keys must be portable \
471             across local and S3 backends"
472                .into(),
473        ));
474    }
475    // Windows reserved device names: a key like `con/foo` or
476    // `nul.png` errors with I/O on Windows even though Linux/S3
477    // accept it. Compare against the Unicode-lowercased
478    // basename (the part before the first `.`). The uppercase
479    // check above already enforces lowercase, so checking the
480    // raw lowercase set is sufficient.
481    let basename = segment.split('.').next().unwrap_or("");
482    if WINDOWS_RESERVED_NAMES.contains(&basename) {
483        return Err(BlobStoreError::InvalidInput(format!(
484            "blob key segment {segment:?} starts with a Windows-reserved device name \
485             (`con`, `prn`, `aux`, `nul`, `com1-9`, `lpt1-9`)"
486        )));
487    }
488    Ok(())
489}
490
491fn check_reserved_suffixes(key: &str) -> Result<(), BlobStoreError> {
492    // The local backend persists `<path>.meta` sidecars next to each
493    // blob's bytes (carrying the original `content_type` so the serving
494    // route can render images, PDFs, etc. correctly). If we let user
495    // keys end in `.meta`, the sidecar of key `foo` and the bytes of
496    // key `foo.meta` would collide — overwriting each other on `put`,
497    // and returning sidecar JSON in place of bytes on `get`. Reserve
498    // the suffix everywhere (case-insensitive — some filesystems are
499    // case-insensitive too) so the local-backend invariant is also a
500    // trait-level invariant; other backends (S3) don't need it but
501    // benefit from key portability.
502    if let Some(last) = key.rsplit('/').next() {
503        // Byte-level suffix comparison so a non-ASCII final segment
504        // (e.g. `"ééé"`, where each `é` is 2 bytes and the byte index
505        // 5-from-the-end lands mid-char) doesn't panic on string-slice
506        // bounds. The reserved suffix is pure ASCII, so comparing the
507        // last 5 raw bytes case-insensitively is unambiguous.
508        let bytes = last.as_bytes();
509        if bytes.len() >= 5 && bytes[bytes.len() - 5..].eq_ignore_ascii_case(b".meta") {
510            return Err(BlobStoreError::InvalidInput(
511                "blob keys ending in `.meta` are reserved (local backend uses `<key>.meta` \
512                 sidecar files for content-type metadata)"
513                    .into(),
514            ));
515        }
516    }
517    Ok(())
518}
519
520fn check_case_folding(key: &str) -> Result<(), BlobStoreError> {
521    // Case-insensitive filesystems (Windows NTFS default, macOS APFS
522    // default) collapse keys whose Unicode case-fold is identical to
523    // the same on-disk path, so two distinct logical keys would
524    // silently overwrite each other on the local backend while
525    // staying distinct in the app's data layer (different HMAC
526    // signatures, different DB rows). S3 keeps them distinct, so an
527    // app that "works" on local also breaks on a backend swap.
528    // Reject any character whose Unicode default case-fold differs
529    // from itself: that's both ASCII uppercase (`A-Z`) and Unicode
530    // uppercase (`Ä`, `É`, `İ`, …). Apps that need case preservation
531    // should encode it (base64, percent-encoding, …) before passing
532    // the key to the store.
533    for c in key.chars() {
534        let mut lower = c.to_lowercase();
535        // The char is "already lowercase / caseless" iff its default
536        // case-fold yields exactly itself as a single code point.
537        // - `'Ä'.to_lowercase()` yields `'ä'` → reject.
538        // - `'ä'.to_lowercase()` yields `'ä'` → accept.
539        // - `'東'.to_lowercase()` yields `'東'` → accept (caseless).
540        // - `'A'.to_lowercase()` yields `'a'` → reject.
541        let first = lower.next();
542        let trailing = lower.next();
543        if first != Some(c) || trailing.is_some() {
544            return Err(BlobStoreError::InvalidInput(
545                "blob keys must be lowercase (uppercase Unicode aliases on case-insensitive \
546                 filesystems and breaks portability between local and S3)"
547                    .into(),
548            ));
549        }
550    }
551    Ok(())
552}
553
554/// Windows reserves these device names regardless of file extension
555/// (`con.txt`, `con/foo`, etc.). Lowercase-only because the uppercase
556/// check in `validate_key` already enforces all-lowercase keys.
557const WINDOWS_RESERVED_NAMES: &[&str] = &[
558    "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
559    "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
560];
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn validate_key_accepts_typical_paths() {
568        validate_key("avatars/123.png").unwrap();
569        validate_key("a/b/c/d.txt").unwrap();
570    }
571
572    #[test]
573    fn validate_key_rejects_traversal() {
574        let err = validate_key("../etc/passwd").unwrap_err();
575        assert!(matches!(err, BlobStoreError::InvalidInput(_)));
576    }
577
578    #[test]
579    fn validate_key_rejects_absolute() {
580        let err = validate_key("/etc/passwd").unwrap_err();
581        assert!(matches!(err, BlobStoreError::InvalidInput(_)));
582    }
583
584    #[test]
585    fn validate_key_rejects_empty() {
586        let err = validate_key("").unwrap_err();
587        assert!(matches!(err, BlobStoreError::InvalidInput(_)));
588    }
589
590    #[test]
591    fn validate_key_rejects_nul() {
592        let err = validate_key("a\0b").unwrap_err();
593        assert!(matches!(err, BlobStoreError::InvalidInput(_)));
594    }
595
596    #[test]
597    fn validate_key_rejects_windows_drive_letter() {
598        for k in [r"C:\tmp\x", "C:/tmp/x", "z:\\foo", "a:bar"] {
599            let err = validate_key(k).unwrap_err();
600            assert!(
601                matches!(err, BlobStoreError::InvalidInput(_)),
602                "key {k:?} should be rejected"
603            );
604        }
605    }
606
607    #[test]
608    fn validate_key_rejects_unc_paths() {
609        for k in [r"\\server\share\file", "//server/share/file"] {
610            let err = validate_key(k).unwrap_err();
611            assert!(
612                matches!(err, BlobStoreError::InvalidInput(_)),
613                "key {k:?} should be rejected"
614            );
615        }
616    }
617
618    #[test]
619    fn validate_key_rejects_dot_segments() {
620        // `a/./b` would resolve to `a/b` on the filesystem, aliasing two
621        // distinct logical keys. HTTP clients also tend to normalize
622        // these out of URL paths, breaking signature verification.
623        for k in ["a/./b", "./foo", "a/././b", "a/.\\b"] {
624            let err = validate_key(k).unwrap_err();
625            assert!(
626                matches!(err, BlobStoreError::InvalidInput(_)),
627                "key {k:?} should be rejected"
628            );
629        }
630    }
631
632    #[test]
633    fn validate_key_rejects_empty_segments() {
634        // Same aliasing/canonicalization problem as `.` segments.
635        // `a//b` collapses to `a/b` on POSIX; `a/b/` produces a trailing
636        // empty segment that HTTP clients silently strip.
637        for k in ["a//b", "a/b/", "a///b"] {
638            let err = validate_key(k).unwrap_err();
639            assert!(
640                matches!(err, BlobStoreError::InvalidInput(_)),
641                "key {k:?} should be rejected"
642            );
643        }
644    }
645
646    #[test]
647    fn validate_key_rejects_backslash_separator() {
648        // Backslashes alias to forward slashes on Windows; reject them
649        // entirely so the canonical separator is always `/`.
650        for k in [r"a\b", r"avatars\me.png", r"x\y\z"] {
651            let err = validate_key(k).unwrap_err();
652            assert!(
653                matches!(err, BlobStoreError::InvalidInput(_)),
654                "key {k:?} should be rejected"
655            );
656        }
657    }
658
659    #[test]
660    fn validate_key_reserves_meta_suffix() {
661        // The local backend stores `<key>.meta` sidecars; a user key
662        // ending in `.meta` would collide with another key's sidecar.
663        // Case-insensitive because some filesystems normalize case.
664        for k in ["foo.meta", "avatars/me.meta", "FOO.META", "x/y/Z.MeTa"] {
665            let err = validate_key(k).unwrap_err();
666            assert!(
667                matches!(err, BlobStoreError::InvalidInput(_)),
668                "key {k:?} should be reserved",
669            );
670        }
671        // But these are fine — not the right suffix.
672        for k in ["meta.png", "foo.metadata", "a.meta.gz", "metafile"] {
673            validate_key(k).unwrap_or_else(|_| panic!("key {k:?} should be accepted"));
674        }
675    }
676
677    #[test]
678    fn validate_key_handles_non_ascii_without_panicking() {
679        // The `.meta` suffix check must compare raw bytes, not a
680        // `&str` slice — otherwise a non-ASCII key whose byte length
681        // is ≥ 5 with a UTF-8 char boundary mid-suffix would panic
682        // with "byte index N is not a char boundary". Pin that we
683        // accept such keys cleanly instead.
684        for k in ["ééé", "résumé.png", "東京", "cafe\u{0301}"] {
685            validate_key(k).unwrap_or_else(|err| {
686                panic!("non-ASCII key {k:?} should validate cleanly, got {err:?}")
687            });
688        }
689        // Non-ASCII keys that *do* end in `.meta` must still be
690        // rejected (the suffix is ASCII).
691        let err = validate_key("résumé.meta").unwrap_err();
692        assert!(matches!(err, BlobStoreError::InvalidInput(_)));
693    }
694
695    #[test]
696    fn validate_key_rejects_uppercase() {
697        // Case-insensitive filesystems (NTFS, APFS) alias these with
698        // their lowercase counterparts on the local backend while
699        // keeping them distinct in app data. Reject up-front so the
700        // portable subset (Unicode-lowercase / caseless) is the only
701        // valid form. Covers ASCII uppercase + Unicode uppercase
702        // (`Ä`, `É`, `İ`, etc.) — anything whose Unicode default
703        // case-fold differs from itself.
704        let rejected = [
705            // ASCII uppercase
706            "Foo.png",
707            "AVATARS/me.png",
708            "aBc",
709            "x/Y/z",
710            // Unicode uppercase variants
711            "Ärger.png",
712            "documents/Émile.txt",
713            "İstanbul/photo.jpg",
714            "ΟΛΑ.txt", // Greek Omicron-Lambda-Alpha
715        ];
716        for k in rejected {
717            let err = validate_key(k).unwrap_err();
718            assert!(
719                matches!(err, BlobStoreError::InvalidInput(_)),
720                "key {k:?} should be rejected for uppercase"
721            );
722        }
723        // Lowercase ASCII, lowercase Unicode, and caseless characters
724        // stay valid.
725        let accepted = [
726            "foo.png",
727            "avatars/me.png",
728            "résumé.png",
729            "ärger.png",
730            "émile.txt",
731            "istanbul/photo.jpg",
732            "東京/photo.jpg", // CJK ideographs are caseless
733            "café/menu.txt",
734        ];
735        for k in accepted {
736            validate_key(k)
737                .unwrap_or_else(|err| panic!("key {k:?} should be accepted, got {err:?}"));
738        }
739    }
740
741    #[test]
742    fn validate_key_rejects_windows_reserved_chars() {
743        // `<`, `>`, `:`, `"`, `|`, `?`, `*` aren't allowed in Windows
744        // filenames; control bytes (\x01-\x1F) likewise. Reject so the
745        // local backend behaves the same on every platform.
746        let rejected = [
747            "foo<bar",
748            "foo>bar",
749            "foo:bar",
750            "foo\"bar",
751            "foo|bar",
752            "foo?bar",
753            "foo*bar",
754            "foo\x01bar",
755            "foo\x1fbar",
756        ];
757        for k in rejected {
758            let err = validate_key(k).unwrap_err();
759            assert!(
760                matches!(err, BlobStoreError::InvalidInput(_)),
761                "key {k:?} should be rejected"
762            );
763        }
764    }
765
766    #[test]
767    fn validate_key_rejects_windows_reserved_names() {
768        // `con.png`, `nul/foo`, `com1.txt`, etc. error with I/O on
769        // Windows even with valid characters and casing. Reject the
770        // entire reserved set per segment.
771        let rejected = [
772            "con",
773            "con.png",
774            "con/foo.png",
775            "x/nul",
776            "x/nul.txt",
777            "aux.bin",
778            "prn",
779            "com1.log",
780            "com9",
781            "lpt1",
782            "lpt9.txt",
783        ];
784        for k in rejected {
785            let err = validate_key(k).unwrap_err();
786            assert!(
787                matches!(err, BlobStoreError::InvalidInput(_)),
788                "key {k:?} should be rejected"
789            );
790        }
791        // Names that *contain* a reserved word but aren't equal to one
792        // before the first dot stay valid.
793        let accepted = [
794            "console.png",
795            "lptastic.txt",
796            "x/auxiliary.bin",
797            "con-tinuation.png",
798            "com10.log", // reserved set is com1-9 only
799        ];
800        for k in accepted {
801            validate_key(k)
802                .unwrap_or_else(|err| panic!("key {k:?} should be accepted, got {err:?}"));
803        }
804    }
805
806    #[test]
807    fn validate_key_rejects_trailing_dot_or_space_segments() {
808        // Windows strips trailing `.` and trailing space from
809        // filenames, so two distinct logical keys would alias on the
810        // local backend (and bypass the reserved-name guard for
811        // `con ` / `con.`). Reject up-front.
812        let rejected = [
813            "foo.",            // trailing dot
814            "avatars/me.png.", // trailing dot on last segment
815            "x./y",            // trailing dot mid-path
816            "foo ",            // trailing space
817            "x /y",            // trailing space mid-path
818            "con ",            // would alias `con` after Windows normalization
819            "con.",            // same
820        ];
821        for k in rejected {
822            let err = validate_key(k).unwrap_err();
823            assert!(
824                matches!(err, BlobStoreError::InvalidInput(_)),
825                "key {k:?} should be rejected"
826            );
827        }
828        // Internal/leading dots and spaces are still fine; only the
829        // segment-trailing forms are forbidden.
830        let accepted = ["foo.bar", "a b", " foo", "x/y/.hidden"];
831        for k in accepted {
832            validate_key(k)
833                .unwrap_or_else(|err| panic!("key {k:?} should be accepted, got {err:?}"));
834        }
835    }
836
837    #[test]
838    fn error_status_mapping() {
839        assert_eq!(
840            BlobStoreError::NotFound("x".into()).status(),
841            http::StatusCode::NOT_FOUND
842        );
843        assert_eq!(
844            BlobStoreError::PermissionDenied("x".into()).status(),
845            http::StatusCode::FORBIDDEN
846        );
847        assert_eq!(
848            BlobStoreError::InvalidInput("x".into()).status(),
849            http::StatusCode::BAD_REQUEST
850        );
851        assert_eq!(
852            BlobStoreError::Signature("x".into()).status(),
853            http::StatusCode::FORBIDDEN
854        );
855        assert_eq!(
856            BlobStoreError::PayloadTooLarge("x".into()).status(),
857            http::StatusCode::PAYLOAD_TOO_LARGE
858        );
859        assert_eq!(
860            BlobStoreError::Unsupported("x".into()).status(),
861            http::StatusCode::NOT_IMPLEMENTED
862        );
863        assert_eq!(
864            BlobStoreError::Backend("x".into()).status(),
865            http::StatusCode::INTERNAL_SERVER_ERROR
866        );
867    }
868
869    #[test]
870    fn error_into_autumn_error_preserves_status() {
871        let err = BlobStoreError::NotFound("k".into()).into_autumn_error();
872        assert_eq!(err.status(), http::StatusCode::NOT_FOUND);
873    }
874
875    // ── RED: presign_put default returns Unsupported ────────────────────────
876
877    struct NoOpStore;
878    impl BlobStore for NoOpStore {
879        fn provider_id(&self) -> &'static str {
880            "noop"
881        }
882        fn put<'a>(&'a self, _: &'a str, _: &'a str, _: bytes::Bytes) -> BlobFuture<'a, Blob> {
883            Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
884        }
885        fn put_stream<'a>(
886            &'a self,
887            _: &'a str,
888            _: &'a str,
889            _: ByteStream<'a>,
890        ) -> BlobFuture<'a, Blob> {
891            Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
892        }
893        fn get<'a>(&'a self, _: &'a str) -> BlobFuture<'a, bytes::Bytes> {
894            Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
895        }
896        fn delete<'a>(&'a self, _: &'a str) -> BlobFuture<'a, ()> {
897            Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
898        }
899        fn head<'a>(&'a self, _: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
900            Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
901        }
902        fn presigned_url<'a>(&'a self, _: &'a str, _: Duration) -> BlobFuture<'a, String> {
903            Box::pin(async { Err(BlobStoreError::Unsupported("noop".into())) })
904        }
905    }
906
907    #[tokio::test]
908    async fn presign_put_default_returns_unsupported() {
909        let store = NoOpStore;
910        let err = store
911            .presign_put("avatars/me.png", "image/png", Duration::from_secs(300))
912            .await
913            .unwrap_err();
914        assert!(
915            matches!(err, BlobStoreError::Unsupported(_)),
916            "expected Unsupported, got {err:?}"
917        );
918        assert_eq!(err.status(), http::StatusCode::NOT_IMPLEMENTED);
919    }
920
921    #[test]
922    fn presign_put_result_fields_accessible() {
923        let r = PresignPutResult {
924            url: "https://example.com/upload".into(),
925            method: "PUT".into(),
926            headers: std::collections::HashMap::from([("Content-Type".into(), "image/png".into())]),
927            expires_in: Duration::from_secs(300),
928        };
929        assert_eq!(r.url, "https://example.com/upload");
930        assert_eq!(r.method, "PUT");
931        assert_eq!(
932            r.headers.get("Content-Type").map(String::as_str),
933            Some("image/png")
934        );
935        assert_eq!(r.expires_in, Duration::from_secs(300));
936    }
937}