pub mod error;
pub mod types;
pub mod traits;
#[path = "local_simple.rs"]
pub mod local;
#[cfg(feature = "s3")]
pub mod s3;
#[cfg(feature = "s3")]
pub mod minio;
#[cfg(feature = "compression")]
pub mod compression;
#[cfg(feature = "security")]
pub mod security;
pub use error::{StorageError, StorageResult};
pub use types::{
StorageFile, StorageConfig, StorageBackend, UploadOptions,
DownloadOptions, PresignedUrlOptions, FileMetadata, StorageStats,
HttpMethod, ByteRange, LocalStorageConfig,
S3StorageConfig, MinIOStorageConfig, GcsStorageConfig,
};
pub use traits::{StorageService, StorageManager, StorageMonitor, FileListResult};
#[cfg(feature = "compression")]
pub use compression::{
ImageCompressor, CompressionResult, CompressionQuality,
FileCategory, CompressionAlgorithm, CompressionConfig,
TextCompressionConfig, DocumentCompressionConfig,
};
#[cfg(feature = "security")]
pub use security::{
SecurityEngine, SecurityConfig, ThreatLevel, SecurityAnalysis,
ExecutableMetadata, DigitalSignature,
Threat, ThreatType, SizeAnalysis, ContentAnalysis,
};
pub const DEFAULT_MULTIPART_THRESHOLD: u64 = 100 * 1024 * 1024; pub const DEFAULT_CHUNK_SIZE: usize = 8 * 1024 * 1024; pub const DEFAULT_PRESIGNED_EXPIRY: u64 = 3600;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn default_config() -> StorageConfig {
StorageConfig::default()
}
#[cfg(any(feature = "s3", test))]
fn require_env_non_empty(var_name: &str) -> StorageResult<String> {
let val = std::env::var(var_name)
.map_err(|_| StorageError::InvalidConfiguration(format!("{var_name} is required but not set")))?;
if val.trim().is_empty() {
return Err(StorageError::InvalidConfiguration(format!("{var_name} is required but empty")));
}
Ok(val)
}
#[cfg(feature = "s3")]
fn warn_if_empty(var_name: &str, value: &str) {
if value.is_empty() {
tracing::warn!(var = var_name, "Credential is empty; falling back to SDK default chain");
}
}
pub async fn from_env() -> StorageResult<Box<dyn StorageService>> {
let backend = std::env::var("STORAGE_BACKEND")
.unwrap_or_else(|_| "local".to_string())
.parse()
.map_err(|_| StorageError::InvalidConfiguration("STORAGE_BACKEND".to_string()))?;
let config = StorageConfig::from_env()?;
match backend {
StorageBackend::Local => {
let local_config = config.into_local_config()
.ok_or_else(|| StorageError::InvalidConfiguration("Local storage configuration missing".to_string()))?;
Ok(Box::new(crate::local::LocalStorage::new(local_config)?))
}
#[cfg(feature = "s3")]
StorageBackend::S3 => {
let bucket = std::env::var("S3_BUCKET").unwrap_or_else(|_| config.bucket.clone());
if bucket.is_empty() || bucket == "default" {
return Err(StorageError::InvalidConfiguration(
"S3_BUCKET (or STORAGE_BUCKET) is required for S3 backend".to_string(),
));
}
let access_key_id = std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_default();
let secret_access_key = std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_default();
warn_if_empty("AWS_ACCESS_KEY_ID", &access_key_id);
warn_if_empty("AWS_SECRET_ACCESS_KEY", &secret_access_key);
let s3_config = S3StorageConfig {
region: std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
access_key_id,
secret_access_key,
bucket,
endpoint: std::env::var("S3_ENDPOINT").ok(),
base_path: config.base_path.clone(),
path_style: std::env::var("S3_PATH_STYLE").map(|v| v == "true").unwrap_or(false),
..Default::default()
};
Ok(Box::new(crate::s3::S3Storage::new(s3_config).await?))
}
#[cfg(not(feature = "s3"))]
StorageBackend::S3 => {
Err(StorageError::UnsupportedOperation {
operation: "storage creation".to_string(),
backend: "s3 (enable the 's3' feature)".to_string(),
})
}
#[cfg(feature = "s3")]
StorageBackend::MinIO => {
let endpoint = require_env_non_empty("MINIO_ENDPOINT")?;
let access_key = std::env::var("MINIO_ACCESS_KEY")
.or_else(|_| std::env::var("AWS_ACCESS_KEY_ID"))
.unwrap_or_default();
let secret_key = std::env::var("MINIO_SECRET_KEY")
.or_else(|_| std::env::var("AWS_SECRET_ACCESS_KEY"))
.unwrap_or_default();
if access_key.is_empty() {
return Err(StorageError::InvalidConfiguration(
"MINIO_ACCESS_KEY (or AWS_ACCESS_KEY_ID) is required for MinIO backend".to_string(),
));
}
if secret_key.is_empty() {
return Err(StorageError::InvalidConfiguration(
"MINIO_SECRET_KEY (or AWS_SECRET_ACCESS_KEY) is required for MinIO backend".to_string(),
));
}
let bucket = std::env::var("MINIO_BUCKET").unwrap_or_else(|_| config.bucket.clone());
if bucket.is_empty() || bucket == "default" {
return Err(StorageError::InvalidConfiguration(
"MINIO_BUCKET (or STORAGE_BUCKET) is required for MinIO backend".to_string(),
));
}
let minio_config = MinIOStorageConfig {
endpoint,
access_key,
secret_key,
bucket,
region: std::env::var("MINIO_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
base_path: config.base_path.clone(),
..Default::default()
};
Ok(Box::new(crate::minio::MinIOStorage::new(minio_config).await?))
}
#[cfg(not(feature = "s3"))]
StorageBackend::MinIO => {
Err(StorageError::UnsupportedOperation {
operation: "storage creation".to_string(),
backend: "minio (enable the 's3' feature)".to_string(),
})
}
StorageBackend::AzureBlob => {
Err(StorageError::UnsupportedOperation {
operation: "storage creation".to_string(),
backend: "azure_blob".to_string(),
})
}
StorageBackend::GCS => {
Err(StorageError::UnsupportedOperation {
operation: "storage creation".to_string(),
backend: "gcs (not yet implemented)".to_string(),
})
}
}
}
pub fn detect_mime_type(file_path: &str) -> String {
mime_guess::from_path(file_path)
.first_or_octet_stream()
.to_string()
}
pub fn sanitize_filename(filename: &str) -> String {
use std::path::Component;
let path = std::path::Path::new(filename);
let mut result = String::new();
for component in path.components() {
match component {
Component::Normal(s) => {
if let Some(s) = s.to_str() {
let sanitized = s
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect::<String>();
if !result.is_empty() {
result.push('/');
}
result.push_str(&sanitized);
}
}
Component::RootDir => continue,
Component::CurDir => continue,
Component::ParentDir => {
if !result.is_empty() {
result.push('/');
}
result.push_str("_parent");
}
Component::Prefix(_) => continue,
}
}
if result.is_empty() {
result.push_str("file");
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mime_type_detection() {
assert_eq!(detect_mime_type("test.txt"), "text/plain");
assert_eq!(detect_mime_type("test.json"), "application/json");
assert_eq!(detect_mime_type("test.jpg"), "image/jpeg");
assert_eq!(detect_mime_type("unknown.totally_unknown_ext"), "application/octet-stream");
}
#[test]
fn test_filename_sanitization() {
assert_eq!(sanitize_filename("normal_file.txt"), "normal_file.txt");
assert_eq!(sanitize_filename("file/with\\slashes"), "file/with_slashes");
assert_eq!(sanitize_filename("../parent/directory"), "_parent/parent/directory");
assert_eq!(sanitize_filename("file:with*special?chars"), "file_with_special_chars");
assert_eq!(sanitize_filename(""), "file");
}
#[test]
fn test_default_config() {
let config = default_config();
assert_eq!(config.backend, StorageBackend::Local);
assert_eq!(config.multipart_threshold, DEFAULT_MULTIPART_THRESHOLD);
}
#[test]
fn test_version() {
assert!(!VERSION.is_empty());
}
#[test]
fn test_require_env_non_empty_missing() {
let result = require_env_non_empty("__BACKBONE_TEST_NONEXISTENT_VAR__");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("required but not set"), "got: {msg}");
}
#[test]
fn test_require_env_non_empty_blank() {
let key = "__BACKBONE_TEST_EMPTY_VAR__";
std::env::set_var(key, " ");
let result = require_env_non_empty(key);
std::env::remove_var(key);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("required but empty"), "got: {msg}");
}
#[test]
fn test_require_env_non_empty_ok() {
let key = "__BACKBONE_TEST_OK_VAR__";
std::env::set_var(key, "value");
let result = require_env_non_empty(key);
std::env::remove_var(key);
assert_eq!(result.unwrap(), "value");
}
}