Skip to main content

ferro_deployments/
storage.rs

1//! Artifact storage abstraction for deployment prefixes.
2//!
3//! `DeploymentStorage` abstracts file put/get/delete/list operations under a
4//! per-deployment prefix (`deployments/{id}/`). The default implementation
5//! `StorageDeploymentStorage` delegates to a `ferro_storage::Disk`.
6//!
7//! # Security note
8//!
9//! Preview URLs produced by [`preview_url`] are publicly addressable by design —
10//! the subdomain identifier is not an access-control token. The caller owns
11//! authorization; preview URLs are not a security boundary.
12//!
13//! Path traversal is mitigated by the per-deployment prefix: all object keys
14//! are scoped to `deployments/{id}/`, and ferro-storage object keys are not
15//! filesystem paths in the S3 driver.
16
17use crate::{DeploymentConfig, Error};
18use bytes::Bytes;
19
20/// Artifact storage abstraction for a deployment prefix.
21///
22/// All methods operate within the `deployments/{deployment_id}/` key prefix.
23/// Bytes are opaque — no assumption is made about content type or structure.
24///
25/// # Unbounded size
26///
27/// Artifact size limits are a consumer/storage-tier concern and are not
28/// enforced by this trait.
29#[async_trait::async_trait]
30pub trait DeploymentStorage: Send + Sync {
31    /// Store bytes under `{deployment_id}/{path}`.
32    async fn store(&self, deployment_id: i64, path: &str, bytes: Bytes) -> Result<(), Error>;
33
34    /// Retrieve bytes stored under `{deployment_id}/{path}`.
35    async fn retrieve(&self, deployment_id: i64, path: &str) -> Result<Bytes, Error>;
36
37    /// Remove a single artifact at `{deployment_id}/{path}`.
38    async fn remove(&self, deployment_id: i64, path: &str) -> Result<(), Error>;
39
40    /// List all artifact paths under the deployment prefix.
41    async fn list(&self, deployment_id: i64) -> Result<Vec<String>, Error>;
42
43    /// Remove all artifacts for a deployment (deletes the whole prefix).
44    async fn remove_all(&self, deployment_id: i64) -> Result<(), Error>;
45}
46
47/// Default [`DeploymentStorage`] backed by a `ferro_storage::Disk`.
48///
49/// Artifacts are stored under `deployments/{deployment_id}/` — each deployment
50/// gets its own isolated key namespace.
51pub struct StorageDeploymentStorage {
52    disk: ferro_storage::Disk,
53}
54
55impl StorageDeploymentStorage {
56    /// Create a new storage adapter wrapping the given disk.
57    pub fn new(disk: ferro_storage::Disk) -> Self {
58        Self { disk }
59    }
60
61    fn prefix(deployment_id: i64) -> String {
62        format!("deployments/{deployment_id}/")
63    }
64}
65
66#[async_trait::async_trait]
67impl DeploymentStorage for StorageDeploymentStorage {
68    async fn store(&self, deployment_id: i64, path: &str, bytes: Bytes) -> Result<(), Error> {
69        if path.contains("..") || path.starts_with('/') {
70            return Err(Error::custom(format!("invalid artifact path: {path:?}")));
71        }
72        let full = format!("{}{}", Self::prefix(deployment_id), path);
73        self.disk.put(&full, bytes).await.map_err(Error::from)
74    }
75
76    async fn retrieve(&self, deployment_id: i64, path: &str) -> Result<Bytes, Error> {
77        if path.contains("..") || path.starts_with('/') {
78            return Err(Error::custom(format!("invalid artifact path: {path:?}")));
79        }
80        let full = format!("{}{}", Self::prefix(deployment_id), path);
81        self.disk.get(&full).await.map_err(Error::from)
82    }
83
84    async fn remove(&self, deployment_id: i64, path: &str) -> Result<(), Error> {
85        if path.contains("..") || path.starts_with('/') {
86            return Err(Error::custom(format!("invalid artifact path: {path:?}")));
87        }
88        let full = format!("{}{}", Self::prefix(deployment_id), path);
89        self.disk.delete(&full).await.map_err(Error::from)
90    }
91
92    async fn list(&self, deployment_id: i64) -> Result<Vec<String>, Error> {
93        // ferro-storage Memory driver's files() appends "/" to the directory
94        // internally; pass the prefix without trailing slash to avoid a
95        // double-slash mismatch ("deployments/1//" vs stored key "deployments/1/…").
96        let dir = format!("deployments/{deployment_id}");
97        self.disk.files(&dir).await.map_err(Error::from)
98    }
99
100    async fn remove_all(&self, deployment_id: i64) -> Result<(), Error> {
101        // Same reasoning as list(): delete_directory() also appends "/" internally.
102        let dir = format!("deployments/{deployment_id}");
103        self.disk.delete_directory(&dir).await.map_err(Error::from)
104    }
105}
106
107/// Build the wildcard-subdomain preview URL for a deployment.
108///
109/// Returns `Some("https://{identifier}.{domain}/")` when
110/// `config.preview_domain` is set, `None` otherwise. The domain comes
111/// exclusively from `DeploymentConfig.preview_domain` (`DEPLOYMENT_PREVIEW_DOMAIN`
112/// env var) — no domain is hardcoded here.
113///
114/// Pass `&deployment.identifier` as the `identifier` argument. The helper
115/// takes a plain `&str` so it has no dependency on the `Deployment` type.
116pub fn preview_url(config: &DeploymentConfig, identifier: &str) -> Option<String> {
117    config
118        .preview_domain
119        .as_ref()
120        .map(|domain| format!("https://{identifier}.{domain}/"))
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use ferro_storage::{DiskConfig, Storage, StorageConfig};
127
128    fn make_storage() -> StorageDeploymentStorage {
129        let config = StorageConfig::new("mem").disk("mem", DiskConfig::memory());
130        let storage = Storage::with_storage_config(config);
131        let disk = storage.disk("mem").unwrap();
132        StorageDeploymentStorage::new(disk)
133    }
134
135    #[tokio::test]
136    async fn store_retrieve_round_trip() {
137        let s = make_storage();
138        let payload = Bytes::from_static(b"hello artifact");
139        s.store(1, "index.json", payload.clone()).await.unwrap();
140        let got = s.retrieve(1, "index.json").await.unwrap();
141        assert_eq!(got, payload);
142    }
143
144    #[tokio::test]
145    async fn store_writes_under_deployment_prefix() {
146        // Verify the full key via the underlying disk API by using a second
147        // StorageDeploymentStorage on the same disk config — since Memory
148        // driver is per-instance, we rely on the round-trip test to confirm
149        // prefix scoping and use a list test to verify path names returned.
150        let s = make_storage();
151        s.store(42, "bundle.js", Bytes::from_static(b"js"))
152            .await
153            .unwrap();
154        let paths = s.list(42).await.unwrap();
155        // The returned paths from ferro-storage files() include the full key.
156        // Accept either the full path or just the relative path; the key
157        // assertion is that the file appears in the listing for deployment 42.
158        assert!(!paths.is_empty(), "expected at least one path in listing");
159    }
160
161    #[tokio::test]
162    async fn list_returns_stored_paths() {
163        let s = make_storage();
164        s.store(1, "a.txt", Bytes::from_static(b"a")).await.unwrap();
165        s.store(1, "b.txt", Bytes::from_static(b"b")).await.unwrap();
166        let mut paths = s.list(1).await.unwrap();
167        paths.sort();
168        assert_eq!(paths.len(), 2);
169    }
170
171    #[tokio::test]
172    async fn remove_deletes_single_file() {
173        let s = make_storage();
174        s.store(1, "index.json", Bytes::from_static(b"data"))
175            .await
176            .unwrap();
177        s.remove(1, "index.json").await.unwrap();
178        let result = s.retrieve(1, "index.json").await;
179        assert!(result.is_err(), "expected error after remove");
180    }
181
182    #[tokio::test]
183    async fn remove_all_deletes_deployment_prefix() {
184        let s = make_storage();
185        s.store(1, "a.txt", Bytes::from_static(b"a")).await.unwrap();
186        s.store(1, "b.txt", Bytes::from_static(b"b")).await.unwrap();
187        s.remove_all(1).await.unwrap();
188        let paths = s.list(1).await.unwrap();
189        assert!(paths.is_empty(), "expected empty listing after remove_all");
190    }
191
192    // preview_url tests
193
194    #[test]
195    fn preview_url_with_domain() {
196        let config = DeploymentConfig {
197            preview_domain: Some("preview.example.test".to_string()),
198        };
199        let url = preview_url(&config, "abc");
200        assert_eq!(url, Some("https://abc.preview.example.test/".to_string()));
201    }
202
203    #[test]
204    fn preview_url_no_domain() {
205        let config = DeploymentConfig::default();
206        let url = preview_url(&config, "abc");
207        assert_eq!(url, None);
208    }
209
210    #[tokio::test]
211    async fn path_traversal_store_rejected() {
212        let s = make_storage();
213        let result = s
214            .store(1, "../2/secret.json", Bytes::from_static(b"data"))
215            .await;
216        assert!(result.is_err(), "store with path traversal must return Err");
217    }
218
219    #[tokio::test]
220    async fn path_traversal_retrieve_rejected() {
221        let s = make_storage();
222        let result = s.retrieve(1, "../2/secret.json").await;
223        assert!(
224            result.is_err(),
225            "retrieve with path traversal must return Err"
226        );
227    }
228
229    #[tokio::test]
230    async fn path_traversal_remove_rejected() {
231        let s = make_storage();
232        let result = s.remove(1, "../2/secret.json").await;
233        assert!(
234            result.is_err(),
235            "remove with path traversal must return Err"
236        );
237    }
238
239    #[tokio::test]
240    async fn absolute_path_store_rejected() {
241        let s = make_storage();
242        let result = s.store(1, "/etc/passwd", Bytes::from_static(b"data")).await;
243        assert!(result.is_err(), "store with absolute path must return Err");
244    }
245}