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().join(format!("rustlavel-storage-{name}"));
131 let _ = std::fs::remove_dir_all(&root);
132 std::fs::create_dir_all(&root).unwrap();
133 LocalStorage::new(root)
134 }
135
136 #[tokio::test]
137 async fn writes_reads_and_deletes() {
138 let storage = storage("roundtrip");
139
140 storage.put("notes/hello.txt", b"hi".to_vec()).await.unwrap();
141
142 assert!(storage.exists("notes/hello.txt").await.unwrap());
143 assert_eq!(storage.get("notes/hello.txt").await.unwrap(), b"hi");
144 assert_eq!(storage.size("notes/hello.txt").await.unwrap(), 2);
145
146 storage.delete("notes/hello.txt").await.unwrap();
147 assert!(!storage.exists("notes/hello.txt").await.unwrap());
148 }
149
150 #[tokio::test]
151 async fn creates_missing_directories() {
152 let storage = storage("nested");
153
154 storage.put("a/b/c/deep.txt", b"x".to_vec()).await.unwrap();
155 assert_eq!(storage.get("a/b/c/deep.txt").await.unwrap(), b"x");
156 }
157
158 #[tokio::test]
159 async fn reading_something_absent_says_where_it_looked() {
160 let storage = storage("missing");
161
162 let error = storage.get("nope.txt").await.unwrap_err().to_string();
163 assert!(error.contains("nope.txt"));
164 assert!(error.contains("rustlavel-storage-missing"));
165 }
166
167 #[tokio::test]
168 async fn deleting_something_absent_is_not_an_error() {
169 assert!(storage("idempotent").delete("nope.txt").await.is_ok());
170 }
171
172 #[tokio::test]
173 async fn refuses_to_write_outside_the_root() {
174 let storage = storage("traversal");
175
176 assert!(storage.put("../escaped.txt", b"x".to_vec()).await.is_err());
177 assert!(storage.get("../../etc/passwd").await.is_err());
178 }
179
180 #[tokio::test]
181 async fn lists_a_directory_in_a_stable_order() {
182 let storage = storage("listing");
183 storage.put("docs/b.txt", b"b".to_vec()).await.unwrap();
184 storage.put("docs/a.txt", b"aa".to_vec()).await.unwrap();
185
186 let entries = storage.list("docs").await.unwrap();
187
188 assert_eq!(entries.len(), 2);
189 assert_eq!(entries[0].path, "docs/a.txt");
190 assert_eq!(entries[0].size, 2);
191 assert!(!entries[0].is_directory);
192 }
193
194 #[tokio::test]
195 async fn listing_a_missing_prefix_is_empty_rather_than_an_error() {
196 assert!(storage("empty-listing").list("nowhere").await.unwrap().is_empty());
197 }
198
199 #[test]
200 fn urls_are_only_produced_when_the_root_is_served() {
201 let storage = storage("urls");
202 assert_eq!(storage.url("a/b.png"), None);
203
204 let served = storage.served_at("https://cdn.example.com/files/");
205 assert_eq!(served.url("a/b.png").as_deref(), Some("https://cdn.example.com/files/a/b.png"));
206 }
207}