rustrails_storage/service/
disk.rs1use 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#[derive(Debug, Clone)]
17pub struct DiskService {
18 name: String,
19 root: PathBuf,
20 base_url: Url,
21}
22
23impl DiskService {
24 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 #[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 #[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}