use std::{
path::{Path, PathBuf},
time::Duration,
};
use async_trait::async_trait;
use bytes::Bytes;
use tokio::fs;
use url::Url;
use super::{StorageError, StorageService, checked_key};
#[derive(Debug, Clone)]
pub struct DiskService {
name: String,
root: PathBuf,
base_url: Url,
}
impl DiskService {
pub fn new(name: impl Into<String>, root: impl Into<PathBuf>) -> Result<Self, StorageError> {
let base_url = Url::parse("http://disk.local/")
.map_err(|error| StorageError::InvalidUrl(error.to_string()))?;
Ok(Self {
name: name.into(),
root: root.into(),
base_url,
})
}
#[must_use]
pub fn with_base_url(mut self, base_url: Url) -> Self {
self.base_url = base_url;
self
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
fn path_for(&self, key: &str) -> Result<PathBuf, StorageError> {
let key = checked_key(key)?;
let mut path = self.root.clone();
for segment in key.split('/') {
path.push(segment);
}
Ok(path)
}
async fn ensure_parent(&self, path: &Path) -> Result<(), StorageError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|source| StorageError::Io {
path: parent.display().to_string(),
source,
})?;
}
Ok(())
}
}
#[async_trait]
impl StorageService for DiskService {
fn name(&self) -> &str {
&self.name
}
async fn upload(&self, key: &str, data: Bytes) -> Result<(), StorageError> {
let path = self.path_for(key)?;
if fs::try_exists(&path)
.await
.map_err(|source| StorageError::Io {
path: path.display().to_string(),
source,
})?
{
return Err(StorageError::DuplicateKey(key.to_owned()));
}
self.ensure_parent(&path).await?;
fs::write(&path, data)
.await
.map_err(|source| StorageError::Io {
path: path.display().to_string(),
source,
})
}
async fn download(&self, key: &str) -> Result<Bytes, StorageError> {
let path = self.path_for(key)?;
match fs::read(&path).await {
Ok(bytes) => Ok(Bytes::from(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err(StorageError::NotFound(key.to_owned()))
}
Err(source) => Err(StorageError::Io {
path: path.display().to_string(),
source,
}),
}
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
let path = self.path_for(key)?;
match fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(StorageError::Io {
path: path.display().to_string(),
source,
}),
}
}
async fn exists(&self, key: &str) -> Result<bool, StorageError> {
let path = self.path_for(key)?;
fs::try_exists(&path)
.await
.map_err(|source| StorageError::Io {
path: path.display().to_string(),
source,
})
}
async fn url(&self, key: &str, expires_in: Duration) -> Result<Url, StorageError> {
let key = checked_key(key)?;
let mut url = self.base_url.clone();
url.set_path(key);
url.query_pairs_mut()
.append_pair("service", &self.name)
.append_pair("expires_in", &expires_in.as_secs().to_string());
Ok(url)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_root(name: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("rustrails-storage-{name}-{}", uuid::Uuid::now_v7()));
path
}
async fn service(name: &str) -> DiskService {
let root = test_root(name);
DiskService::new(name, root).expect("service should build")
}
#[tokio::test]
async fn test_upload_and_download_round_trip() {
let service = service("round-trip").await;
service
.upload("a.txt", Bytes::from_static(b"hello"))
.await
.expect("upload should succeed");
let bytes = service
.download("a.txt")
.await
.expect("download should succeed");
assert_eq!(bytes, Bytes::from_static(b"hello"));
}
#[tokio::test]
async fn test_upload_rejects_duplicate_keys() {
let service = service("duplicate").await;
service
.upload("a.txt", Bytes::from_static(b"one"))
.await
.expect("upload should succeed");
let error = service
.upload("a.txt", Bytes::from_static(b"two"))
.await
.expect_err("duplicate upload should fail");
assert!(matches!(error, StorageError::DuplicateKey(key) if key == "a.txt"));
}
#[tokio::test]
async fn test_download_missing_key_returns_not_found() {
let service = service("missing").await;
let error = service
.download("missing.txt")
.await
.expect_err("download should fail");
assert!(matches!(error, StorageError::NotFound(key) if key == "missing.txt"));
}
#[tokio::test]
async fn test_delete_removes_existing_file() {
let service = service("delete").await;
service
.upload("a.txt", Bytes::from_static(b"hello"))
.await
.expect("upload should succeed");
service
.delete("a.txt")
.await
.expect("delete should succeed");
assert!(
!service
.exists("a.txt")
.await
.expect("exists should succeed")
);
}
#[tokio::test]
async fn test_delete_missing_file_is_a_noop() {
let service = service("delete-missing").await;
service
.delete("missing.txt")
.await
.expect("delete should succeed");
}
#[tokio::test]
async fn test_exists_returns_true_for_uploaded_file() {
let service = service("exists-true").await;
service
.upload("a.txt", Bytes::from_static(b"hello"))
.await
.expect("upload should succeed");
assert!(
service
.exists("a.txt")
.await
.expect("exists should succeed")
);
}
#[tokio::test]
async fn test_exists_returns_false_for_missing_file() {
let service = service("exists-false").await;
assert!(
!service
.exists("missing.txt")
.await
.expect("exists should succeed")
);
}
#[tokio::test]
async fn test_upload_creates_nested_directories() {
let service = service("nested").await;
service
.upload("avatars/user-1/photo.jpg", Bytes::from_static(b"hello"))
.await
.expect("upload should succeed");
assert!(service.root().join("avatars/user-1/photo.jpg").exists());
}
#[tokio::test]
async fn test_zero_byte_upload_is_supported() {
let service = service("zero-byte").await;
service
.upload("empty.txt", Bytes::new())
.await
.expect("upload should succeed");
let bytes = service
.download("empty.txt")
.await
.expect("download should succeed");
assert!(bytes.is_empty());
}
#[tokio::test]
async fn test_url_contains_service_and_expiry() {
let service = service("url").await;
let url = service
.url("file.txt", Duration::from_secs(120))
.await
.expect("url should build");
assert_eq!(url.path(), "/file.txt");
assert!(
url.query()
.expect("query should exist")
.contains("expires_in=120")
);
assert!(
url.query()
.expect("query should exist")
.contains("service=url")
);
}
#[tokio::test]
async fn test_custom_base_url_is_used() {
let service = DiskService::new("disk", test_root("custom-base"))
.expect("service should build")
.with_base_url(Url::parse("https://cdn.example/storage/").expect("url should parse"));
let url = service
.url("file.txt", Duration::from_secs(60))
.await
.expect("url should build");
assert_eq!(
url.as_str(),
"https://cdn.example/file.txt?service=disk&expires_in=60"
);
}
#[tokio::test]
async fn test_service_name_is_reported() {
let service = service("service-name").await;
assert_eq!(service.name(), "service-name");
}
#[tokio::test]
async fn test_uploads_are_isolated_by_root() {
let service_a = service("isolated-a").await;
let service_b = service("isolated-b").await;
service_a
.upload("same.txt", Bytes::from_static(b"a"))
.await
.expect("upload should succeed");
service_b
.upload("same.txt", Bytes::from_static(b"b"))
.await
.expect("upload should succeed");
assert_eq!(
service_a
.download("same.txt")
.await
.expect("download should succeed"),
Bytes::from_static(b"a")
);
assert_eq!(
service_b
.download("same.txt")
.await
.expect("download should succeed"),
Bytes::from_static(b"b")
);
}
#[tokio::test]
async fn test_delete_keeps_parent_directory_stable() {
let service = service("parent-dir").await;
service
.upload("nested/file.txt", Bytes::from_static(b"hello"))
.await
.expect("upload should succeed");
service
.delete("nested/file.txt")
.await
.expect("delete should succeed");
assert!(service.root().join("nested").exists());
}
#[tokio::test]
async fn test_invalid_empty_key_rejected_for_upload() {
let service = service("invalid-key").await;
let error = service
.upload(" ", Bytes::from_static(b"hello"))
.await
.expect_err("upload should fail");
assert!(matches!(error, StorageError::InvalidUrl(_)));
}
#[tokio::test]
async fn test_download_after_delete_returns_not_found() {
let service = service("download-after-delete").await;
service
.upload("a.txt", Bytes::from_static(b"hello"))
.await
.expect("upload should succeed");
service
.delete("a.txt")
.await
.expect("delete should succeed");
let error = service
.download("a.txt")
.await
.expect_err("download should fail");
assert!(matches!(error, StorageError::NotFound(key) if key == "a.txt"));
}
}