use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use futures::Stream;
use thiserror::Error;
pub mod blob;
pub mod config;
pub mod local;
pub mod migrations;
pub use blob::{Blob, BlobMeta};
pub use config::{
StorageBackend, StorageBackendConfigError, StorageBackendPlan, StorageConfig,
StorageLocalConfig, StorageS3Config,
};
pub use local::LocalBlobStore;
pub type BlobFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, BlobStoreError>> + Send + 'a>>;
pub type ByteStream<'a> = Pin<Box<dyn Stream<Item = Result<Bytes, BlobStoreError>> + Send + 'a>>;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BlobStoreError {
#[error("blob not found: {0}")]
NotFound(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("payload too large: {0}")]
PayloadTooLarge(String),
#[error("io error: {0}")]
Io(String),
#[error("operation not supported: {0}")]
Unsupported(String),
#[error("signature error: {0}")]
Signature(String),
#[error("backend error: {0}")]
Backend(String),
}
impl BlobStoreError {
#[must_use]
pub fn io(err: impl std::fmt::Display) -> Self {
Self::Io(err.to_string())
}
#[must_use]
pub fn backend(err: impl std::fmt::Display) -> Self {
Self::Backend(err.to_string())
}
}
impl BlobStoreError {
#[must_use]
pub const fn status(&self) -> http::StatusCode {
match self {
Self::NotFound(_) => http::StatusCode::NOT_FOUND,
Self::PermissionDenied(_) | Self::Signature(_) => http::StatusCode::FORBIDDEN,
Self::InvalidInput(_) => http::StatusCode::BAD_REQUEST,
Self::PayloadTooLarge(_) => http::StatusCode::PAYLOAD_TOO_LARGE,
Self::Unsupported(_) => http::StatusCode::NOT_IMPLEMENTED,
Self::Io(_) | Self::Backend(_) => http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[must_use]
pub fn into_autumn_error(self) -> crate::AutumnError {
let status = self.status();
crate::AutumnError::internal_server_error(self).with_status(status)
}
}
pub trait BlobStore: Send + Sync + 'static {
fn provider_id(&self) -> &str;
fn put<'a>(&'a self, key: &'a str, content_type: &'a str, bytes: Bytes)
-> BlobFuture<'a, Blob>;
fn put_stream<'a>(
&'a self,
key: &'a str,
content_type: &'a str,
data: ByteStream<'a>,
) -> BlobFuture<'a, Blob>;
fn get<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Bytes>;
fn delete<'a>(&'a self, key: &'a str) -> BlobFuture<'a, ()>;
fn head<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Option<BlobMeta>>;
fn presigned_url<'a>(&'a self, key: &'a str, expires_in: Duration) -> BlobFuture<'a, String>;
}
pub type SharedBlobStore = Arc<dyn BlobStore>;
#[derive(Clone)]
pub struct BlobStoreState {
inner: SharedBlobStore,
}
impl BlobStoreState {
#[must_use]
pub fn new(store: SharedBlobStore) -> Self {
Self { inner: store }
}
#[must_use]
pub fn store(&self) -> &SharedBlobStore {
&self.inner
}
}
pub fn validate_key(key: &str) -> Result<(), BlobStoreError> {
check_basic_formatting(key)?;
check_windows_paths(key)?;
for segment in key.split('/') {
validate_segment(segment)?;
}
check_reserved_suffixes(key)?;
check_case_folding(key)?;
Ok(())
}
fn check_basic_formatting(key: &str) -> Result<(), BlobStoreError> {
if key.is_empty() {
return Err(BlobStoreError::InvalidInput("blob key is empty".into()));
}
if key.contains('\0') {
return Err(BlobStoreError::InvalidInput(
"blob key contains NUL byte".into(),
));
}
if key.starts_with('/') || key.starts_with('\\') {
return Err(BlobStoreError::InvalidInput(
"blob key must be relative".into(),
));
}
if key.contains('\\') {
return Err(BlobStoreError::InvalidInput(
"blob key contains a backslash; use `/` as the segment separator".into(),
));
}
Ok(())
}
fn check_windows_paths(key: &str) -> Result<(), BlobStoreError> {
let bytes = key.as_bytes();
let drive_letter = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
if drive_letter {
return Err(BlobStoreError::InvalidInput(
"blob key looks like a Windows drive-letter path".into(),
));
}
if key.starts_with("\\\\") || key.starts_with("//") {
return Err(BlobStoreError::InvalidInput(
"blob key looks like a UNC / network path".into(),
));
}
Ok(())
}
fn validate_segment(segment: &str) -> Result<(), BlobStoreError> {
if segment == ".." {
return Err(BlobStoreError::InvalidInput(
"blob key contains traversal segment".into(),
));
}
if segment == "." {
return Err(BlobStoreError::InvalidInput(
"blob key contains a `.` segment".into(),
));
}
if segment.is_empty() {
return Err(BlobStoreError::InvalidInput(
"blob key contains an empty segment".into(),
));
}
if segment.ends_with('.') || segment.ends_with(' ') {
return Err(BlobStoreError::InvalidInput(format!(
"blob key segment {segment:?} ends with `.` or space; Windows normalizes \
these and would alias the segment with its stripped form"
)));
}
if segment.bytes().any(|b| {
matches!(
b,
b'<' | b'>' | b':' | b'"' | b'|' | b'?' | b'*' | 0x01..=0x1F
)
}) {
return Err(BlobStoreError::InvalidInput(
"blob key contains a Windows-reserved filename character (`<`, `>`, \
`:`, `\"`, `|`, `?`, `*`, or a control byte) — keys must be portable \
across local and S3 backends"
.into(),
));
}
let basename = segment.split('.').next().unwrap_or("");
if WINDOWS_RESERVED_NAMES.contains(&basename) {
return Err(BlobStoreError::InvalidInput(format!(
"blob key segment {segment:?} starts with a Windows-reserved device name \
(`con`, `prn`, `aux`, `nul`, `com1-9`, `lpt1-9`)"
)));
}
Ok(())
}
fn check_reserved_suffixes(key: &str) -> Result<(), BlobStoreError> {
if let Some(last) = key.rsplit('/').next() {
let bytes = last.as_bytes();
if bytes.len() >= 5 && bytes[bytes.len() - 5..].eq_ignore_ascii_case(b".meta") {
return Err(BlobStoreError::InvalidInput(
"blob keys ending in `.meta` are reserved (local backend uses `<key>.meta` \
sidecar files for content-type metadata)"
.into(),
));
}
}
Ok(())
}
fn check_case_folding(key: &str) -> Result<(), BlobStoreError> {
for c in key.chars() {
let mut lower = c.to_lowercase();
let first = lower.next();
let trailing = lower.next();
if first != Some(c) || trailing.is_some() {
return Err(BlobStoreError::InvalidInput(
"blob keys must be lowercase (uppercase Unicode aliases on case-insensitive \
filesystems and breaks portability between local and S3)"
.into(),
));
}
}
Ok(())
}
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
"com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_key_accepts_typical_paths() {
validate_key("avatars/123.png").unwrap();
validate_key("a/b/c/d.txt").unwrap();
}
#[test]
fn validate_key_rejects_traversal() {
let err = validate_key("../etc/passwd").unwrap_err();
assert!(matches!(err, BlobStoreError::InvalidInput(_)));
}
#[test]
fn validate_key_rejects_absolute() {
let err = validate_key("/etc/passwd").unwrap_err();
assert!(matches!(err, BlobStoreError::InvalidInput(_)));
}
#[test]
fn validate_key_rejects_empty() {
let err = validate_key("").unwrap_err();
assert!(matches!(err, BlobStoreError::InvalidInput(_)));
}
#[test]
fn validate_key_rejects_nul() {
let err = validate_key("a\0b").unwrap_err();
assert!(matches!(err, BlobStoreError::InvalidInput(_)));
}
#[test]
fn validate_key_rejects_windows_drive_letter() {
for k in [r"C:\tmp\x", "C:/tmp/x", "z:\\foo", "a:bar"] {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected"
);
}
}
#[test]
fn validate_key_rejects_unc_paths() {
for k in [r"\\server\share\file", "//server/share/file"] {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected"
);
}
}
#[test]
fn validate_key_rejects_dot_segments() {
for k in ["a/./b", "./foo", "a/././b", "a/.\\b"] {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected"
);
}
}
#[test]
fn validate_key_rejects_empty_segments() {
for k in ["a//b", "a/b/", "a///b"] {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected"
);
}
}
#[test]
fn validate_key_rejects_backslash_separator() {
for k in [r"a\b", r"avatars\me.png", r"x\y\z"] {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected"
);
}
}
#[test]
fn validate_key_reserves_meta_suffix() {
for k in ["foo.meta", "avatars/me.meta", "FOO.META", "x/y/Z.MeTa"] {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be reserved",
);
}
for k in ["meta.png", "foo.metadata", "a.meta.gz", "metafile"] {
validate_key(k).unwrap_or_else(|_| panic!("key {k:?} should be accepted"));
}
}
#[test]
fn validate_key_handles_non_ascii_without_panicking() {
for k in ["ééé", "résumé.png", "東京", "cafe\u{0301}"] {
validate_key(k).unwrap_or_else(|err| {
panic!("non-ASCII key {k:?} should validate cleanly, got {err:?}")
});
}
let err = validate_key("résumé.meta").unwrap_err();
assert!(matches!(err, BlobStoreError::InvalidInput(_)));
}
#[test]
fn validate_key_rejects_uppercase() {
let rejected = [
"Foo.png",
"AVATARS/me.png",
"aBc",
"x/Y/z",
"Ärger.png",
"documents/Émile.txt",
"İstanbul/photo.jpg",
"ΟΛΑ.txt", ];
for k in rejected {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected for uppercase"
);
}
let accepted = [
"foo.png",
"avatars/me.png",
"résumé.png",
"ärger.png",
"émile.txt",
"istanbul/photo.jpg",
"東京/photo.jpg", "café/menu.txt",
];
for k in accepted {
validate_key(k)
.unwrap_or_else(|err| panic!("key {k:?} should be accepted, got {err:?}"));
}
}
#[test]
fn validate_key_rejects_windows_reserved_chars() {
let rejected = [
"foo<bar",
"foo>bar",
"foo:bar",
"foo\"bar",
"foo|bar",
"foo?bar",
"foo*bar",
"foo\x01bar",
"foo\x1fbar",
];
for k in rejected {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected"
);
}
}
#[test]
fn validate_key_rejects_windows_reserved_names() {
let rejected = [
"con",
"con.png",
"con/foo.png",
"x/nul",
"x/nul.txt",
"aux.bin",
"prn",
"com1.log",
"com9",
"lpt1",
"lpt9.txt",
];
for k in rejected {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected"
);
}
let accepted = [
"console.png",
"lptastic.txt",
"x/auxiliary.bin",
"con-tinuation.png",
"com10.log", ];
for k in accepted {
validate_key(k)
.unwrap_or_else(|err| panic!("key {k:?} should be accepted, got {err:?}"));
}
}
#[test]
fn validate_key_rejects_trailing_dot_or_space_segments() {
let rejected = [
"foo.", "avatars/me.png.", "x./y", "foo ", "x /y", "con ", "con.", ];
for k in rejected {
let err = validate_key(k).unwrap_err();
assert!(
matches!(err, BlobStoreError::InvalidInput(_)),
"key {k:?} should be rejected"
);
}
let accepted = ["foo.bar", "a b", " foo", "x/y/.hidden"];
for k in accepted {
validate_key(k)
.unwrap_or_else(|err| panic!("key {k:?} should be accepted, got {err:?}"));
}
}
#[test]
fn error_status_mapping() {
assert_eq!(
BlobStoreError::NotFound("x".into()).status(),
http::StatusCode::NOT_FOUND
);
assert_eq!(
BlobStoreError::PermissionDenied("x".into()).status(),
http::StatusCode::FORBIDDEN
);
assert_eq!(
BlobStoreError::InvalidInput("x".into()).status(),
http::StatusCode::BAD_REQUEST
);
assert_eq!(
BlobStoreError::Signature("x".into()).status(),
http::StatusCode::FORBIDDEN
);
assert_eq!(
BlobStoreError::PayloadTooLarge("x".into()).status(),
http::StatusCode::PAYLOAD_TOO_LARGE
);
assert_eq!(
BlobStoreError::Unsupported("x".into()).status(),
http::StatusCode::NOT_IMPLEMENTED
);
assert_eq!(
BlobStoreError::Backend("x".into()).status(),
http::StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn error_into_autumn_error_preserves_status() {
let err = BlobStoreError::NotFound("k".into()).into_autumn_error();
assert_eq!(err.status(), http::StatusCode::NOT_FOUND);
}
}