use dashmap::DashMap;
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("File not found: {0}")]
NotFound(String),
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Storage error: {0}")]
Other(String),
}
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) -> Result<bool, StorageError>;
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;
}
pub struct FileSystemStorage {
pub location: PathBuf,
pub base_url: String,
}
impl FileSystemStorage {
fn resolve_name(&self, name: &str) -> Result<PathBuf, StorageError> {
Ok(self.location.join(normalize_relative_path(name)?))
}
fn available_name(&self, name: &str) -> Result<String, StorageError> {
available_name(name, |candidate| {
self.resolve_name(candidate).is_ok_and(|path| path.exists())
})
}
}
impl Storage for FileSystemStorage {
fn save(&self, name: &str, content: &[u8]) -> Result<String, StorageError> {
let available_name = self.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) -> Result<bool, StorageError> {
Ok(self.resolve_name(name)?.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::Other(format!(
"Path is not a directory: {}",
target.display()
)));
}
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)
}
}
pub struct InMemoryStorage {
files: DashMap<String, Vec<u8>>,
base_url: String,
}
impl InMemoryStorage {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
Self {
files: DashMap::new(),
base_url: base_url.into(),
}
}
fn available_name(&self, name: &str) -> Result<String, StorageError> {
available_name(name, |candidate| self.files.contains_key(candidate))
}
}
impl Storage for InMemoryStorage {
fn save(&self, name: &str, content: &[u8]) -> Result<String, StorageError> {
let available_name = self.available_name(name)?;
self.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)?;
self.files
.get(&normalized)
.map(|entry| entry.value().clone())
.ok_or(StorageError::NotFound(normalized))
}
fn delete(&self, name: &str) -> Result<(), StorageError> {
let normalized = normalize_storage_name(name)?;
if self.files.remove(&normalized).is_none() {
return Err(StorageError::NotFound(normalized));
}
Ok(())
}
fn exists(&self, name: &str) -> Result<bool, StorageError> {
Ok(self.files.contains_key(&normalize_storage_name(name)?))
}
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 mut directories = BTreeSet::new();
let mut files = BTreeSet::new();
let mut matched = prefix.is_none();
for entry in &self.files {
let key = entry.key();
let remainder = match &prefix {
Some(prefix) => {
if key == prefix {
return Err(StorageError::Other(format!(
"Path is not a directory: {prefix}"
)));
}
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 remainder.is_empty() {
continue;
}
if let Some((directory, _rest)) = remainder.split_once('/') {
directories.insert(directory.to_string());
} else {
files.insert(remainder.to_string());
matched = true;
}
}
if !matched {
return Err(StorageError::NotFound(path.to_string()));
}
Ok((
directories.into_iter().collect(),
files.into_iter().collect(),
))
}
fn size(&self, name: &str) -> Result<u64, StorageError> {
let normalized = normalize_storage_name(name)?;
self.files
.get(&normalized)
.map(|entry| entry.value().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 {
PathBuf::from(name)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UploadedFile {
pub name: String,
pub content_type: String,
pub size: u64,
pub data: Vec<u8>,
}
pub struct MemoryFileUploadHandler;
impl MemoryFileUploadHandler {
#[must_use]
pub fn receive(name: &str, content_type: &str, data: Vec<u8>) -> UploadedFile {
UploadedFile {
name: name.to_string(),
content_type: content_type.to_string(),
size: data.len() as u64,
data,
}
}
}
pub struct TemporaryFileUploadHandler {
pub temp_dir: PathBuf,
}
impl TemporaryFileUploadHandler {
pub fn receive(
&self,
name: &str,
content_type: &str,
data: &[u8],
) -> Result<UploadedFile, StorageError> {
let storage = FileSystemStorage {
location: self.temp_dir.clone(),
base_url: String::new(),
};
let saved_name = storage.save(name, data)?;
Ok(UploadedFile {
name: saved_name,
content_type: content_type.to_string(),
size: data.len() as u64,
data: data.to_vec(),
})
}
}
fn normalize_relative_path(name: &str) -> Result<PathBuf, StorageError> {
let candidate = Path::new(name);
if candidate.as_os_str().is_empty() {
return Err(StorageError::Other(
"Storage path cannot be empty".to_string(),
));
}
let mut normalized = PathBuf::new();
for component in candidate.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(StorageError::Other(format!("Invalid storage path: {name}")));
}
}
}
if normalized.as_os_str().is_empty() {
return Err(StorageError::Other(format!("Invalid storage path: {name}")));
}
Ok(normalized)
}
fn normalize_storage_name(name: &str) -> Result<String, StorageError> {
let normalized = normalize_relative_path(name)?;
Ok(path_to_storage_name(&normalized))
}
fn available_name(name: &str, exists: impl Fn(&str) -> bool) -> Result<String, StorageError> {
let normalized = normalize_relative_path(name)?;
let parent = normalized
.parent()
.filter(|path| !path.as_os_str().is_empty());
let file_name = normalized
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| StorageError::Other(format!("Invalid storage path: {name}")))?;
let file_path = Path::new(file_name);
let extension = file_path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
let stem = file_path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| StorageError::Other(format!("Invalid storage path: {name}")))?;
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 Ok(candidate_name);
}
}
Err(StorageError::Other(format!(
"Unable to find available name for: {name}"
)))
}
fn path_to_storage_name(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
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)
}
}
fn build_url(base_url: &str, name: &str) -> String {
let trimmed_name = name.trim_start_matches('/').replace('\\', "/");
if base_url.is_empty() {
return trimmed_name;
}
let base = if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{base_url}/")
};
format!("{base}{trimmed_name}")
}
#[cfg(test)]
mod tests {
use super::{
FileSystemStorage, InMemoryStorage, MemoryFileUploadHandler, Storage,
TemporaryFileUploadHandler,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
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("system time before unix epoch")
.as_nanos();
let counter = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"rjango-core-files-{}-{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 _ignored = fs::remove_dir_all(&self.path);
}
}
fn filesystem_storage(root: &Path) -> FileSystemStorage {
FileSystemStorage {
location: root.to_path_buf(),
base_url: "/media/".to_string(),
}
}
#[test]
fn filesystem_save_and_open_round_trip() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
let saved_name = storage
.save("docs/report.txt", b"hello world")
.expect("save file");
assert_eq!(saved_name, "docs/report.txt");
assert_eq!(
storage.open(&saved_name).expect("open file"),
b"hello world"
);
}
#[test]
fn filesystem_save_creates_parent_directories() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
let saved_name = storage
.save("nested/path/report.txt", b"content")
.expect("save file");
assert!(dir.path().join(saved_name).exists());
}
#[test]
fn filesystem_save_avoids_name_collisions() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
let first = storage.save("report.txt", b"first").expect("save first");
let second = storage.save("report.txt", b"second").expect("save second");
assert_eq!(first, "report.txt");
assert_eq!(second, "report_1.txt");
assert_eq!(storage.open(&first).expect("open first"), b"first");
assert_eq!(storage.open(&second).expect("open second"), b"second");
}
#[test]
fn filesystem_delete_removes_file() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
let saved_name = storage.save("delete-me.txt", b"gone").expect("save file");
storage.delete(&saved_name).expect("delete file");
assert!(!storage.exists(&saved_name).expect("check exists"));
}
#[test]
fn filesystem_exists_reports_state() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
assert!(!storage.exists("missing.txt").expect("missing exists"));
storage.save("present.txt", b"here").expect("save file");
assert!(storage.exists("present.txt").expect("present exists"));
}
#[test]
fn filesystem_listdir_splits_directories_and_files() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
storage
.save("folder/child.txt", b"child")
.expect("save child");
storage.save("root.txt", b"root").expect("save root");
let (directories, files) = storage.listdir("").expect("list directory");
assert_eq!(directories, vec!["folder"]);
assert_eq!(files, vec!["root.txt"]);
}
#[test]
fn filesystem_size_reports_byte_length() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
let saved_name = storage.save("sized.txt", b"123456").expect("save file");
assert_eq!(storage.size(&saved_name).expect("get size"), 6);
}
#[test]
fn filesystem_url_joins_base_url_and_name() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
assert_eq!(storage.url("avatars/user.png"), "/media/avatars/user.png");
}
#[test]
fn filesystem_path_joins_location_and_name() {
let dir = TestDir::new();
let storage = filesystem_storage(dir.path());
assert_eq!(
storage.path("avatars/user.png"),
dir.path().join("avatars/user.png")
);
}
#[test]
fn in_memory_storage_round_trips_saved_bytes() {
let storage = InMemoryStorage::new("/memory/");
let saved_name = storage.save("alpha.txt", b"abc").expect("save bytes");
assert_eq!(saved_name, "alpha.txt");
assert_eq!(storage.open("alpha.txt").expect("open bytes"), b"abc");
}
#[test]
fn in_memory_storage_delete_and_exists_work_together() {
let storage = InMemoryStorage::new("/memory/");
storage.save("alpha.txt", b"abc").expect("save bytes");
assert!(storage.exists("alpha.txt").expect("exists before delete"));
storage.delete("alpha.txt").expect("delete bytes");
assert!(!storage.exists("alpha.txt").expect("exists after delete"));
}
#[test]
fn in_memory_storage_lists_directories_and_sizes_files() {
let storage = InMemoryStorage::new("/memory/");
storage.save("docs/a.txt", b"a").expect("save a");
storage.save("docs/b.txt", b"bb").expect("save b");
storage.save("root.txt", b"ccc").expect("save root");
let (directories, files) = storage.listdir("").expect("list root");
assert_eq!(directories, vec!["docs"]);
assert_eq!(files, vec!["root.txt"]);
assert_eq!(storage.size("docs/b.txt").expect("size"), 2);
}
#[test]
fn memory_upload_handler_builds_uploaded_file_metadata() {
let uploaded =
MemoryFileUploadHandler::receive("avatar.png", "image/png", vec![137, 80, 78, 71]);
assert_eq!(uploaded.name, "avatar.png");
assert_eq!(uploaded.content_type, "image/png");
assert_eq!(uploaded.size, 4);
assert_eq!(uploaded.data, vec![137, 80, 78, 71]);
}
#[test]
fn temporary_upload_handler_writes_temp_file_and_returns_metadata() {
let dir = TestDir::new();
let handler = TemporaryFileUploadHandler {
temp_dir: dir.path().to_path_buf(),
};
let uploaded = handler
.receive("upload.bin", "application/octet-stream", &[1, 2, 3, 4])
.expect("receive temp upload");
assert_eq!(uploaded.name, "upload.bin");
assert_eq!(uploaded.content_type, "application/octet-stream");
assert_eq!(uploaded.size, 4);
assert_eq!(uploaded.data, vec![1, 2, 3, 4]);
let entries = fs::read_dir(dir.path())
.expect("read temp dir")
.collect::<Result<Vec<_>, _>>()
.expect("collect entries");
assert_eq!(entries.len(), 1);
let saved_path = entries[0].path();
assert_eq!(
fs::read(saved_path).expect("read saved file"),
vec![1, 2, 3, 4]
);
}
}
pub mod base;
pub mod locks;
pub mod storage;
pub mod temp;
pub mod uploadedfile;
pub mod uploadhandler;
pub mod utils;