use std::collections::{BTreeSet, HashMap};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use super::utils::{
build_url, normalize_relative_path, normalize_storage_name, path_to_storage_name,
};
pub trait Storage: Send + Sync {
fn save(&self, name: &str, content: &[u8]) -> Result<String, StorageError>;
fn open(&self, name: &str) -> Result<Vec<u8>, StorageError>;
fn delete(&self, name: &str) -> Result<(), StorageError>;
fn exists(&self, name: &str) -> bool;
fn listdir(&self, path: &str) -> Result<(Vec<String>, Vec<String>), StorageError>;
fn size(&self, name: &str) -> Result<u64, StorageError>;
fn url(&self, name: &str) -> String;
fn path(&self, name: &str) -> PathBuf;
fn get_available_name(&self, name: &str) -> String;
}
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("file not found: {0}")]
NotFound(String),
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("suspicious filename: {0}")]
SuspiciousFilename(String),
}
pub struct FileSystemStorage {
pub location: PathBuf,
pub base_url: String,
}
pub struct MemoryStorage {
files: RwLock<HashMap<String, Vec<u8>>>,
base_url: String,
}
impl FileSystemStorage {
fn resolve_name(&self, name: &str) -> Result<PathBuf, StorageError> {
Ok(self.location.join(normalize_relative_path(name)?))
}
}
impl Storage for FileSystemStorage {
fn save(&self, name: &str, content: &[u8]) -> Result<String, StorageError> {
let available_name = self.get_available_name(name);
let full_path = self.resolve_name(&available_name)?;
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&full_path, content)?;
Ok(available_name)
}
fn open(&self, name: &str) -> Result<Vec<u8>, StorageError> {
let full_path = self.resolve_name(name)?;
fs::read(&full_path).map_err(|error| map_io_error(name, error))
}
fn delete(&self, name: &str) -> Result<(), StorageError> {
let full_path = self.resolve_name(name)?;
if !full_path.exists() {
return Err(StorageError::NotFound(name.to_string()));
}
if full_path.is_dir() {
fs::remove_dir_all(full_path)?;
} else {
fs::remove_file(full_path)?;
}
Ok(())
}
fn exists(&self, name: &str) -> bool {
self.resolve_name(name).is_ok_and(|path| path.exists())
}
fn listdir(&self, path: &str) -> Result<(Vec<String>, Vec<String>), StorageError> {
let target = if path.is_empty() {
self.location.clone()
} else {
self.resolve_name(path)?
};
if !target.exists() {
return Err(StorageError::NotFound(path.to_string()));
}
if !target.is_dir() {
return Err(StorageError::SuspiciousFilename(path.to_string()));
}
let mut directories = Vec::new();
let mut files = Vec::new();
for entry in fs::read_dir(target)? {
let entry = entry?;
let entry_name = entry.file_name().to_string_lossy().into_owned();
if entry.file_type()?.is_dir() {
directories.push(entry_name);
} else {
files.push(entry_name);
}
}
directories.sort();
files.sort();
Ok((directories, files))
}
fn size(&self, name: &str) -> Result<u64, StorageError> {
let full_path = self.resolve_name(name)?;
fs::metadata(full_path)
.map(|metadata| metadata.len())
.map_err(|error| map_io_error(name, error))
}
fn url(&self, name: &str) -> String {
build_url(&self.base_url, name)
}
fn path(&self, name: &str) -> PathBuf {
self.location.join(name)
}
fn get_available_name(&self, name: &str) -> String {
available_name(name, |candidate| {
self.resolve_name(candidate).is_ok_and(|path| path.exists())
})
}
}
impl Default for MemoryStorage {
fn default() -> Self {
Self::new("/memory/")
}
}
impl MemoryStorage {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
Self {
files: RwLock::new(HashMap::new()),
base_url: base_url.into(),
}
}
}
impl Storage for MemoryStorage {
fn save(&self, name: &str, content: &[u8]) -> Result<String, StorageError> {
let available_name = self.get_available_name(name);
let mut files = self
.files
.write()
.expect("memory storage write lock poisoned");
files.insert(available_name.clone(), content.to_vec());
Ok(available_name)
}
fn open(&self, name: &str) -> Result<Vec<u8>, StorageError> {
let normalized = normalize_storage_name(name)?;
let files = self
.files
.read()
.expect("memory storage read lock poisoned");
files
.get(&normalized)
.cloned()
.ok_or(StorageError::NotFound(normalized))
}
fn delete(&self, name: &str) -> Result<(), StorageError> {
let normalized = normalize_storage_name(name)?;
let mut files = self
.files
.write()
.expect("memory storage write lock poisoned");
if files.remove(&normalized).is_none() {
return Err(StorageError::NotFound(normalized));
}
Ok(())
}
fn exists(&self, name: &str) -> bool {
let Ok(normalized) = normalize_storage_name(name) else {
return false;
};
let files = self
.files
.read()
.expect("memory storage read lock poisoned");
files.contains_key(&normalized)
}
fn listdir(&self, path: &str) -> Result<(Vec<String>, Vec<String>), StorageError> {
let prefix = if path.is_empty() {
None
} else {
Some(normalize_storage_name(path)?)
};
let files = self
.files
.read()
.expect("memory storage read lock poisoned");
let mut directories = BTreeSet::new();
let mut leaf_files = BTreeSet::new();
let mut matched = prefix.is_none();
for key in files.keys() {
let remainder = match &prefix {
Some(prefix) => {
if key == prefix {
return Err(StorageError::SuspiciousFilename(path.to_string()));
}
let Some(remainder) = key.strip_prefix(prefix) else {
continue;
};
let Some(remainder) = remainder.strip_prefix('/') else {
continue;
};
matched = true;
remainder
}
None => key.as_str(),
};
if let Some((directory, _)) = remainder.split_once('/') {
directories.insert(directory.to_string());
continue;
}
if !remainder.is_empty() {
leaf_files.insert(remainder.to_string());
matched = true;
}
}
if !matched {
return Err(StorageError::NotFound(path.to_string()));
}
Ok((
directories.into_iter().collect(),
leaf_files.into_iter().collect(),
))
}
fn size(&self, name: &str) -> Result<u64, StorageError> {
let normalized = normalize_storage_name(name)?;
let files = self
.files
.read()
.expect("memory storage read lock poisoned");
files
.get(&normalized)
.map(|content| content.len() as u64)
.ok_or(StorageError::NotFound(normalized))
}
fn url(&self, name: &str) -> String {
build_url(&self.base_url, name)
}
fn path(&self, name: &str) -> PathBuf {
match normalize_storage_name(name) {
Ok(normalized) => PathBuf::from(normalized),
Err(_) => PathBuf::from(name),
}
}
fn get_available_name(&self, name: &str) -> String {
let files = self
.files
.read()
.expect("memory storage read lock poisoned");
available_name(name, |candidate| files.contains_key(candidate))
}
}
fn available_name(name: &str, exists: impl Fn(&str) -> bool) -> String {
let Ok(normalized) = normalize_relative_path(name) else {
return name.to_string();
};
let parent = normalized
.parent()
.filter(|path| !path.as_os_str().is_empty());
let Some(file_name) = normalized.file_name().and_then(|value| value.to_str()) else {
return name.to_string();
};
let file_path = Path::new(file_name);
let extension = file_path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
let Some(stem) = file_path.file_stem().and_then(|value| value.to_str()) else {
return name.to_string();
};
let extension_suffix = if extension.is_empty() {
String::new()
} else {
format!(".{extension}")
};
for index in 0.. {
let candidate_file_name = if index == 0 {
file_name.to_string()
} else {
format!("{stem}_{index}{extension_suffix}")
};
let candidate_path = match parent {
Some(parent) => parent.join(&candidate_file_name),
None => PathBuf::from(&candidate_file_name),
};
let candidate_name = path_to_storage_name(&candidate_path);
if !exists(&candidate_name) {
return candidate_name;
}
}
name.to_string()
}
fn map_io_error(name: &str, error: io::Error) -> StorageError {
if error.kind() == io::ErrorKind::NotFound {
StorageError::NotFound(name.to_string())
} else {
StorageError::Io(error)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use super::{FileSystemStorage, MemoryStorage, Storage, StorageError};
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
let counter = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"rjango-storage-test-{}-{nanos}-{counter}",
process::id()
));
fs::create_dir_all(&path).expect("create test directory");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn memory_storage_save_open_delete_and_exists_round_trip() {
let storage = MemoryStorage::new("/media/");
let saved_name = storage.save("hello.txt", b"hello").expect("save file");
assert_eq!(saved_name, "hello.txt");
assert!(storage.exists("hello.txt"));
assert_eq!(storage.open("hello.txt").expect("open file"), b"hello");
storage.delete("hello.txt").expect("delete file");
assert!(!storage.exists("hello.txt"));
}
#[test]
fn memory_storage_lists_nested_directories_and_files() {
let storage = MemoryStorage::new("/media/");
storage
.save("docs/readme.txt", b"docs")
.expect("save nested file");
storage.save("photo.jpg", b"jpg").expect("save root file");
let (directories, files) = storage.listdir("").expect("list root");
assert_eq!(directories, vec!["docs"]);
assert_eq!(files, vec!["photo.jpg"]);
let (directories, files) = storage.listdir("docs").expect("list docs");
assert!(directories.is_empty());
assert_eq!(files, vec!["readme.txt"]);
}
#[test]
fn memory_storage_reports_size_url_and_path() {
let storage = MemoryStorage::new("/media/");
storage.save("avatar.png", b"1234").expect("save avatar");
assert_eq!(storage.size("avatar.png").expect("size"), 4);
assert_eq!(storage.url("avatar.png"), "/media/avatar.png");
assert_eq!(storage.path("avatar.png"), PathBuf::from("avatar.png"));
}
#[test]
fn memory_storage_gets_available_name_when_collision_exists() {
let storage = MemoryStorage::new("/media/");
storage.save("dup.txt", b"first").expect("save first");
assert_eq!(storage.get_available_name("dup.txt"), "dup_1.txt");
}
#[test]
fn filesystem_storage_performs_basic_operations() {
let dir = TestDir::new();
let storage = FileSystemStorage {
location: dir.path().to_path_buf(),
base_url: "/media/".to_string(),
};
let saved_name = storage
.save("reports/summary.txt", b"ok")
.expect("save file");
assert_eq!(saved_name, "reports/summary.txt");
assert!(storage.exists("reports/summary.txt"));
assert_eq!(
storage.open("reports/summary.txt").expect("open file"),
b"ok"
);
assert_eq!(storage.size("reports/summary.txt").expect("size"), 2);
assert_eq!(
storage.url("reports/summary.txt"),
"/media/reports/summary.txt"
);
assert_eq!(
storage.path("reports/summary.txt"),
dir.path().join("reports/summary.txt")
);
}
#[test]
fn filesystem_storage_lists_directories_and_files() {
let dir = TestDir::new();
let storage = FileSystemStorage {
location: dir.path().to_path_buf(),
base_url: "/media/".to_string(),
};
storage.save("alpha.txt", b"a").expect("save alpha");
storage.save("nested/beta.txt", b"b").expect("save beta");
let (directories, files) = storage.listdir("").expect("list root");
assert_eq!(directories, vec!["nested"]);
assert_eq!(files, vec!["alpha.txt"]);
let (directories, files) = storage.listdir("nested").expect("list nested");
assert!(directories.is_empty());
assert_eq!(files, vec!["beta.txt"]);
}
#[test]
fn filesystem_storage_delete_removes_saved_file() {
let dir = TestDir::new();
let storage = FileSystemStorage {
location: dir.path().to_path_buf(),
base_url: "/media/".to_string(),
};
storage.save("delete-me.txt", b"bye").expect("save file");
storage.delete("delete-me.txt").expect("delete file");
assert!(!storage.exists("delete-me.txt"));
}
#[test]
fn filesystem_storage_rejects_suspicious_names() {
let dir = TestDir::new();
let storage = FileSystemStorage {
location: dir.path().to_path_buf(),
base_url: "/media/".to_string(),
};
let error = storage
.save("../escape.txt", b"oops")
.expect_err("expected rejection");
assert!(matches!(error, StorageError::SuspiciousFilename(name) if name == "../escape.txt"));
}
}