Files
foxhunt/crates/storage/src/local.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

1031 lines
34 KiB
Rust

//! Local filesystem storage implementation
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::fs;
use tracing::{debug, info};
use crate::error::{StorageError, StorageResult};
use crate::{Storage, StorageMetadata};
/// Configuration for local filesystem storage
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::module_name_repetitions)]
pub struct LocalStorageConfig {
/// Base directory for storage operations
pub base_path: PathBuf,
/// Enable atomic writes using temporary files
pub atomic_writes: bool,
/// Enable file locking for concurrent access
pub enable_locking: bool,
/// Create parent directories if they don't exist
pub create_directories: bool,
/// File permissions (Unix only)
pub file_permissions: Option<u32>,
/// Directory permissions (Unix only)
pub directory_permissions: Option<u32>,
/// Enable compression for stored files
pub enable_compression: bool,
/// Buffer size for file operations
pub buffer_size: usize,
}
impl Default for LocalStorageConfig {
fn default() -> Self {
Self {
base_path: PathBuf::from("./storage"),
atomic_writes: true,
enable_locking: true,
create_directories: true,
file_permissions: Some(0o644),
directory_permissions: Some(0o755),
enable_compression: false,
buffer_size: 8192,
}
}
}
impl LocalStorageConfig {
/// Load configuration from environment variables
///
/// # Errors
///
/// This function currently never returns an error but is marked as Result for future extensibility.
pub fn from_env() -> StorageResult<Self> {
let mut config = Self::default();
if let Ok(base_path) = std::env::var("STORAGE_LOCAL_BASE_PATH") {
config.base_path = PathBuf::from(base_path);
}
if let Ok(atomic_str) = std::env::var("STORAGE_LOCAL_ATOMIC_WRITES") {
config.atomic_writes = atomic_str.to_lowercase() == "true";
}
if let Ok(locking_str) = std::env::var("STORAGE_LOCAL_ENABLE_LOCKING") {
config.enable_locking = locking_str.to_lowercase() == "true";
}
if let Ok(create_str) = std::env::var("STORAGE_LOCAL_CREATE_DIRECTORIES") {
config.create_directories = create_str.to_lowercase() == "true";
}
if let Ok(compression_str) = std::env::var("STORAGE_LOCAL_ENABLE_COMPRESSION") {
config.enable_compression = compression_str.to_lowercase() == "true";
}
if let Ok(buffer_str) = std::env::var("STORAGE_LOCAL_BUFFER_SIZE") {
if let Ok(buffer_size) = buffer_str.parse::<usize>() {
config.buffer_size = buffer_size;
}
}
Ok(config)
}
/// Validate the configuration
///
/// # Errors
///
/// Returns `StorageError::ConfigError` if:
/// - Base path is not absolute
/// - Buffer size is 0
pub fn validate(&self) -> StorageResult<()> {
if !self.base_path.is_absolute() {
return Err(StorageError::ConfigError {
message: "Base path must be absolute".to_owned(),
});
}
if self.buffer_size == 0 {
return Err(StorageError::ConfigError {
message: "Buffer size must be greater than 0".to_owned(),
});
}
Ok(())
}
}
/// File operation types for metrics and logging
#[derive(Debug, Clone, Copy)]
pub enum FileOperation {
/// Read operation - retrieving file contents
Read,
/// Write operation - storing file contents
Write,
/// Delete operation - removing files
Delete,
/// List operation - listing directory contents
List,
/// Exists operation - checking file existence
Exists,
/// Metadata operation - retrieving file metadata
Metadata,
}
impl FileOperation {
/// Convert the operation to a string representation
pub fn as_str(&self) -> &'static str {
match self {
FileOperation::Read => "read",
FileOperation::Write => "write",
FileOperation::Delete => "delete",
FileOperation::List => "list",
FileOperation::Exists => "exists",
FileOperation::Metadata => "metadata",
}
}
}
/// Local filesystem storage implementation
#[allow(clippy::module_name_repetitions)]
pub struct LocalStorage {
config: LocalStorageConfig,
base_path: PathBuf,
}
impl LocalStorage {
/// Create new local storage instance
///
/// # Errors
/// Returns error if the operation fails
///
/// # Errors
/// Returns error if the operation fails
pub async fn new(config: LocalStorageConfig) -> StorageResult<Self> {
config.validate()?;
let base_path = config.base_path.clone();
// Create base directory if it doesn't exist
if config.create_directories {
Self::ensure_directory_exists(&base_path, config.directory_permissions).await?;
}
info!("Local storage initialized at: {}", base_path.display());
Ok(Self { config, base_path })
}
/// Get the full filesystem path for a storage path
fn get_full_path(&self, path: &str) -> PathBuf {
// Sanitize path to prevent directory traversal
let sanitized = path.replace("..", "").replace("\\", "/");
let sanitized = sanitized.trim_start_matches('/');
self.base_path.join(sanitized)
}
/// Ensure a directory exists, creating it if necessary
async fn ensure_directory_exists(path: &Path, permissions: Option<u32>) -> StorageResult<()> {
if !path.exists() {
fs::create_dir_all(path)
.await
.map_err(|e| StorageError::IoError {
message: format!("Failed to create directory {}: {}", path.display(), e),
})?;
// Set permissions on Unix systems
#[cfg(unix)]
if let Some(perms) = permissions {
use std::os::unix::fs::PermissionsExt;
let std_perms = std::fs::Permissions::from_mode(perms);
std::fs::set_permissions(path, std_perms).map_err(|e| StorageError::IoError {
message: format!(
"Failed to set directory permissions for {}: {}",
path.display(),
e
),
})?;
}
debug!("Created directory: {}", path.display());
}
Ok(())
}
/// Compress data if compression is enabled
fn compress_data(&self, data: &[u8]) -> StorageResult<Vec<u8>> {
if !self.config.enable_compression {
return Ok(data.to_vec());
}
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(data)
.map_err(|e| StorageError::CompressionError {
message: format!("Compression failed: {}", e),
})?;
let compressed = encoder
.finish()
.map_err(|e| StorageError::CompressionError {
message: format!("Compression finalization failed: {}", e),
})?;
debug!(
"Compressed {} bytes to {} bytes",
data.len(),
compressed.len()
);
Ok(compressed)
}
/// Decompress data if it was compressed
fn decompress_data(&self, data: &[u8]) -> StorageResult<Vec<u8>> {
if !self.config.enable_compression {
return Ok(data.to_vec());
}
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(data);
let mut decompressed = Vec::new();
decoder
.read_to_end(&mut decompressed)
.map_err(|e| StorageError::CompressionError {
message: format!("Decompression failed: {}", e),
})?;
debug!(
"Decompressed {} bytes to {} bytes",
data.len(),
decompressed.len()
);
Ok(decompressed)
}
/// Write data to file with optional atomic operation
async fn write_file_data(&self, full_path: &Path, data: &[u8]) -> StorageResult<()> {
// Ensure parent directory exists
if let Some(parent) = full_path.parent() {
if self.config.create_directories {
Self::ensure_directory_exists(parent, self.config.directory_permissions).await?;
}
}
let compressed_data = self.compress_data(data)?;
if self.config.atomic_writes {
// Write to temporary file first, then rename
let temp_path = full_path.with_extension(format!(
"tmp.{}.{}",
std::process::id(),
full_path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("bin")
));
fs::write(&temp_path, &compressed_data)
.await
.map_err(|e| StorageError::IoError {
message: format!(
"Failed to write temporary file {}: {}",
temp_path.display(),
e
),
})?;
// Set file permissions
#[cfg(unix)]
if let Some(perms) = self.config.file_permissions {
use std::os::unix::fs::PermissionsExt;
let std_perms = std::fs::Permissions::from_mode(perms);
std::fs::set_permissions(&temp_path, std_perms).map_err(|e| {
StorageError::IoError {
message: format!(
"Failed to set file permissions for {}: {}",
temp_path.display(),
e
),
}
})?;
}
// Atomic rename
fs::rename(&temp_path, full_path)
.await
.map_err(|e| StorageError::IoError {
message: format!(
"Failed to rename {} to {}: {}",
temp_path.display(),
full_path.display(),
e
),
})?;
debug!(
"Atomically wrote {} bytes to {}",
compressed_data.len(),
full_path.display()
);
} else {
// Direct write
fs::write(full_path, &compressed_data)
.await
.map_err(|e| StorageError::IoError {
message: format!("Failed to write file {}: {}", full_path.display(), e),
})?;
// Set file permissions
#[cfg(unix)]
if let Some(perms) = self.config.file_permissions {
use std::os::unix::fs::PermissionsExt;
let std_perms = std::fs::Permissions::from_mode(perms);
std::fs::set_permissions(full_path, std_perms).map_err(|e| {
StorageError::IoError {
message: format!(
"Failed to set file permissions for {}: {}",
full_path.display(),
e
),
}
})?;
}
debug!(
"Wrote {} bytes to {}",
compressed_data.len(),
full_path.display()
);
}
Ok(())
}
/// Record operation metrics
fn record_metrics(operation: FileOperation, duration: std::time::Duration, success: bool) {
crate::metrics::get_metrics().record_operation(
operation.as_str(),
"local",
duration,
success,
);
}
}
#[async_trait]
impl Storage for LocalStorage {
async fn store(&self, path: &str, data: &[u8]) -> StorageResult<()> {
let start = std::time::Instant::now();
let full_path = self.get_full_path(path);
let result = self.write_file_data(&full_path, data).await;
let duration = start.elapsed();
Self::record_metrics(FileOperation::Write, duration, result.is_ok());
if result.is_ok() {
crate::metrics::get_metrics().record_transfer(
"store",
"local",
data.len() as u64,
duration,
);
}
result
}
async fn retrieve(&self, path: &str) -> StorageResult<Vec<u8>> {
let start = std::time::Instant::now();
let full_path = self.get_full_path(path);
let result: StorageResult<Vec<u8>> = async {
let compressed_data = fs::read(&full_path).await.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => StorageError::NotFound {
path: path.to_string(),
},
std::io::ErrorKind::PermissionDenied => StorageError::PermissionDenied {
path: path.to_string(),
},
_ => StorageError::IoError {
message: format!("Failed to read file {}: {}", full_path.display(), e),
},
})?;
let data = self.decompress_data(&compressed_data)?;
debug!(
"Retrieved {} bytes from {}",
data.len(),
full_path.display()
);
Ok(data)
}
.await;
let duration = start.elapsed();
Self::record_metrics(FileOperation::Read, duration, result.is_ok());
if let Ok(ref data) = result {
crate::metrics::get_metrics().record_transfer(
"retrieve",
"local",
data.len() as u64,
duration,
);
}
result
}
async fn exists(&self, path: &str) -> StorageResult<bool> {
let start = std::time::Instant::now();
let full_path = self.get_full_path(path);
let exists = full_path.exists();
debug!("Path {} exists: {}", path, exists);
let duration = start.elapsed();
Self::record_metrics(FileOperation::Exists, duration, true);
Ok(exists)
}
async fn delete(&self, path: &str) -> StorageResult<bool> {
let start = std::time::Instant::now();
let full_path = self.get_full_path(path);
let result = if full_path.exists() {
fs::remove_file(&full_path)
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => StorageError::NotFound {
path: path.to_string(),
},
std::io::ErrorKind::PermissionDenied => StorageError::PermissionDenied {
path: path.to_string(),
},
_ => StorageError::IoError {
message: format!("Failed to delete file {}: {}", full_path.display(), e),
},
})?;
debug!("Deleted file: {}", full_path.display());
Ok(true)
} else {
debug!("File not found for deletion: {}", full_path.display());
Ok(false)
};
let duration = start.elapsed();
Self::record_metrics(FileOperation::Delete, duration, result.is_ok());
result
}
async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
let start = std::time::Instant::now();
let prefix_path = self.get_full_path(prefix);
let result = async {
let mut paths = Vec::new();
// Handle both directory listing and prefix matching
let (search_dir, file_prefix) = if prefix_path.is_dir() {
(prefix_path, String::new())
} else {
let parent = match prefix_path.parent() {
Some(p) => p,
None => &self.base_path,
};
let filename = prefix_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("_invalid_")
.to_string();
(parent.to_path_buf(), filename)
};
if !search_dir.exists() {
return Ok(paths);
}
// Recursive helper function to traverse directories
fn collect_files_recursive(
dir: &std::path::Path,
base_path: &std::path::Path,
file_prefix: &str,
paths: &mut Vec<String>,
) -> Result<(), std::io::Error> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let entry_path = entry.path();
if entry_path.is_file() {
if let Some(filename) = entry_path.file_name().and_then(|n| n.to_str()) {
if file_prefix.is_empty() || filename.starts_with(file_prefix) {
// Convert back to relative path
if let Ok(relative) = entry_path.strip_prefix(base_path) {
if let Some(path_str) = relative.to_str() {
paths.push(path_str.replace('\\', "/"));
}
}
}
}
} else if entry_path.is_dir() {
// Recursively collect from subdirectories
collect_files_recursive(&entry_path, base_path, file_prefix, paths)?;
}
}
Ok(())
}
// Use recursive collection
collect_files_recursive(&search_dir, &self.base_path, &file_prefix, &mut paths)
.map_err(|e| StorageError::IoError {
message: format!("Failed to collect files recursively: {}", e),
})?;
paths.sort();
debug!("Listed {} files with prefix '{}'", paths.len(), prefix);
Ok(paths)
}
.await;
let duration = start.elapsed();
Self::record_metrics(FileOperation::List, duration, result.is_ok());
result
}
async fn metadata(&self, path: &str) -> StorageResult<StorageMetadata> {
let start = std::time::Instant::now();
let full_path = self.get_full_path(path);
let result = async {
let metadata = fs::metadata(&full_path).await.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => StorageError::NotFound {
path: path.to_string(),
},
std::io::ErrorKind::PermissionDenied => StorageError::PermissionDenied {
path: path.to_string(),
},
_ => StorageError::IoError {
message: format!("Failed to get metadata for {}: {}", full_path.display(), e),
},
})?;
let last_modified_dt = match metadata.modified() {
Ok(modified_time) => {
match modified_time.duration_since(UNIX_EPOCH) {
Ok(duration) => DateTime::from_timestamp(duration.as_secs() as i64, 0)
.unwrap_or_else(Utc::now),
Err(_) => {
Utc::now() // If duration calculation fails, use current time
},
}
},
Err(_) => {
Utc::now() // If modified time not available, use current time
},
};
let storage_metadata = StorageMetadata {
path: path.to_string(),
size: metadata.len(),
content_type: Some("application/octet-stream".to_owned()),
last_modified: last_modified_dt,
etag: None, // Could implement checksum-based ETag
tags: HashMap::new(),
};
debug!("Retrieved metadata for {}: {} bytes", path, metadata.len());
Ok(storage_metadata)
}
.await;
let duration = start.elapsed();
Self::record_metrics(FileOperation::Metadata, duration, result.is_ok());
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn create_test_storage() -> (LocalStorage, TempDir) {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
..Default::default()
};
let storage = LocalStorage::new(config).await.unwrap();
(storage, temp_dir)
}
#[tokio::test]
async fn test_store_and_retrieve() {
let (storage, _temp_dir) = create_test_storage().await;
let test_data = b"Hello, World!";
storage.store("test.txt", test_data).await.unwrap();
let retrieved = storage.retrieve("test.txt").await.unwrap();
assert_eq!(retrieved, test_data);
}
#[tokio::test]
async fn test_exists() {
let (storage, _temp_dir) = create_test_storage().await;
assert!(!storage.exists("nonexistent.txt").await.unwrap());
storage.store("test.txt", b"data").await.unwrap();
assert!(storage.exists("test.txt").await.unwrap());
}
#[tokio::test]
async fn test_delete() {
let (storage, _temp_dir) = create_test_storage().await;
storage.store("test.txt", b"data").await.unwrap();
assert!(storage.exists("test.txt").await.unwrap());
let deleted = storage.delete("test.txt").await.unwrap();
assert!(deleted);
assert!(!storage.exists("test.txt").await.unwrap());
// Deleting non-existent file should return false
let deleted = storage.delete("nonexistent.txt").await.unwrap();
assert!(!deleted);
}
#[tokio::test]
async fn test_list() {
let (storage, _temp_dir) = create_test_storage().await;
storage.store("file1.txt", b"data1").await.unwrap();
storage.store("file2.txt", b"data2").await.unwrap();
storage.store("other.dat", b"data3").await.unwrap();
let all_files = storage.list("").await.unwrap();
assert_eq!(all_files.len(), 3);
assert!(all_files.contains(&"file1.txt".to_owned()));
assert!(all_files.contains(&"file2.txt".to_owned()));
assert!(all_files.contains(&"other.dat".to_owned()));
}
#[tokio::test]
async fn test_metadata() {
let (storage, _temp_dir) = create_test_storage().await;
let test_data = b"Hello, World!";
storage.store("test.txt", test_data).await.unwrap();
let metadata = storage.metadata("test.txt").await.unwrap();
assert_eq!(metadata.path, "test.txt");
assert!(metadata.size > 0); // May be larger due to compression
assert_eq!(
metadata.content_type,
Some("application/octet-stream".to_owned())
);
}
#[tokio::test]
async fn test_nested_directories() {
let (storage, _temp_dir) = create_test_storage().await;
storage
.store("nested/dir/file.txt", b"nested data")
.await
.unwrap();
let retrieved = storage.retrieve("nested/dir/file.txt").await.unwrap();
assert_eq!(retrieved, b"nested data");
assert!(storage.exists("nested/dir/file.txt").await.unwrap());
}
#[tokio::test]
async fn test_path_sanitization() {
let (storage, _temp_dir) = create_test_storage().await;
// Directory traversal attempts should be sanitized
storage
.store("../../../etc/passwd", b"malicious")
.await
.unwrap();
// Should be stored safely within base directory
assert!(storage.exists("etc/passwd").await.unwrap());
}
// COMPRESSION EDGE CASE TESTS
#[tokio::test]
async fn test_compression_empty_data() {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
enable_compression: true,
..Default::default()
};
let storage = LocalStorage::new(config).await.unwrap();
// Empty data should compress/decompress without error
storage.store("empty.bin", b"").await.unwrap();
let retrieved = storage.retrieve("empty.bin").await.unwrap();
assert_eq!(retrieved, b"");
}
#[tokio::test]
async fn test_compression_highly_compressible_data() {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
enable_compression: true,
..Default::default()
};
let storage = LocalStorage::new(config).await.unwrap();
// Highly compressible data (repeated bytes)
let data = vec![0u8; 10000];
storage.store("compressible.bin", &data).await.unwrap();
let retrieved = storage.retrieve("compressible.bin").await.unwrap();
assert_eq!(retrieved, data);
}
#[tokio::test]
async fn test_compression_random_data() {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
enable_compression: true,
..Default::default()
};
let storage = LocalStorage::new(config).await.unwrap();
// Random data (not compressible)
let data: Vec<u8> = (0..1000).map(|i| (i * 7 + 13) as u8).collect();
storage.store("random.bin", &data).await.unwrap();
let retrieved = storage.retrieve("random.bin").await.unwrap();
assert_eq!(retrieved, data);
}
#[tokio::test]
async fn test_no_compression_mode() {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
enable_compression: false,
..Default::default()
};
let storage = LocalStorage::new(config).await.unwrap();
let data = b"test data without compression";
storage.store("uncompressed.bin", data).await.unwrap();
let retrieved = storage.retrieve("uncompressed.bin").await.unwrap();
assert_eq!(retrieved, data);
}
#[tokio::test]
async fn test_compression_large_data() {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
enable_compression: true,
..Default::default()
};
let storage = LocalStorage::new(config).await.unwrap();
// Large data (1MB)
let data = vec![42u8; 1024 * 1024];
storage.store("large.bin", &data).await.unwrap();
let retrieved = storage.retrieve("large.bin").await.unwrap();
assert_eq!(retrieved, data);
}
// ERROR HANDLING TESTS
#[tokio::test]
async fn test_retrieve_nonexistent_file() {
let (storage, _temp_dir) = create_test_storage().await;
let result = storage.retrieve("nonexistent.txt").await;
assert!(result.is_err());
match result {
Err(StorageError::NotFound { path }) => {
assert_eq!(path, "nonexistent.txt");
},
_ => panic!("Expected NotFound error"),
}
}
#[tokio::test]
async fn test_metadata_nonexistent_file() {
let (storage, _temp_dir) = create_test_storage().await;
let result = storage.metadata("nonexistent.txt").await;
assert!(result.is_err());
assert!(matches!(result, Err(StorageError::NotFound { .. })));
}
#[tokio::test]
async fn test_atomic_write_with_temp_file() {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
atomic_writes: true,
..Default::default()
};
let storage = LocalStorage::new(config).await.unwrap();
let data = b"atomic write test";
storage.store("atomic.txt", data).await.unwrap();
// Verify no temporary files are left behind
let paths = storage.list("").await.unwrap();
for path in paths {
assert!(!path.contains(".tmp"), "Temporary file found: {}", path);
}
}
#[tokio::test]
async fn test_non_atomic_write() {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
atomic_writes: false,
..Default::default()
};
let storage = LocalStorage::new(config).await.unwrap();
let data = b"non-atomic write test";
storage.store("nonatomic.txt", data).await.unwrap();
let retrieved = storage.retrieve("nonatomic.txt").await.unwrap();
assert_eq!(retrieved, data);
}
#[tokio::test]
async fn test_overwrite_existing_file() {
let (storage, _temp_dir) = create_test_storage().await;
// Store initial data
storage.store("overwrite.txt", b"original").await.unwrap();
// Overwrite with new data
storage.store("overwrite.txt", b"updated").await.unwrap();
let retrieved = storage.retrieve("overwrite.txt").await.unwrap();
assert_eq!(retrieved, b"updated");
}
#[tokio::test]
async fn test_deep_nested_directories() {
let (storage, _temp_dir) = create_test_storage().await;
let deep_path = "a/b/c/d/e/f/g/h/i/j/file.txt";
storage.store(deep_path, b"deeply nested").await.unwrap();
assert!(storage.exists(deep_path).await.unwrap());
let retrieved = storage.retrieve(deep_path).await.unwrap();
assert_eq!(retrieved, b"deeply nested");
}
#[tokio::test]
async fn test_special_characters_in_path() {
let (storage, _temp_dir) = create_test_storage().await;
// Test with spaces and underscores
let path = "test file_with-special.txt";
storage.store(path, b"special chars").await.unwrap();
assert!(storage.exists(path).await.unwrap());
}
#[tokio::test]
async fn test_list_with_prefix_matching() {
let (storage, _temp_dir) = create_test_storage().await;
storage.store("prefix_test1.txt", b"1").await.unwrap();
storage.store("prefix_test2.txt", b"2").await.unwrap();
storage.store("other.txt", b"3").await.unwrap();
let files = storage.list("prefix_").await.unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().all(|f| f.starts_with("prefix_")));
}
#[tokio::test]
async fn test_list_empty_directory() {
let (storage, _temp_dir) = create_test_storage().await;
let files = storage.list("nonexistent_dir/").await.unwrap();
assert!(files.is_empty());
}
#[tokio::test]
async fn test_multiple_operations_sequence() {
let (storage, _temp_dir) = create_test_storage().await;
// Sequence of operations
storage.store("seq1.txt", b"data1").await.unwrap();
assert!(storage.exists("seq1.txt").await.unwrap());
storage.store("seq2.txt", b"data2").await.unwrap();
assert_eq!(storage.list("seq").await.unwrap().len(), 2);
storage.delete("seq1.txt").await.unwrap();
assert!(!storage.exists("seq1.txt").await.unwrap());
assert_eq!(storage.list("seq").await.unwrap().len(), 1);
}
#[tokio::test]
async fn test_metadata_fields() {
let (storage, _temp_dir) = create_test_storage().await;
let data = b"metadata test data";
storage.store("meta.txt", data).await.unwrap();
let metadata = storage.metadata("meta.txt").await.unwrap();
assert_eq!(metadata.path, "meta.txt");
assert!(metadata.size > 0);
assert_eq!(
metadata.content_type,
Some("application/octet-stream".to_owned())
);
assert!(metadata.last_modified <= Utc::now());
}
#[tokio::test]
async fn test_config_validation_absolute_path() {
let config = LocalStorageConfig {
base_path: std::path::PathBuf::from("relative/path"),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
assert!(matches!(result, Err(StorageError::ConfigError { .. })));
}
#[tokio::test]
async fn test_config_validation_buffer_size() {
let temp_dir = TempDir::new().unwrap();
let config = LocalStorageConfig {
base_path: temp_dir.path().to_path_buf(),
buffer_size: 0,
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
assert!(matches!(result, Err(StorageError::ConfigError { .. })));
}
#[tokio::test]
async fn test_binary_data_storage() {
let (storage, _temp_dir) = create_test_storage().await;
// Binary data with all byte values
let data: Vec<u8> = (0..=255).collect();
storage.store("binary.bin", &data).await.unwrap();
let retrieved = storage.retrieve("binary.bin").await.unwrap();
assert_eq!(retrieved, data);
}
#[tokio::test]
async fn test_concurrent_operations() {
let (storage, _temp_dir) = create_test_storage().await;
let storage = std::sync::Arc::new(storage);
// Spawn multiple concurrent operations
let mut handles = vec![];
for i in 0..10 {
let storage_clone = storage.clone();
let handle = tokio::spawn(async move {
let path = format!("concurrent_{}.txt", i);
let data = format!("data_{}", i).into_bytes();
storage_clone.store(&path, &data).await.unwrap();
let retrieved = storage_clone.retrieve(&path).await.unwrap();
assert_eq!(retrieved, data);
});
handles.push(handle);
}
// Wait for all operations to complete
for handle in handles {
handle.await.unwrap();
}
// Verify all files were created
let files = storage.list("concurrent_").await.unwrap();
assert_eq!(files.len(), 10);
}
}