ferro_deployments/
storage.rs1use crate::{DeploymentConfig, Error};
18use bytes::Bytes;
19
20#[async_trait::async_trait]
30pub trait DeploymentStorage: Send + Sync {
31 async fn store(&self, deployment_id: i64, path: &str, bytes: Bytes) -> Result<(), Error>;
33
34 async fn retrieve(&self, deployment_id: i64, path: &str) -> Result<Bytes, Error>;
36
37 async fn remove(&self, deployment_id: i64, path: &str) -> Result<(), Error>;
39
40 async fn list(&self, deployment_id: i64) -> Result<Vec<String>, Error>;
42
43 async fn remove_all(&self, deployment_id: i64) -> Result<(), Error>;
45}
46
47pub struct StorageDeploymentStorage {
52 disk: ferro_storage::Disk,
53}
54
55impl StorageDeploymentStorage {
56 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 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 let dir = format!("deployments/{deployment_id}");
103 self.disk.delete_directory(&dir).await.map_err(Error::from)
104 }
105}
106
107pub 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 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 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 #[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}