rainier_filesystem/
filesystem.rs1use bytes::Bytes;
4use chrono::{DateTime, Utc};
5use rainier_support::{BoxFuture, Error, Result};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Metadata {
10 pub path: String,
12 pub size: u64,
14 pub last_modified: Option<DateTime<Utc>>,
16}
17
18pub trait Filesystem: Send + Sync + 'static {
28 fn name(&self) -> &str;
30
31 fn get<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Option<Bytes>>>;
37
38 fn put<'a>(&'a self, path: &'a str, contents: Bytes) -> BoxFuture<'a, Result<()>>;
41
42 fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<bool>>;
44
45 fn exists<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<bool>>;
47
48 fn metadata<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Option<Metadata>>>;
50
51 fn list<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<Vec<Metadata>>>;
55
56 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 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 fn url(&self, path: &str) -> Option<String> {
86 let _ = path;
87 None
88 }
89}
90
91#[async_trait::async_trait]
95pub trait FilesystemExt: Filesystem {
96 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 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 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 async fn size(&self, path: &str) -> Result<Option<u64>> {
120 Ok(self.metadata(path).await?.map(|meta| meta.size))
121 }
122
123 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 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
142pub 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 "" | "." => 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 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
179pub 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 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 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}