Skip to main content

rustlavel_storage/
lib.rs

1//! rustlavel-storage: files, wherever they live.
2//!
3//! ```ignore
4//! storage.put("avatars/7.png", bytes).await?;
5//! let bytes = storage.get("avatars/7.png").await?;
6//! ```
7//!
8//! One trait over local disk and any S3-compatible object store, so a feature
9//! written against a developer's `storage/app` directory runs unchanged
10//! against S3, R2, or MinIO.
11
12pub mod local;
13pub mod s3;
14pub mod sigv4;
15
16use rustlavel_core::{Config, Error, Result};
17
18pub use local::LocalStorage;
19pub use s3::{S3Config, S3Storage};
20
21/// One stored file's metadata.
22#[derive(Debug, Clone, PartialEq)]
23pub struct Entry {
24    pub path: String,
25    pub size: u64,
26    /// Whether this is a prefix (a directory) rather than an object.
27    pub is_directory: bool,
28}
29
30/// Whether a file is readable without credentials.
31///
32/// Local disk has no such concept, so it is recorded and ignored there; on S3
33/// it becomes the object ACL. Naming it in the API keeps a feature from working
34/// locally and silently failing in production.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Visibility {
37    Private,
38    Public,
39}
40
41/// The operations every driver supports.
42pub trait Storage: Send + Sync {
43    fn put(&self, path: &str, contents: Vec<u8>) -> impl Future<Output = Result<()>> + Send;
44
45    fn put_with(
46        &self,
47        path: &str,
48        contents: Vec<u8>,
49        visibility: Visibility,
50    ) -> impl Future<Output = Result<()>> + Send;
51
52    fn get(&self, path: &str) -> impl Future<Output = Result<Vec<u8>>> + Send;
53
54    fn exists(&self, path: &str) -> impl Future<Output = Result<bool>> + Send;
55
56    fn delete(&self, path: &str) -> impl Future<Output = Result<()>> + Send;
57
58    fn size(&self, path: &str) -> impl Future<Output = Result<u64>> + Send;
59
60    /// Everything directly under a prefix.
61    fn list(&self, prefix: &str) -> impl Future<Output = Result<Vec<Entry>>> + Send;
62
63    /// A URL a browser can fetch, when the driver can produce one.
64    fn url(&self, path: &str) -> Option<String>;
65}
66
67/// Reject a path that would escape the storage root.
68///
69/// Paths often come from user input — an upload's filename, a key in a
70/// request — so this runs before every operation rather than at the edges.
71pub fn normalize(path: &str) -> Result<String> {
72    let mut segments: Vec<&str> = Vec::new();
73
74    for segment in path.split('/') {
75        match segment {
76            "" | "." => continue,
77            ".." => {
78                if segments.pop().is_none() {
79                    return Err(traversal(path));
80                }
81            }
82            s if s.contains('\\') || s.contains('\0') => return Err(traversal(path)),
83            s => segments.push(s),
84        }
85    }
86
87    if segments.is_empty() {
88        return Err(Error::msg("a storage path cannot be empty"));
89    }
90    Ok(segments.join("/"))
91}
92
93fn traversal(path: &str) -> Error {
94    Error::msg(format!(
95        "`{path}` escapes the storage root. Paths are relative and may not contain `..`."
96    ))
97}
98
99/// Guess a content type from the extension, for the `Content-Type` an object
100/// store records and serves back.
101pub fn content_type(path: &str) -> &'static str {
102    match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
103        "html" | "htm" => "text/html; charset=utf-8",
104        "css" => "text/css; charset=utf-8",
105        "js" | "mjs" => "text/javascript; charset=utf-8",
106        "json" => "application/json",
107        "svg" => "image/svg+xml",
108        "png" => "image/png",
109        "jpg" | "jpeg" => "image/jpeg",
110        "gif" => "image/gif",
111        "webp" => "image/webp",
112        "avif" => "image/avif",
113        "pdf" => "application/pdf",
114        "csv" => "text/csv; charset=utf-8",
115        "txt" | "md" => "text/plain; charset=utf-8",
116        "zip" => "application/zip",
117        "mp4" => "video/mp4",
118        "webm" => "video/webm",
119        _ => "application/octet-stream",
120    }
121}
122
123/// Build the configured driver.
124///
125/// Returns an enum rather than a boxed trait object: `Storage` uses `async fn`,
126/// which is not dyn-safe, and two drivers do not justify the machinery that
127/// would make it so.
128pub enum AnyStorage {
129    Local(LocalStorage),
130    S3(Box<S3Storage>),
131}
132
133impl AnyStorage {
134    pub fn from_config(config: &Config, root: &std::path::Path) -> Result<AnyStorage> {
135        match config.string("storage.driver", "local").as_str() {
136            "local" => {
137                let path = config.string("storage.path", "storage/app");
138                Ok(AnyStorage::Local(LocalStorage::new(root.join(path))))
139            }
140            "s3" => Ok(AnyStorage::S3(Box::new(S3Storage::new(S3Config::from_config(config)?)))),
141            other => Err(Error::msg(format!(
142                "`{other}` is not a storage driver. Use `local` or `s3`."
143            ))),
144        }
145    }
146}
147
148impl Storage for AnyStorage {
149    async fn put(&self, path: &str, contents: Vec<u8>) -> Result<()> {
150        match self {
151            AnyStorage::Local(driver) => driver.put(path, contents).await,
152            AnyStorage::S3(driver) => driver.put(path, contents).await,
153        }
154    }
155
156    async fn put_with(&self, path: &str, contents: Vec<u8>, visibility: Visibility) -> Result<()> {
157        match self {
158            AnyStorage::Local(driver) => driver.put_with(path, contents, visibility).await,
159            AnyStorage::S3(driver) => driver.put_with(path, contents, visibility).await,
160        }
161    }
162
163    async fn get(&self, path: &str) -> Result<Vec<u8>> {
164        match self {
165            AnyStorage::Local(driver) => driver.get(path).await,
166            AnyStorage::S3(driver) => driver.get(path).await,
167        }
168    }
169
170    async fn exists(&self, path: &str) -> Result<bool> {
171        match self {
172            AnyStorage::Local(driver) => driver.exists(path).await,
173            AnyStorage::S3(driver) => driver.exists(path).await,
174        }
175    }
176
177    async fn delete(&self, path: &str) -> Result<()> {
178        match self {
179            AnyStorage::Local(driver) => driver.delete(path).await,
180            AnyStorage::S3(driver) => driver.delete(path).await,
181        }
182    }
183
184    async fn size(&self, path: &str) -> Result<u64> {
185        match self {
186            AnyStorage::Local(driver) => driver.size(path).await,
187            AnyStorage::S3(driver) => driver.size(path).await,
188        }
189    }
190
191    async fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
192        match self {
193            AnyStorage::Local(driver) => driver.list(prefix).await,
194            AnyStorage::S3(driver) => driver.list(prefix).await,
195        }
196    }
197
198    fn url(&self, path: &str) -> Option<String> {
199        match self {
200            AnyStorage::Local(driver) => driver.url(path),
201            AnyStorage::S3(driver) => driver.url(path),
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn normalizes_ordinary_paths() {
212        assert_eq!(normalize("avatars/7.png").unwrap(), "avatars/7.png");
213        assert_eq!(normalize("/avatars//7.png").unwrap(), "avatars/7.png");
214        assert_eq!(normalize("a/./b").unwrap(), "a/b");
215        assert_eq!(normalize("a/b/../c").unwrap(), "a/c");
216    }
217
218    #[test]
219    fn refuses_to_escape_the_root() {
220        for attempt in ["../secrets", "a/../../secrets", "..", "a\\b", "a\0b"] {
221            assert!(normalize(attempt).is_err(), "{attempt:?} should be rejected");
222        }
223    }
224
225    #[test]
226    fn an_empty_path_is_an_error() {
227        assert!(normalize("").is_err());
228        assert!(normalize("/").is_err());
229    }
230
231    #[test]
232    fn guesses_content_types_from_the_extension() {
233        assert_eq!(content_type("a/b/photo.PNG"), "image/png");
234        assert_eq!(content_type("report.pdf"), "application/pdf");
235        assert_eq!(content_type("noextension"), "application/octet-stream");
236    }
237}