rustlavel_storage/
local.rs1use crate::{Entry, Storage, Visibility, normalize};
4use rustlavel_core::{Error, Result};
5use std::path::{Path, PathBuf};
6
7#[derive(Clone)]
9pub struct LocalStorage {
10 root: PathBuf,
11 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 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 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 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 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 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}