Skip to main content

rainier_filesystem/
local.rs

1//! [`LocalFilesystem`] — files on this machine's disk.
2
3use 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
11/// Files under one root directory.
12///
13/// Every path is [normalised](normalise_path) and then joined to the root, and
14/// the result is checked to still be **inside** it. Two independent guards,
15/// because the consequence of getting it wrong is reading or writing anywhere
16/// the process can reach.
17pub struct LocalFilesystem {
18    root: PathBuf,
19    /// A URL prefix, for a root something else serves.
20    url_prefix: Option<String>,
21}
22
23impl LocalFilesystem {
24    /// Files under `root`.
25    pub fn new(root: impl Into<PathBuf>) -> Self {
26        Self { root: root.into(), url_prefix: None }
27    }
28
29    /// The URL prefix files under this root are reachable at.
30    ///
31    /// Only meaningful if something — nginx, a CDN, a route you wrote — actually
32    /// serves the directory. Without it [`url`](Filesystem::url) is `None`,
33    /// because a link that 404s is worse than no link.
34    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    /// The root directory.
40    pub fn root(&self) -> &Path {
41        &self.root
42    }
43
44    /// Resolve a storage path to a filesystem path inside the root.
45    fn resolve(&self, path: &str) -> Result<PathBuf> {
46        let normalised = normalise_path(path)?;
47        let joined = self.root.join(&normalised);
48
49        // The second guard. `normalise_path` already refuses `..`, but a symlink
50        // inside the root can point outside it, and on Windows a name like
51        // `C:foo` or a device name (`CON`, `NUL`) can escape a naive join.
52        // Comparing the canonical parent is what actually holds.
53        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            // A directory is not a file, and reporting it as one would make
75            // `exists` true for something `get` cannot read.
76            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            // Checked before reading rather than inferred from the error, because
93            // the error a directory produces is **platform-specific**: Unix gives
94            // `IsADirectory` and Windows gives `PermissionDenied`. Matching on
95            // either would make this port behave differently by platform, and
96            // `PermissionDenied` is also a genuine failure worth surfacing.
97            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                // Deleted between the check and the read.
104                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            // Written to a temporary file and renamed, so a reader never sees a
121            // half-written file and a crash mid-write does not truncate what was
122            // already there. The rename is atomic on every platform that matters.
123            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                    // Leaving the temporary behind would accumulate rubbish next
136                    // to the real files.
137                    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                // A prefix with nothing under it lists as empty, not as an
179                // error: "what is in here" has a sensible answer for a
180                // directory that does not exist yet.
181                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                    // A name that is not UTF-8 has no storage path, so it is not
196                    // addressable through this port and listing it would be a
197                    // lie.
198                    continue;
199                };
200
201                // The half-written files `put` leaves during a rename are not
202                // stored files.
203                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            // Sorted, so a listing is reproducible; the OS gives no order.
215            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            // A real rename rather than copy-then-delete: atomic, and it does
232            // not read the file through this process.
233            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
262/// Turn an I/O error into a framework one.
263///
264/// The path is named but the underlying message is not passed through verbatim:
265/// an OS error frequently contains the absolute path, which discloses the
266/// deployment's directory layout.
267fn 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    /// A temporary root, removed when the test ends.
277    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        // `exists("dir")` must be false, or `get("dir")` would be expected to
401        // work and cannot.
402        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        // A canary outside the root, which none of these must reach.
448        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        // A leading slash is stripped rather than honoured, so this writes
467        // inside the root — not to `/etc`.
468        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}