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}