ferro-deployments 0.2.49

Immutable deployment model and atomic promote for the Ferro framework
Documentation
//! Artifact storage abstraction for deployment prefixes.
//!
//! `DeploymentStorage` abstracts file put/get/delete/list operations under a
//! per-deployment prefix (`deployments/{id}/`). The default implementation
//! `StorageDeploymentStorage` delegates to a `ferro_storage::Disk`.
//!
//! # Security note
//!
//! Preview URLs produced by [`preview_url`] are publicly addressable by design —
//! the subdomain identifier is not an access-control token. The caller owns
//! authorization; preview URLs are not a security boundary.
//!
//! Path traversal is mitigated by the per-deployment prefix: all object keys
//! are scoped to `deployments/{id}/`, and ferro-storage object keys are not
//! filesystem paths in the S3 driver.

use crate::{DeploymentConfig, Error};
use bytes::Bytes;

/// Artifact storage abstraction for a deployment prefix.
///
/// All methods operate within the `deployments/{deployment_id}/` key prefix.
/// Bytes are opaque — no assumption is made about content type or structure.
///
/// # Unbounded size
///
/// Artifact size limits are a consumer/storage-tier concern and are not
/// enforced by this trait.
#[async_trait::async_trait]
pub trait DeploymentStorage: Send + Sync {
    /// Store bytes under `{deployment_id}/{path}`.
    async fn store(&self, deployment_id: i64, path: &str, bytes: Bytes) -> Result<(), Error>;

    /// Retrieve bytes stored under `{deployment_id}/{path}`.
    async fn retrieve(&self, deployment_id: i64, path: &str) -> Result<Bytes, Error>;

    /// Remove a single artifact at `{deployment_id}/{path}`.
    async fn remove(&self, deployment_id: i64, path: &str) -> Result<(), Error>;

    /// List all artifact paths under the deployment prefix.
    async fn list(&self, deployment_id: i64) -> Result<Vec<String>, Error>;

    /// Remove all artifacts for a deployment (deletes the whole prefix).
    async fn remove_all(&self, deployment_id: i64) -> Result<(), Error>;
}

/// Default [`DeploymentStorage`] backed by a `ferro_storage::Disk`.
///
/// Artifacts are stored under `deployments/{deployment_id}/` — each deployment
/// gets its own isolated key namespace.
pub struct StorageDeploymentStorage {
    disk: ferro_storage::Disk,
}

impl StorageDeploymentStorage {
    /// Create a new storage adapter wrapping the given disk.
    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> {
        // ferro-storage Memory driver's files() appends "/" to the directory
        // internally; pass the prefix without trailing slash to avoid a
        // double-slash mismatch ("deployments/1//" vs stored key "deployments/1/…").
        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> {
        // Same reasoning as list(): delete_directory() also appends "/" internally.
        let dir = format!("deployments/{deployment_id}");
        self.disk.delete_directory(&dir).await.map_err(Error::from)
    }
}

/// Build the wildcard-subdomain preview URL for a deployment.
///
/// Returns `Some("https://{identifier}.{domain}/")` when
/// `config.preview_domain` is set, `None` otherwise. The domain comes
/// exclusively from `DeploymentConfig.preview_domain` (`DEPLOYMENT_PREVIEW_DOMAIN`
/// env var) — no domain is hardcoded here.
///
/// Pass `&deployment.identifier` as the `identifier` argument. The helper
/// takes a plain `&str` so it has no dependency on the `Deployment` type.
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() {
        // Verify the full key via the underlying disk API by using a second
        // StorageDeploymentStorage on the same disk config — since Memory
        // driver is per-instance, we rely on the round-trip test to confirm
        // prefix scoping and use a list test to verify path names returned.
        let s = make_storage();
        s.store(42, "bundle.js", Bytes::from_static(b"js"))
            .await
            .unwrap();
        let paths = s.list(42).await.unwrap();
        // The returned paths from ferro-storage files() include the full key.
        // Accept either the full path or just the relative path; the key
        // assertion is that the file appears in the listing for deployment 42.
        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");
    }

    // preview_url tests

    #[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");
    }
}