use crate::{DeploymentConfig, Error};
use bytes::Bytes;
#[async_trait::async_trait]
pub trait DeploymentStorage: Send + Sync {
async fn store(&self, deployment_id: i64, path: &str, bytes: Bytes) -> Result<(), Error>;
async fn retrieve(&self, deployment_id: i64, path: &str) -> Result<Bytes, Error>;
async fn remove(&self, deployment_id: i64, path: &str) -> Result<(), Error>;
async fn list(&self, deployment_id: i64) -> Result<Vec<String>, Error>;
async fn remove_all(&self, deployment_id: i64) -> Result<(), Error>;
}
pub struct StorageDeploymentStorage {
disk: ferro_storage::Disk,
}
impl StorageDeploymentStorage {
pub fn new(disk: ferro_storage::Disk) -> Self {
Self { disk }
}
fn prefix(deployment_id: i64) -> String {
format!("deployments/{deployment_id}/")
}
}
#[async_trait::async_trait]
impl DeploymentStorage for StorageDeploymentStorage {
async fn store(&self, deployment_id: i64, path: &str, bytes: Bytes) -> Result<(), Error> {
if path.contains("..") || path.starts_with('/') {
return Err(Error::custom(format!("invalid artifact path: {path:?}")));
}
let full = format!("{}{}", Self::prefix(deployment_id), path);
self.disk.put(&full, bytes).await.map_err(Error::from)
}
async fn retrieve(&self, deployment_id: i64, path: &str) -> Result<Bytes, Error> {
if path.contains("..") || path.starts_with('/') {
return Err(Error::custom(format!("invalid artifact path: {path:?}")));
}
let full = format!("{}{}", Self::prefix(deployment_id), path);
self.disk.get(&full).await.map_err(Error::from)
}
async fn remove(&self, deployment_id: i64, path: &str) -> Result<(), Error> {
if path.contains("..") || path.starts_with('/') {
return Err(Error::custom(format!("invalid artifact path: {path:?}")));
}
let full = format!("{}{}", Self::prefix(deployment_id), path);
self.disk.delete(&full).await.map_err(Error::from)
}
async fn list(&self, deployment_id: i64) -> Result<Vec<String>, Error> {
let dir = format!("deployments/{deployment_id}");
self.disk.files(&dir).await.map_err(Error::from)
}
async fn remove_all(&self, deployment_id: i64) -> Result<(), Error> {
let dir = format!("deployments/{deployment_id}");
self.disk.delete_directory(&dir).await.map_err(Error::from)
}
}
pub fn preview_url(config: &DeploymentConfig, identifier: &str) -> Option<String> {
config
.preview_domain
.as_ref()
.map(|domain| format!("https://{identifier}.{domain}/"))
}
#[cfg(test)]
mod tests {
use super::*;
use ferro_storage::{DiskConfig, Storage, StorageConfig};
fn make_storage() -> StorageDeploymentStorage {
let config = StorageConfig::new("mem").disk("mem", DiskConfig::memory());
let storage = Storage::with_storage_config(config);
let disk = storage.disk("mem").unwrap();
StorageDeploymentStorage::new(disk)
}
#[tokio::test]
async fn store_retrieve_round_trip() {
let s = make_storage();
let payload = Bytes::from_static(b"hello artifact");
s.store(1, "index.json", payload.clone()).await.unwrap();
let got = s.retrieve(1, "index.json").await.unwrap();
assert_eq!(got, payload);
}
#[tokio::test]
async fn store_writes_under_deployment_prefix() {
let s = make_storage();
s.store(42, "bundle.js", Bytes::from_static(b"js"))
.await
.unwrap();
let paths = s.list(42).await.unwrap();
assert!(!paths.is_empty(), "expected at least one path in listing");
}
#[tokio::test]
async fn list_returns_stored_paths() {
let s = make_storage();
s.store(1, "a.txt", Bytes::from_static(b"a")).await.unwrap();
s.store(1, "b.txt", Bytes::from_static(b"b")).await.unwrap();
let mut paths = s.list(1).await.unwrap();
paths.sort();
assert_eq!(paths.len(), 2);
}
#[tokio::test]
async fn remove_deletes_single_file() {
let s = make_storage();
s.store(1, "index.json", Bytes::from_static(b"data"))
.await
.unwrap();
s.remove(1, "index.json").await.unwrap();
let result = s.retrieve(1, "index.json").await;
assert!(result.is_err(), "expected error after remove");
}
#[tokio::test]
async fn remove_all_deletes_deployment_prefix() {
let s = make_storage();
s.store(1, "a.txt", Bytes::from_static(b"a")).await.unwrap();
s.store(1, "b.txt", Bytes::from_static(b"b")).await.unwrap();
s.remove_all(1).await.unwrap();
let paths = s.list(1).await.unwrap();
assert!(paths.is_empty(), "expected empty listing after remove_all");
}
#[test]
fn preview_url_with_domain() {
let config = DeploymentConfig {
preview_domain: Some("preview.example.test".to_string()),
};
let url = preview_url(&config, "abc");
assert_eq!(url, Some("https://abc.preview.example.test/".to_string()));
}
#[test]
fn preview_url_no_domain() {
let config = DeploymentConfig::default();
let url = preview_url(&config, "abc");
assert_eq!(url, None);
}
#[tokio::test]
async fn path_traversal_store_rejected() {
let s = make_storage();
let result = s
.store(1, "../2/secret.json", Bytes::from_static(b"data"))
.await;
assert!(result.is_err(), "store with path traversal must return Err");
}
#[tokio::test]
async fn path_traversal_retrieve_rejected() {
let s = make_storage();
let result = s.retrieve(1, "../2/secret.json").await;
assert!(
result.is_err(),
"retrieve with path traversal must return Err"
);
}
#[tokio::test]
async fn path_traversal_remove_rejected() {
let s = make_storage();
let result = s.remove(1, "../2/secret.json").await;
assert!(
result.is_err(),
"remove with path traversal must return Err"
);
}
#[tokio::test]
async fn absolute_path_store_rejected() {
let s = make_storage();
let result = s.store(1, "/etc/passwd", Bytes::from_static(b"data")).await;
assert!(result.is_err(), "store with absolute path must return Err");
}
}