1use std::path::{Path, PathBuf};
4
5use bytes::Bytes;
6use chrono::{DateTime, Utc};
7use rainier_support::{BoxFuture, Error, Result};
8
9use crate::filesystem::{normalise_path, normalise_prefix, Filesystem, Metadata};
10
11pub struct LocalFilesystem {
18 root: PathBuf,
19 url_prefix: Option<String>,
21}
22
23impl LocalFilesystem {
24 pub fn new(root: impl Into<PathBuf>) -> Self {
26 Self { root: root.into(), url_prefix: None }
27 }
28
29 pub fn with_url_prefix(mut self, prefix: impl Into<String>) -> Self {
35 self.url_prefix = Some(prefix.into().trim_end_matches('/').to_string());
36 self
37 }
38
39 pub fn root(&self) -> &Path {
41 &self.root
42 }
43
44 fn resolve(&self, path: &str) -> Result<PathBuf> {
46 let normalised = normalise_path(path)?;
47 let joined = self.root.join(&normalised);
48
49 let parent = joined.parent().unwrap_or(&self.root);
54 if let (Ok(canonical_root), Ok(canonical_parent)) =
55 (std::fs::canonicalize(&self.root), std::fs::canonicalize(parent))
56 {
57 if !canonical_parent.starts_with(&canonical_root) {
58 return Err(Error::bad_request(
59 "that storage path resolves outside the filesystem root",
60 ));
61 }
62 }
63
64 Ok(joined)
65 }
66
67 async fn read_metadata(&self, path: &str, resolved: &Path) -> Result<Option<Metadata>> {
68 match tokio::fs::metadata(resolved).await {
69 Ok(meta) if meta.is_file() => Ok(Some(Metadata {
70 path: normalise_path(path)?,
71 size: meta.len(),
72 last_modified: meta.modified().ok().map(system_time_to_utc),
73 })),
74 Ok(_) => Ok(None),
77 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
78 Err(e) => Err(io_error("read metadata for", path, e)),
79 }
80 }
81}
82
83impl Filesystem for LocalFilesystem {
84 fn name(&self) -> &str {
85 "local"
86 }
87
88 fn get<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Option<Bytes>>> {
89 Box::pin(async move {
90 let resolved = self.resolve(path)?;
91
92 if self.read_metadata(path, &resolved).await?.is_none() {
98 return Ok(None);
99 }
100
101 match tokio::fs::read(&resolved).await {
102 Ok(bytes) => Ok(Some(Bytes::from(bytes))),
103 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
105 Err(e) => Err(io_error("read", path, e)),
106 }
107 })
108 }
109
110 fn put<'a>(&'a self, path: &'a str, contents: Bytes) -> BoxFuture<'a, Result<()>> {
111 Box::pin(async move {
112 let resolved = self.resolve(path)?;
113
114 if let Some(parent) = resolved.parent() {
115 tokio::fs::create_dir_all(parent)
116 .await
117 .map_err(|e| io_error("create the directory for", path, e))?;
118 }
119
120 let temporary = resolved.with_extension(format!(
124 "{}.rainier-tmp",
125 resolved.extension().and_then(|e| e.to_str()).unwrap_or("")
126 ));
127
128 tokio::fs::write(&temporary, &contents)
129 .await
130 .map_err(|e| io_error("write", path, e))?;
131
132 match tokio::fs::rename(&temporary, &resolved).await {
133 Ok(()) => Ok(()),
134 Err(e) => {
135 let _ = tokio::fs::remove_file(&temporary).await;
138 Err(io_error("write", path, e))
139 }
140 }
141 })
142 }
143
144 fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<bool>> {
145 Box::pin(async move {
146 let resolved = self.resolve(path)?;
147
148 match tokio::fs::remove_file(&resolved).await {
149 Ok(()) => Ok(true),
150 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
151 Err(e) => Err(io_error("delete", path, e)),
152 }
153 })
154 }
155
156 fn exists<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<bool>> {
157 Box::pin(async move {
158 let resolved = self.resolve(path)?;
159 Ok(self.read_metadata(path, &resolved).await?.is_some())
160 })
161 }
162
163 fn metadata<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Option<Metadata>>> {
164 Box::pin(async move {
165 let resolved = self.resolve(path)?;
166 self.read_metadata(path, &resolved).await
167 })
168 }
169
170 fn list<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<Vec<Metadata>>> {
171 Box::pin(async move {
172 let normalised = normalise_prefix(prefix)?;
173 let directory =
174 if normalised.is_empty() { self.root.clone() } else { self.root.join(&normalised) };
175
176 let mut entries = match tokio::fs::read_dir(&directory).await {
177 Ok(entries) => entries,
178 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
182 Err(e) => return Err(io_error("list", prefix, e)),
183 };
184
185 let mut out = Vec::new();
186 while let Some(entry) =
187 entries.next_entry().await.map_err(|e| io_error("list", prefix, e))?
188 {
189 let meta = match entry.metadata().await {
190 Ok(meta) if meta.is_file() => meta,
191 _ => continue,
192 };
193
194 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
195 continue;
199 };
200
201 if name.ends_with(".rainier-tmp") {
204 continue;
205 }
206
207 out.push(Metadata {
208 path: if normalised.is_empty() { name } else { format!("{normalised}/{name}") },
209 size: meta.len(),
210 last_modified: meta.modified().ok().map(system_time_to_utc),
211 });
212 }
213
214 out.sort_by(|a, b| a.path.cmp(&b.path));
216 Ok(out)
217 })
218 }
219
220 fn rename<'a>(&'a self, from: &'a str, to: &'a str) -> BoxFuture<'a, Result<()>> {
221 Box::pin(async move {
222 let source = self.resolve(from)?;
223 let target = self.resolve(to)?;
224
225 if let Some(parent) = target.parent() {
226 tokio::fs::create_dir_all(parent)
227 .await
228 .map_err(|e| io_error("create the directory for", to, e))?;
229 }
230
231 tokio::fs::rename(&source, &target).await.map_err(|e| {
234 if e.kind() == std::io::ErrorKind::NotFound {
235 Error::not_found(format!("`{from}` does not exist"))
236 } else {
237 io_error("move", from, e)
238 }
239 })
240 })
241 }
242
243 fn url(&self, path: &str) -> Option<String> {
244 let prefix = self.url_prefix.as_ref()?;
245 let normalised = normalise_path(path).ok()?;
246 Some(format!("{prefix}/{normalised}"))
247 }
248}
249
250impl std::fmt::Debug for LocalFilesystem {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 f.debug_struct("LocalFilesystem").field("root", &self.root).finish()
253 }
254}
255
256fn system_time_to_utc(time: std::time::SystemTime) -> DateTime<Utc> {
257 let seconds =
258 time.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
259 DateTime::from_timestamp(seconds, 0).unwrap_or_default()
260}
261
262fn io_error(action: &str, path: &str, error: std::io::Error) -> Error {
268 Error::internal(format!("could not {action} `{path}`: {}", error.kind()))
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::filesystem::FilesystemExt;
275
276 struct Temp(PathBuf);
278
279 impl Temp {
280 fn new(name: &str) -> Self {
281 let root = std::env::temp_dir()
282 .join("rainier-fs-tests")
283 .join(format!("{name}-{}", std::process::id()));
284 let _ = std::fs::remove_dir_all(&root);
285 std::fs::create_dir_all(&root).unwrap();
286 Self(root)
287 }
288
289 fn filesystem(&self) -> LocalFilesystem {
290 LocalFilesystem::new(&self.0)
291 }
292 }
293
294 impl Drop for Temp {
295 fn drop(&mut self) {
296 let _ = std::fs::remove_dir_all(&self.0);
297 }
298 }
299
300 #[tokio::test]
301 async fn a_file_round_trips() {
302 let temp = Temp::new("round-trip");
303 let fs = temp.filesystem();
304
305 fs.put_string("a/b/hello.txt", "hello").await.unwrap();
306
307 assert_eq!(fs.get_string("a/b/hello.txt").await.unwrap().as_deref(), Some("hello"));
308 assert!(fs.exists("a/b/hello.txt").await.unwrap());
309 assert_eq!(fs.size("a/b/hello.txt").await.unwrap(), Some(5));
310 }
311
312 #[tokio::test]
313 async fn directories_are_created_as_needed() {
314 let temp = Temp::new("mkdir");
315 let fs = temp.filesystem();
316
317 fs.put_string("deeply/nested/path/file.txt", "x").await.unwrap();
318 assert!(fs.exists("deeply/nested/path/file.txt").await.unwrap());
319 }
320
321 #[tokio::test]
322 async fn a_missing_file_reads_as_none_rather_than_erring() {
323 let temp = Temp::new("missing");
324 let fs = temp.filesystem();
325
326 assert_eq!(fs.get("absent.txt").await.unwrap(), None);
327 assert!(!fs.exists("absent.txt").await.unwrap());
328 assert_eq!(fs.metadata("absent.txt").await.unwrap(), None);
329 assert_eq!(fs.size("absent.txt").await.unwrap(), None);
330 }
331
332 #[tokio::test]
333 async fn get_or_fail_is_a_404() {
334 let temp = Temp::new("or-fail");
335 let err = temp.filesystem().get_or_fail("absent.txt").await.unwrap_err();
336
337 assert_eq!(err.status(), 404);
338 }
339
340 #[tokio::test]
341 async fn deleting_reports_whether_it_was_there() {
342 let temp = Temp::new("delete");
343 let fs = temp.filesystem();
344 fs.put_string("gone.txt", "x").await.unwrap();
345
346 assert!(fs.delete("gone.txt").await.unwrap());
347 assert!(!fs.delete("gone.txt").await.unwrap());
348 }
349
350 #[tokio::test]
351 async fn a_write_replaces_what_was_there() {
352 let temp = Temp::new("replace");
353 let fs = temp.filesystem();
354
355 fs.put_string("f.txt", "first").await.unwrap();
356 fs.put_string("f.txt", "second").await.unwrap();
357
358 assert_eq!(fs.get_string("f.txt").await.unwrap().as_deref(), Some("second"));
359 }
360
361 #[tokio::test]
362 async fn a_write_leaves_no_temporary_behind() {
363 let temp = Temp::new("no-temp");
364 let fs = temp.filesystem();
365 fs.put_string("f.txt", "x").await.unwrap();
366
367 let listed = fs.list("").await.unwrap();
368 assert_eq!(listed.len(), 1, "{listed:?}");
369 assert_eq!(listed[0].path, "f.txt");
370 }
371
372 #[tokio::test]
373 async fn listing_is_shallow_and_sorted() {
374 let temp = Temp::new("list");
375 let fs = temp.filesystem();
376
377 fs.put_string("z.txt", "x").await.unwrap();
378 fs.put_string("a.txt", "x").await.unwrap();
379 fs.put_string("sub/deep.txt", "x").await.unwrap();
380
381 let root: Vec<String> = fs.list("").await.unwrap().into_iter().map(|m| m.path).collect();
382 assert_eq!(root, vec!["a.txt", "z.txt"], "directories and their contents are not listed");
383
384 let sub: Vec<String> = fs.list("sub").await.unwrap().into_iter().map(|m| m.path).collect();
385 assert_eq!(sub, vec!["sub/deep.txt"], "and a listed path is one `get` accepts");
386 }
387
388 #[tokio::test]
389 async fn listing_a_prefix_with_nothing_under_it_is_empty_not_an_error() {
390 let temp = Temp::new("list-empty");
391 assert!(temp.filesystem().list("never/created").await.unwrap().is_empty());
392 }
393
394 #[tokio::test]
395 async fn a_directory_is_not_a_file() {
396 let temp = Temp::new("directory");
397 let fs = temp.filesystem();
398 fs.put_string("dir/file.txt", "x").await.unwrap();
399
400 assert!(!fs.exists("dir").await.unwrap());
403 assert_eq!(fs.get("dir").await.unwrap(), None);
404 }
405
406 #[tokio::test]
407 async fn copying_leaves_the_original() {
408 let temp = Temp::new("copy");
409 let fs = temp.filesystem();
410 fs.put_string("a.txt", "content").await.unwrap();
411
412 fs.copy("a.txt", "b/c.txt").await.unwrap();
413
414 assert_eq!(fs.get_string("a.txt").await.unwrap().as_deref(), Some("content"));
415 assert_eq!(fs.get_string("b/c.txt").await.unwrap().as_deref(), Some("content"));
416 }
417
418 #[tokio::test]
419 async fn copying_something_absent_is_a_404() {
420 let temp = Temp::new("copy-missing");
421 assert_eq!(temp.filesystem().copy("no.txt", "b.txt").await.unwrap_err().status(), 404);
422 }
423
424 #[tokio::test]
425 async fn moving_removes_the_original() {
426 let temp = Temp::new("move");
427 let fs = temp.filesystem();
428 fs.put_string("a.txt", "content").await.unwrap();
429
430 fs.rename("a.txt", "sub/b.txt").await.unwrap();
431
432 assert!(!fs.exists("a.txt").await.unwrap());
433 assert_eq!(fs.get_string("sub/b.txt").await.unwrap().as_deref(), Some("content"));
434 }
435
436 #[tokio::test]
437 async fn moving_something_absent_is_a_404() {
438 let temp = Temp::new("move-missing");
439 assert_eq!(temp.filesystem().rename("no.txt", "b.txt").await.unwrap_err().status(), 404);
440 }
441
442 #[tokio::test]
443 async fn a_traversal_cannot_escape_the_root() {
444 let temp = Temp::new("traversal");
445 let fs = temp.filesystem();
446
447 let outside = temp.0.parent().unwrap().join("outside.txt");
449 std::fs::write(&outside, "secret").unwrap();
450
451 for hostile in ["../outside.txt", "a/../../outside.txt", "..\\outside.txt"] {
452 assert!(fs.get(hostile).await.is_err(), "{hostile}");
453 assert!(fs.put(hostile, Bytes::from_static(b"x")).await.is_err(), "{hostile}");
454 assert!(fs.delete(hostile).await.is_err(), "{hostile}");
455 }
456
457 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "secret", "untouched");
458 let _ = std::fs::remove_file(&outside);
459 }
460
461 #[tokio::test]
462 async fn an_absolute_path_is_treated_as_relative_to_the_root() {
463 let temp = Temp::new("absolute");
464 let fs = temp.filesystem();
465
466 fs.put_string("/etc/passwd", "not really").await.unwrap();
469
470 assert!(temp.0.join("etc/passwd").exists());
471 }
472
473 #[tokio::test]
474 async fn appending_accumulates() {
475 let temp = Temp::new("append");
476 let fs = temp.filesystem();
477
478 fs.append("log.txt", b"one\n").await.unwrap();
479 fs.append("log.txt", b"two\n").await.unwrap();
480
481 assert_eq!(fs.get_string("log.txt").await.unwrap().as_deref(), Some("one\ntwo\n"));
482 }
483
484 #[tokio::test]
485 async fn metadata_records_a_modification_time() {
486 let temp = Temp::new("mtime");
487 let fs = temp.filesystem();
488 fs.put_string("f.txt", "x").await.unwrap();
489
490 let modified = fs.last_modified("f.txt").await.unwrap().expect("a time");
491 assert!(modified.timestamp() > 1_600_000_000, "{modified}");
492 }
493
494 #[test]
495 fn there_is_no_url_without_a_prefix() {
496 let temp = Temp::new("url");
497 assert_eq!(temp.filesystem().url("a.txt"), None);
498
499 let served = temp.filesystem().with_url_prefix("https://cdn.example.com/files/");
500 assert_eq!(served.url("a/b.txt").as_deref(), Some("https://cdn.example.com/files/a/b.txt"));
501 }
502
503 #[test]
504 fn a_url_is_not_produced_for_a_path_that_would_be_refused() {
505 let temp = Temp::new("url-traversal");
506 let served = temp.filesystem().with_url_prefix("https://cdn.example.com");
507
508 assert_eq!(served.url("../secrets"), None);
509 }
510
511 #[tokio::test]
512 async fn the_driver_is_named() {
513 let temp = Temp::new("name");
514 assert_eq!(temp.filesystem().name(), "local");
515 }
516
517 #[tokio::test]
518 async fn unicode_names_work() {
519 let temp = Temp::new("unicode");
520 let fs = temp.filesystem();
521
522 fs.put_string("uploads/my file 🎉.txt", "x").await.unwrap();
523 assert!(fs.exists("uploads/my file 🎉.txt").await.unwrap());
524 }
525}