Skip to main content

rainier_filesystem/
filesystem.rs

1//! The [`Filesystem`] port and the conveniences every driver gets.
2
3use bytes::Bytes;
4use chrono::{DateTime, Utc};
5use rainier_support::{BoxFuture, Error, Result};
6
7/// What a driver knows about one stored file.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Metadata {
10    /// The path, as the driver would accept it back.
11    pub path: String,
12    /// Size in bytes.
13    pub size: u64,
14    /// When it was last written, if the driver records that.
15    pub last_modified: Option<DateTime<Utc>>,
16}
17
18/// Where a driver keeps bytes.
19///
20/// One shape for every driver, for one reason: an
21/// application that writes uploads should not know whether they land on a disk,
22/// in S3, or in a test's memory.
23///
24/// Paths are **always `/`-separated and always relative**, whatever the platform
25/// underneath. A driver that stores on disk translates; a driver that stores in a
26/// bucket does not have to.
27pub trait Filesystem: Send + Sync + 'static {
28    /// A label for diagnostics — `"local"`, `"s3"`, `"memory"`.
29    fn name(&self) -> &str;
30
31    /// Read a file. `None` if it is not there.
32    ///
33    /// A missing file is **not an error**: "read it if it exists" is the common
34    /// case, and making the caller distinguish absence from failure by parsing a
35    /// message is how absence ends up being treated as an outage.
36    fn get<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Option<Bytes>>>;
37
38    /// Write a file, creating any directories it needs and replacing what was
39    /// there.
40    fn put<'a>(&'a self, path: &'a str, contents: Bytes) -> BoxFuture<'a, Result<()>>;
41
42    /// Delete a file. `true` if it was there.
43    fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<bool>>;
44
45    /// Whether a file exists.
46    fn exists<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<bool>>;
47
48    /// A file's metadata, or `None`.
49    fn metadata<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Option<Metadata>>>;
50
51    /// Every file directly under `prefix`, not recursing.
52    ///
53    /// `""` lists the root.
54    fn list<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<Vec<Metadata>>>;
55
56    /// Copy a file.
57    ///
58    /// Provided as read-then-write, which every driver can do. Override it where
59    /// the backend can copy server-side — S3 can, and doing so avoids pulling
60    /// the object through this process.
61    fn copy<'a>(&'a self, from: &'a str, to: &'a str) -> BoxFuture<'a, Result<()>> {
62        Box::pin(async move {
63            let contents = self
64                .get(from)
65                .await?
66                .ok_or_else(|| Error::not_found(format!("`{from}` does not exist")))?;
67            self.put(to, contents).await
68        })
69    }
70
71    /// Move a file.
72    fn rename<'a>(&'a self, from: &'a str, to: &'a str) -> BoxFuture<'a, Result<()>> {
73        Box::pin(async move {
74            self.copy(from, to).await?;
75            self.delete(from).await?;
76            Ok(())
77        })
78    }
79
80    /// A URL a browser can fetch, if this driver has one.
81    ///
82    /// `None` for a driver with no public face — a local disk behind an
83    /// application is not reachable by URL, and pretending otherwise produces a
84    /// link that 404s.
85    fn url(&self, path: &str) -> Option<String> {
86        let _ = path;
87        None
88    }
89}
90
91/// The typed conveniences every [`Filesystem`] gets.
92///
93/// Kept out of the object-safe trait so `Arc<dyn Filesystem>` still exists.
94#[async_trait::async_trait]
95pub trait FilesystemExt: Filesystem {
96    /// Read a file as a string.
97    async fn get_string(&self, path: &str) -> Result<Option<String>> {
98        match self.get(path).await? {
99            Some(bytes) => String::from_utf8(bytes.to_vec())
100                .map(Some)
101                .map_err(|_| Error::internal(format!("`{path}` is not valid UTF-8"))),
102            None => Ok(None),
103        }
104    }
105
106    /// Write a string.
107    async fn put_string(&self, path: &str, contents: &str) -> Result<()> {
108        self.put(path, Bytes::from(contents.to_string().into_bytes())).await
109    }
110
111    /// Read a file, failing with a `404` if it is absent.
112    ///
113    /// What a controller serving a file wants, so the missing case is one `?`.
114    async fn get_or_fail(&self, path: &str) -> Result<Bytes> {
115        self.get(path).await?.ok_or_else(|| Error::not_found(format!("`{path}` does not exist")))
116    }
117
118    /// A file's size.
119    async fn size(&self, path: &str) -> Result<Option<u64>> {
120        Ok(self.metadata(path).await?.map(|meta| meta.size))
121    }
122
123    /// When a file was last written.
124    async fn last_modified(&self, path: &str) -> Result<Option<DateTime<Utc>>> {
125        Ok(self.metadata(path).await?.and_then(|meta| meta.last_modified))
126    }
127
128    /// Append to a file, creating it if absent.
129    ///
130    /// **Read-modify-write, and therefore not atomic.** Two concurrent appends
131    /// can lose one. Fine for a log nobody depends on; wrong for anything else,
132    /// and there is no portable way to make it right across a disk and a bucket.
133    async fn append(&self, path: &str, contents: &[u8]) -> Result<()> {
134        let mut existing = self.get(path).await?.map(|b| b.to_vec()).unwrap_or_default();
135        existing.extend_from_slice(contents);
136        self.put(path, Bytes::from(existing)).await
137    }
138}
139
140impl<F: Filesystem + ?Sized> FilesystemExt for F {}
141
142/// Reject a path that could escape its root, and normalise separators.
143///
144/// Applies to **every** driver, not only the local one: a `..` in an S3 key is
145/// legal but nearly always a bug, and a leading `/` produces a key with an empty
146/// first segment that is then impossible to address consistently.
147pub fn normalise_path(path: &str) -> Result<String> {
148    let path = path.replace('\\', "/");
149
150    let mut segments = Vec::new();
151    for segment in path.split('/') {
152        match segment {
153            // A doubled separator, a leading one, or `./` — all noise.
154            "" | "." => continue,
155            ".." => {
156                return Err(Error::bad_request(
157                    "a storage path must not contain `..`; it is either an attempt to escape \
158                     the root or a path that was built wrong",
159                ))
160            }
161            segment => segments.push(segment),
162        }
163    }
164
165    if segments.is_empty() {
166        return Err(Error::bad_request("a storage path must not be empty"));
167    }
168
169    // A NUL truncates the path in every C API underneath, so a name containing
170    // one can address a different file than it appears to.
171    let joined = segments.join("/");
172    if joined.contains('\0') {
173        return Err(Error::bad_request("a storage path must not contain a NUL byte"));
174    }
175
176    Ok(joined)
177}
178
179/// Normalise a listing prefix, which unlike a path may be empty.
180pub fn normalise_prefix(prefix: &str) -> Result<String> {
181    let trimmed = prefix.trim_matches('/');
182    if trimmed.is_empty() {
183        return Ok(String::new());
184    }
185    normalise_path(trimmed)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn a_plain_path_passes_through() {
194        assert_eq!(normalise_path("a/b/c.txt").unwrap(), "a/b/c.txt");
195    }
196
197    #[test]
198    fn separators_are_normalised() {
199        assert_eq!(normalise_path("a\\b\\c.txt").unwrap(), "a/b/c.txt");
200        assert_eq!(normalise_path("a//b///c.txt").unwrap(), "a/b/c.txt");
201        assert_eq!(normalise_path("/a/b/").unwrap(), "a/b");
202        assert_eq!(normalise_path("./a/./b").unwrap(), "a/b");
203    }
204
205    #[test]
206    fn a_traversal_is_refused_rather_than_resolved() {
207        // Resolving it would silently address a different file, which is worse
208        // than refusing: the caller thinks it wrote where it asked.
209        for hostile in ["../secrets", "a/../../etc/passwd", "..", "a/..", "..\\windows"] {
210            let err = normalise_path(hostile).unwrap_err();
211            assert_eq!(err.status(), 400, "{hostile}");
212            assert!(err.message().contains(".."), "{hostile}");
213        }
214    }
215
216    #[test]
217    fn an_empty_path_is_refused() {
218        for empty in ["", "/", "//", ".", "./"] {
219            assert!(normalise_path(empty).is_err(), "{empty:?}");
220        }
221    }
222
223    #[test]
224    fn a_nul_byte_is_refused() {
225        // It truncates the path in every C API underneath, so `a\0b.txt` can
226        // address `a` while looking like something else.
227        assert!(normalise_path("a\0b.txt").is_err());
228    }
229
230    #[test]
231    fn a_prefix_may_be_empty_but_still_may_not_escape() {
232        assert_eq!(normalise_prefix("").unwrap(), "");
233        assert_eq!(normalise_prefix("/").unwrap(), "");
234        assert_eq!(normalise_prefix("uploads/").unwrap(), "uploads");
235        assert!(normalise_prefix("../etc").is_err());
236    }
237
238    #[test]
239    fn unicode_and_spaces_survive() {
240        assert_eq!(normalise_path("uploads/my file 🎉.png").unwrap(), "uploads/my file 🎉.png");
241    }
242}