Skip to main content

rustlavel_storage/
local.rs

1//! Files on local disk.
2
3use crate::{Entry, Storage, Visibility, normalize};
4use rustlavel_core::{Error, Result};
5use std::path::{Path, PathBuf};
6
7/// Stores files under a root directory.
8#[derive(Clone)]
9pub struct LocalStorage {
10    root: PathBuf,
11    /// Prefix for [`Storage::url`], when the root is served over HTTP.
12    url_prefix: Option<String>,
13}
14
15impl LocalStorage {
16    pub fn new(root: impl Into<PathBuf>) -> Self {
17        LocalStorage { root: root.into(), url_prefix: None }
18    }
19
20    /// Say how this root is reached over HTTP, so `url` can answer.
21    pub fn served_at(mut self, prefix: impl Into<String>) -> Self {
22        self.url_prefix = Some(prefix.into());
23        self
24    }
25
26    pub fn root(&self) -> &Path {
27        &self.root
28    }
29
30    fn resolve(&self, path: &str) -> Result<PathBuf> {
31        Ok(self.root.join(normalize(path)?))
32    }
33
34    fn missing(&self, path: &str) -> Error {
35        Error::msg(format!("`{path}` is not in {}", self.root.display()))
36    }
37}
38
39impl Storage for LocalStorage {
40    async fn put(&self, path: &str, contents: Vec<u8>) -> Result<()> {
41        self.put_with(path, contents, Visibility::Private).await
42    }
43
44    async fn put_with(&self, path: &str, contents: Vec<u8>, _visibility: Visibility) -> Result<()> {
45        // Local disk has no visibility of its own; the web server decides what
46        // is reachable. Recorded in the signature so a caller writing portable
47        // code does not have to branch.
48        let target = self.resolve(path)?;
49
50        if let Some(parent) = target.parent() {
51            tokio::fs::create_dir_all(parent).await.map_err(Error::Io)?;
52        }
53        tokio::fs::write(&target, contents).await.map_err(Error::Io)
54    }
55
56    async fn get(&self, path: &str) -> Result<Vec<u8>> {
57        let target = self.resolve(path)?;
58        match tokio::fs::read(&target).await {
59            Ok(bytes) => Ok(bytes),
60            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(self.missing(path)),
61            Err(e) => Err(Error::Io(e)),
62        }
63    }
64
65    async fn exists(&self, path: &str) -> Result<bool> {
66        Ok(tokio::fs::metadata(self.resolve(path)?).await.is_ok())
67    }
68
69    async fn delete(&self, path: &str) -> Result<()> {
70        match tokio::fs::remove_file(self.resolve(path)?).await {
71            Ok(()) => Ok(()),
72            // Deleting something that is already gone is the desired state.
73            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
74            Err(e) => Err(Error::Io(e)),
75        }
76    }
77
78    async fn size(&self, path: &str) -> Result<u64> {
79        match tokio::fs::metadata(self.resolve(path)?).await {
80            Ok(metadata) => Ok(metadata.len()),
81            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(self.missing(path)),
82            Err(e) => Err(Error::Io(e)),
83        }
84    }
85
86    async fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
87        let directory =
88            if prefix.trim_matches('/').is_empty() { self.root.clone() } else { self.resolve(prefix)? };
89
90        let mut entries = Vec::new();
91        let mut reader = match tokio::fs::read_dir(&directory).await {
92            Ok(reader) => reader,
93            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
94            Err(e) => return Err(Error::Io(e)),
95        };
96
97        while let Some(entry) = reader.next_entry().await.map_err(Error::Io)? {
98            let metadata = entry.metadata().await.map_err(Error::Io)?;
99            let relative = entry
100                .path()
101                .strip_prefix(&self.root)
102                .map(|p| p.to_string_lossy().replace('\\', "/"))
103                .unwrap_or_default();
104
105            entries.push(Entry {
106                path: relative,
107                size: metadata.len(),
108                is_directory: metadata.is_dir(),
109            });
110        }
111
112        // Directory order is filesystem-dependent; sorting makes it repeatable.
113        entries.sort_by(|a, b| a.path.cmp(&b.path));
114        Ok(entries)
115    }
116
117    fn url(&self, path: &str) -> Option<String> {
118        let prefix = self.url_prefix.as_ref()?;
119        let clean = normalize(path).ok()?;
120        Some(format!("{}/{clean}", prefix.trim_end_matches('/')))
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    /// Each test gets its own root: tests run concurrently.
129    fn storage(name: &str) -> LocalStorage {
130        let root = std::env::temp_dir()
131            .join(format!("rustlavel-storage-{name}-{}", std::process::id()));
132        let _ = std::fs::remove_dir_all(&root);
133        std::fs::create_dir_all(&root).unwrap();
134        LocalStorage::new(root)
135    }
136
137    #[tokio::test]
138    async fn writes_reads_and_deletes() {
139        let storage = storage("roundtrip");
140
141        storage.put("notes/hello.txt", b"hi".to_vec()).await.unwrap();
142
143        assert!(storage.exists("notes/hello.txt").await.unwrap());
144        assert_eq!(storage.get("notes/hello.txt").await.unwrap(), b"hi");
145        assert_eq!(storage.size("notes/hello.txt").await.unwrap(), 2);
146
147        storage.delete("notes/hello.txt").await.unwrap();
148        assert!(!storage.exists("notes/hello.txt").await.unwrap());
149    }
150
151    #[tokio::test]
152    async fn creates_missing_directories() {
153        let storage = storage("nested");
154
155        storage.put("a/b/c/deep.txt", b"x".to_vec()).await.unwrap();
156        assert_eq!(storage.get("a/b/c/deep.txt").await.unwrap(), b"x");
157    }
158
159    #[tokio::test]
160    async fn reading_something_absent_says_where_it_looked() {
161        let storage = storage("missing");
162
163        let error = storage.get("nope.txt").await.unwrap_err().to_string();
164        assert!(error.contains("nope.txt"));
165        assert!(error.contains("rustlavel-storage-missing"));
166    }
167
168    #[tokio::test]
169    async fn deleting_something_absent_is_not_an_error() {
170        assert!(storage("idempotent").delete("nope.txt").await.is_ok());
171    }
172
173    #[tokio::test]
174    async fn refuses_to_write_outside_the_root() {
175        let storage = storage("traversal");
176
177        assert!(storage.put("../escaped.txt", b"x".to_vec()).await.is_err());
178        assert!(storage.get("../../etc/passwd").await.is_err());
179    }
180
181    #[tokio::test]
182    async fn lists_a_directory_in_a_stable_order() {
183        let storage = storage("listing");
184        storage.put("docs/b.txt", b"b".to_vec()).await.unwrap();
185        storage.put("docs/a.txt", b"aa".to_vec()).await.unwrap();
186
187        let entries = storage.list("docs").await.unwrap();
188
189        assert_eq!(entries.len(), 2);
190        assert_eq!(entries[0].path, "docs/a.txt");
191        assert_eq!(entries[0].size, 2);
192        assert!(!entries[0].is_directory);
193    }
194
195    #[tokio::test]
196    async fn listing_a_missing_prefix_is_empty_rather_than_an_error() {
197        assert!(storage("empty-listing").list("nowhere").await.unwrap().is_empty());
198    }
199
200    #[test]
201    fn urls_are_only_produced_when_the_root_is_served() {
202        let storage = storage("urls");
203        assert_eq!(storage.url("a/b.png"), None);
204
205        let served = storage.served_at("https://cdn.example.com/files/");
206        assert_eq!(served.url("a/b.png").as_deref(), Some("https://cdn.example.com/files/a/b.png"));
207    }
208}