#[cfg(feature = "storage-s3")]
pub mod s3;
pub mod registry;
pub use registry::StorageRegistry;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("file not found: {0}")]
NotFound(String),
#[error("invalid path: {0}")]
InvalidPath(String),
#[error("io error: {0}")]
Io(String),
}
#[async_trait]
pub trait Storage: Send + Sync + 'static {
async fn save(&self, key: &str, data: &[u8]) -> Result<(), StorageError>;
async fn load(&self, key: &str) -> Result<Vec<u8>, StorageError>;
async fn delete(&self, key: &str) -> Result<(), StorageError>;
async fn exists(&self, key: &str) -> Result<bool, StorageError>;
fn url(&self, key: &str) -> Option<String>;
async fn presigned_get_url(&self, _key: &str, _ttl: std::time::Duration) -> Option<String> {
None
}
async fn presigned_put_url(
&self,
_key: &str,
_ttl: std::time::Duration,
_content_type: Option<&str>,
) -> Option<String> {
None
}
}
pub type BoxedStorage = Arc<dyn Storage>;
pub fn validate_key(key: &str) -> Result<(), StorageError> {
if key.is_empty() {
return Err(StorageError::InvalidPath("empty key".into()));
}
if key.starts_with('/') {
return Err(StorageError::InvalidPath(format!(
"key must be relative: {key}"
)));
}
if key.contains("..") {
return Err(StorageError::InvalidPath(format!(
"key contains `..`: {key}"
)));
}
if key.contains('\0') {
return Err(StorageError::InvalidPath(format!("key contains null byte")));
}
Ok(())
}
pub struct LocalStorage {
root: PathBuf,
base_url: Option<String>,
}
impl LocalStorage {
#[must_use]
pub fn new(root: PathBuf) -> Self {
Self {
root,
base_url: None,
}
}
#[must_use]
pub fn with_base_url(mut self, base: impl Into<String>) -> Self {
self.base_url = Some(base.into());
self
}
fn full_path(&self, key: &str) -> PathBuf {
self.root.join(key)
}
}
#[async_trait]
impl Storage for LocalStorage {
async fn save(&self, key: &str, data: &[u8]) -> Result<(), StorageError> {
validate_key(key)?;
let path = self.full_path(key);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| StorageError::Io(e.to_string()))?;
}
tokio::fs::write(&path, data)
.await
.map_err(|e| StorageError::Io(e.to_string()))
}
async fn load(&self, key: &str) -> Result<Vec<u8>, StorageError> {
validate_key(key)?;
let path = self.full_path(key);
match tokio::fs::read(&path).await {
Ok(bytes) => Ok(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(StorageError::NotFound(key.to_owned()))
}
Err(e) => Err(StorageError::Io(e.to_string())),
}
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
validate_key(key)?;
let path = self.full_path(key);
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(StorageError::Io(e.to_string())),
}
}
async fn exists(&self, key: &str) -> Result<bool, StorageError> {
validate_key(key)?;
Ok(tokio::fs::try_exists(self.full_path(key))
.await
.map_err(|e| StorageError::Io(e.to_string()))?)
}
fn url(&self, key: &str) -> Option<String> {
let base = self.base_url.as_ref()?;
Some(format!("{}/{}", base.trim_end_matches('/'), key))
}
}
#[derive(Default)]
pub struct InMemoryStorage {
files: Mutex<HashMap<String, Vec<u8>>>,
}
impl InMemoryStorage {
#[must_use]
pub fn new() -> Self {
Self {
files: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl Storage for InMemoryStorage {
async fn save(&self, key: &str, data: &[u8]) -> Result<(), StorageError> {
validate_key(key)?;
self.files
.lock()
.expect("storage mutex poisoned")
.insert(key.to_owned(), data.to_vec());
Ok(())
}
async fn load(&self, key: &str) -> Result<Vec<u8>, StorageError> {
validate_key(key)?;
self.files
.lock()
.expect("storage mutex poisoned")
.get(key)
.cloned()
.ok_or_else(|| StorageError::NotFound(key.to_owned()))
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
validate_key(key)?;
self.files
.lock()
.expect("storage mutex poisoned")
.remove(key);
Ok(())
}
async fn exists(&self, key: &str) -> Result<bool, StorageError> {
validate_key(key)?;
Ok(self
.files
.lock()
.expect("storage mutex poisoned")
.contains_key(key))
}
fn url(&self, _key: &str) -> Option<String> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_rejects_empty() {
assert!(matches!(
validate_key(""),
Err(StorageError::InvalidPath(_))
));
}
#[test]
fn validate_rejects_leading_slash() {
assert!(matches!(
validate_key("/etc/passwd"),
Err(StorageError::InvalidPath(_))
));
}
#[test]
fn validate_rejects_dotdot() {
assert!(matches!(
validate_key("../escape"),
Err(StorageError::InvalidPath(_))
));
assert!(matches!(
validate_key("safe/../bad"),
Err(StorageError::InvalidPath(_))
));
}
#[test]
fn validate_accepts_normal_keys() {
assert!(validate_key("avatars/alice.png").is_ok());
assert!(validate_key("file.txt").is_ok());
}
#[tokio::test]
async fn in_memory_save_and_load() {
let s = InMemoryStorage::new();
s.save("k", b"hello").await.unwrap();
assert_eq!(s.load("k").await.unwrap(), b"hello".to_vec());
}
#[tokio::test]
async fn in_memory_load_missing_is_not_found() {
let s = InMemoryStorage::new();
let err = s.load("nope").await.unwrap_err();
assert!(matches!(err, StorageError::NotFound(_)));
}
#[tokio::test]
async fn in_memory_delete_then_load_is_not_found() {
let s = InMemoryStorage::new();
s.save("k", b"data").await.unwrap();
s.delete("k").await.unwrap();
let err = s.load("k").await.unwrap_err();
assert!(matches!(err, StorageError::NotFound(_)));
}
#[tokio::test]
async fn in_memory_exists() {
let s = InMemoryStorage::new();
assert!(!s.exists("k").await.unwrap());
s.save("k", b"d").await.unwrap();
assert!(s.exists("k").await.unwrap());
}
#[tokio::test]
async fn in_memory_save_validates_key() {
let s = InMemoryStorage::new();
let err = s.save("../nope", b"x").await.unwrap_err();
assert!(matches!(err, StorageError::InvalidPath(_)));
}
#[tokio::test]
async fn local_storage_save_and_load() {
let dir = tempdir();
let s = LocalStorage::new(dir.clone());
s.save("hello.txt", b"world").await.unwrap();
let bytes = s.load("hello.txt").await.unwrap();
assert_eq!(bytes, b"world");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn local_storage_creates_subdirs() {
let dir = tempdir();
let s = LocalStorage::new(dir.clone());
s.save("a/b/c/file.txt", b"deep").await.unwrap();
assert_eq!(s.load("a/b/c/file.txt").await.unwrap(), b"deep");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn local_storage_url_with_base() {
let dir = tempdir();
let s = LocalStorage::new(dir.clone()).with_base_url("https://cdn.example.com/uploads");
assert_eq!(
s.url("avatars/alice.png").as_deref(),
Some("https://cdn.example.com/uploads/avatars/alice.png"),
);
let _ = std::fs::remove_dir_all(&dir);
}
fn tempdir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
let mut p = std::env::temp_dir();
p.push(format!("rustango_storage_test_{pid}_{n}"));
let _ = std::fs::remove_dir_all(&p);
p
}
}