Skip to main content

rustrails_storage/service/
disk.rs

1//! Filesystem-backed storage service.
2
3use std::{
4    path::{Path, PathBuf},
5    time::Duration,
6};
7
8use async_trait::async_trait;
9use bytes::Bytes;
10use tokio::fs;
11use url::Url;
12
13use super::{StorageError, StorageService, checked_key};
14
15/// Stores objects on the local filesystem.
16#[derive(Debug, Clone)]
17pub struct DiskService {
18    name: String,
19    root: PathBuf,
20    base_url: Url,
21}
22
23impl DiskService {
24    /// Creates a disk service rooted at the given path.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error when the default base URL cannot be constructed.
29    pub fn new(name: impl Into<String>, root: impl Into<PathBuf>) -> Result<Self, StorageError> {
30        let base_url = Url::parse("http://disk.local/")
31            .map_err(|error| StorageError::InvalidUrl(error.to_string()))?;
32        Ok(Self {
33            name: name.into(),
34            root: root.into(),
35            base_url,
36        })
37    }
38
39    /// Returns a copy with a custom public base URL.
40    #[must_use]
41    pub fn with_base_url(mut self, base_url: Url) -> Self {
42        self.base_url = base_url;
43        self
44    }
45
46    /// Returns the configured root path.
47    #[must_use]
48    pub fn root(&self) -> &Path {
49        &self.root
50    }
51
52    fn path_for(&self, key: &str) -> Result<PathBuf, StorageError> {
53        let key = checked_key(key)?;
54        let mut path = self.root.clone();
55        for segment in key.split('/') {
56            path.push(segment);
57        }
58        Ok(path)
59    }
60
61    async fn ensure_parent(&self, path: &Path) -> Result<(), StorageError> {
62        if let Some(parent) = path.parent() {
63            fs::create_dir_all(parent)
64                .await
65                .map_err(|source| StorageError::Io {
66                    path: parent.display().to_string(),
67                    source,
68                })?;
69        }
70        Ok(())
71    }
72}
73
74#[async_trait]
75impl StorageService for DiskService {
76    fn name(&self) -> &str {
77        &self.name
78    }
79
80    async fn upload(&self, key: &str, data: Bytes) -> Result<(), StorageError> {
81        let path = self.path_for(key)?;
82        if fs::try_exists(&path)
83            .await
84            .map_err(|source| StorageError::Io {
85                path: path.display().to_string(),
86                source,
87            })?
88        {
89            return Err(StorageError::DuplicateKey(key.to_owned()));
90        }
91        self.ensure_parent(&path).await?;
92        fs::write(&path, data)
93            .await
94            .map_err(|source| StorageError::Io {
95                path: path.display().to_string(),
96                source,
97            })
98    }
99
100    async fn download(&self, key: &str) -> Result<Bytes, StorageError> {
101        let path = self.path_for(key)?;
102        match fs::read(&path).await {
103            Ok(bytes) => Ok(Bytes::from(bytes)),
104            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
105                Err(StorageError::NotFound(key.to_owned()))
106            }
107            Err(source) => Err(StorageError::Io {
108                path: path.display().to_string(),
109                source,
110            }),
111        }
112    }
113
114    async fn delete(&self, key: &str) -> Result<(), StorageError> {
115        let path = self.path_for(key)?;
116        match fs::remove_file(&path).await {
117            Ok(()) => Ok(()),
118            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
119            Err(source) => Err(StorageError::Io {
120                path: path.display().to_string(),
121                source,
122            }),
123        }
124    }
125
126    async fn exists(&self, key: &str) -> Result<bool, StorageError> {
127        let path = self.path_for(key)?;
128        fs::try_exists(&path)
129            .await
130            .map_err(|source| StorageError::Io {
131                path: path.display().to_string(),
132                source,
133            })
134    }
135
136    async fn url(&self, key: &str, expires_in: Duration) -> Result<Url, StorageError> {
137        let key = checked_key(key)?;
138        let mut url = self.base_url.clone();
139        url.set_path(key);
140        url.query_pairs_mut()
141            .append_pair("service", &self.name)
142            .append_pair("expires_in", &expires_in.as_secs().to_string());
143        Ok(url)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn test_root(name: &str) -> PathBuf {
152        let mut path = std::env::temp_dir();
153        path.push(format!("rustrails-storage-{name}-{}", uuid::Uuid::now_v7()));
154        path
155    }
156
157    async fn service(name: &str) -> DiskService {
158        let root = test_root(name);
159        DiskService::new(name, root).expect("service should build")
160    }
161
162    #[tokio::test]
163    async fn test_upload_and_download_round_trip() {
164        let service = service("round-trip").await;
165        service
166            .upload("a.txt", Bytes::from_static(b"hello"))
167            .await
168            .expect("upload should succeed");
169        let bytes = service
170            .download("a.txt")
171            .await
172            .expect("download should succeed");
173        assert_eq!(bytes, Bytes::from_static(b"hello"));
174    }
175
176    #[tokio::test]
177    async fn test_upload_rejects_duplicate_keys() {
178        let service = service("duplicate").await;
179        service
180            .upload("a.txt", Bytes::from_static(b"one"))
181            .await
182            .expect("upload should succeed");
183        let error = service
184            .upload("a.txt", Bytes::from_static(b"two"))
185            .await
186            .expect_err("duplicate upload should fail");
187        assert!(matches!(error, StorageError::DuplicateKey(key) if key == "a.txt"));
188    }
189
190    #[tokio::test]
191    async fn test_download_missing_key_returns_not_found() {
192        let service = service("missing").await;
193        let error = service
194            .download("missing.txt")
195            .await
196            .expect_err("download should fail");
197        assert!(matches!(error, StorageError::NotFound(key) if key == "missing.txt"));
198    }
199
200    #[tokio::test]
201    async fn test_delete_removes_existing_file() {
202        let service = service("delete").await;
203        service
204            .upload("a.txt", Bytes::from_static(b"hello"))
205            .await
206            .expect("upload should succeed");
207        service
208            .delete("a.txt")
209            .await
210            .expect("delete should succeed");
211        assert!(
212            !service
213                .exists("a.txt")
214                .await
215                .expect("exists should succeed")
216        );
217    }
218
219    #[tokio::test]
220    async fn test_delete_missing_file_is_a_noop() {
221        let service = service("delete-missing").await;
222        service
223            .delete("missing.txt")
224            .await
225            .expect("delete should succeed");
226    }
227
228    #[tokio::test]
229    async fn test_exists_returns_true_for_uploaded_file() {
230        let service = service("exists-true").await;
231        service
232            .upload("a.txt", Bytes::from_static(b"hello"))
233            .await
234            .expect("upload should succeed");
235        assert!(
236            service
237                .exists("a.txt")
238                .await
239                .expect("exists should succeed")
240        );
241    }
242
243    #[tokio::test]
244    async fn test_exists_returns_false_for_missing_file() {
245        let service = service("exists-false").await;
246        assert!(
247            !service
248                .exists("missing.txt")
249                .await
250                .expect("exists should succeed")
251        );
252    }
253
254    #[tokio::test]
255    async fn test_upload_creates_nested_directories() {
256        let service = service("nested").await;
257        service
258            .upload("avatars/user-1/photo.jpg", Bytes::from_static(b"hello"))
259            .await
260            .expect("upload should succeed");
261        assert!(service.root().join("avatars/user-1/photo.jpg").exists());
262    }
263
264    #[tokio::test]
265    async fn test_zero_byte_upload_is_supported() {
266        let service = service("zero-byte").await;
267        service
268            .upload("empty.txt", Bytes::new())
269            .await
270            .expect("upload should succeed");
271        let bytes = service
272            .download("empty.txt")
273            .await
274            .expect("download should succeed");
275        assert!(bytes.is_empty());
276    }
277
278    #[tokio::test]
279    async fn test_url_contains_service_and_expiry() {
280        let service = service("url").await;
281        let url = service
282            .url("file.txt", Duration::from_secs(120))
283            .await
284            .expect("url should build");
285        assert_eq!(url.path(), "/file.txt");
286        assert!(
287            url.query()
288                .expect("query should exist")
289                .contains("expires_in=120")
290        );
291        assert!(
292            url.query()
293                .expect("query should exist")
294                .contains("service=url")
295        );
296    }
297
298    #[tokio::test]
299    async fn test_custom_base_url_is_used() {
300        let service = DiskService::new("disk", test_root("custom-base"))
301            .expect("service should build")
302            .with_base_url(Url::parse("https://cdn.example/storage/").expect("url should parse"));
303        let url = service
304            .url("file.txt", Duration::from_secs(60))
305            .await
306            .expect("url should build");
307        assert_eq!(
308            url.as_str(),
309            "https://cdn.example/file.txt?service=disk&expires_in=60"
310        );
311    }
312
313    #[tokio::test]
314    async fn test_service_name_is_reported() {
315        let service = service("service-name").await;
316        assert_eq!(service.name(), "service-name");
317    }
318
319    #[tokio::test]
320    async fn test_uploads_are_isolated_by_root() {
321        let service_a = service("isolated-a").await;
322        let service_b = service("isolated-b").await;
323        service_a
324            .upload("same.txt", Bytes::from_static(b"a"))
325            .await
326            .expect("upload should succeed");
327        service_b
328            .upload("same.txt", Bytes::from_static(b"b"))
329            .await
330            .expect("upload should succeed");
331        assert_eq!(
332            service_a
333                .download("same.txt")
334                .await
335                .expect("download should succeed"),
336            Bytes::from_static(b"a")
337        );
338        assert_eq!(
339            service_b
340                .download("same.txt")
341                .await
342                .expect("download should succeed"),
343            Bytes::from_static(b"b")
344        );
345    }
346
347    #[tokio::test]
348    async fn test_delete_keeps_parent_directory_stable() {
349        let service = service("parent-dir").await;
350        service
351            .upload("nested/file.txt", Bytes::from_static(b"hello"))
352            .await
353            .expect("upload should succeed");
354        service
355            .delete("nested/file.txt")
356            .await
357            .expect("delete should succeed");
358        assert!(service.root().join("nested").exists());
359    }
360
361    #[tokio::test]
362    async fn test_invalid_empty_key_rejected_for_upload() {
363        let service = service("invalid-key").await;
364        let error = service
365            .upload("   ", Bytes::from_static(b"hello"))
366            .await
367            .expect_err("upload should fail");
368        assert!(matches!(error, StorageError::InvalidUrl(_)));
369    }
370
371    #[tokio::test]
372    async fn test_download_after_delete_returns_not_found() {
373        let service = service("download-after-delete").await;
374        service
375            .upload("a.txt", Bytes::from_static(b"hello"))
376            .await
377            .expect("upload should succeed");
378        service
379            .delete("a.txt")
380            .await
381            .expect("delete should succeed");
382        let error = service
383            .download("a.txt")
384            .await
385            .expect_err("download should fail");
386        assert!(matches!(error, StorageError::NotFound(key) if key == "a.txt"));
387    }
388}