1use std::collections::BTreeMap;
7use std::io;
8use std::path::{Component, Path, PathBuf};
9use std::sync::{Mutex, PoisonError};
10use std::time::SystemTime;
11
12use crate::atomic::atomic_write;
13
14pub trait ConfigStore: Send + Sync {
18 fn read(&self, name: &str) -> Option<String>;
20 fn write(&self, name: &str, data: &str) -> io::Result<()>;
22 fn remove(&self, name: &str) -> io::Result<()>;
24 fn list(&self, dir: &str) -> Vec<String>;
26}
27
28pub trait FileOps: Send + Sync {
30 fn read(&self, path: &Path) -> io::Result<Vec<u8>>;
31 fn write(&self, path: &Path, data: &[u8]) -> io::Result<()>;
33 fn remove(&self, path: &Path) -> io::Result<()>;
35 fn list(&self, dir: &Path) -> Vec<(String, SystemTime)>;
37 fn is_file(&self, path: &Path) -> bool;
38}
39
40pub fn check_name(name: &str) -> io::Result<()> {
42 let bad = || io::Error::new(io::ErrorKind::InvalidInput, format!("invalid config name `{name}`"));
43 if name.is_empty() || name.contains('\\') || name.contains('\0') {
44 return Err(bad());
45 }
46 for part in name.split('/') {
47 if part.is_empty() || part == "." || part == ".." {
48 return Err(bad());
49 }
50 }
51 if Path::new(name).components().any(|c| !matches!(c, Component::Normal(_))) {
52 return Err(bad());
53 }
54 Ok(())
55}
56
57fn not_found(e: io::Error) -> io::Result<()> {
58 if e.kind() == io::ErrorKind::NotFound { Ok(()) } else { Err(e) }
59}
60
61#[derive(Clone, Debug)]
65pub struct DirConfig {
66 pub dir: PathBuf,
67}
68
69impl DirConfig {
70 pub fn new(dir: impl Into<PathBuf>) -> DirConfig {
71 DirConfig { dir: dir.into() }
72 }
73}
74
75impl ConfigStore for DirConfig {
76 fn read(&self, name: &str) -> Option<String> {
77 check_name(name).ok()?;
78 std::fs::read_to_string(self.dir.join(name)).ok()
79 }
80 fn write(&self, name: &str, data: &str) -> io::Result<()> {
81 check_name(name)?;
82 StdFiles.write(&self.dir.join(name), data.as_bytes())
83 }
84 fn remove(&self, name: &str) -> io::Result<()> {
85 check_name(name)?;
86 StdFiles.remove(&self.dir.join(name))
87 }
88 fn list(&self, dir: &str) -> Vec<String> {
89 let path = if dir.is_empty() {
90 self.dir.clone()
91 } else {
92 if check_name(dir).is_err() {
93 return vec![];
94 }
95 self.dir.join(dir)
96 };
97 StdFiles.list(&path).into_iter().map(|(n, _)| n).collect()
98 }
99}
100
101#[derive(Clone, Copy, Debug, Default)]
103pub struct StdFiles;
104
105impl FileOps for StdFiles {
106 fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
107 std::fs::read(path)
108 }
109 fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
110 if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
111 std::fs::create_dir_all(d)?;
112 }
113 atomic_write(path, data)
114 }
115 fn remove(&self, path: &Path) -> io::Result<()> {
116 std::fs::remove_file(path).or_else(not_found)
117 }
118 fn list(&self, dir: &Path) -> Vec<(String, SystemTime)> {
119 let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
120 let mut v: Vec<(String, SystemTime)> = rd
121 .flatten()
122 .filter_map(|e| {
123 let m = e.metadata().ok().filter(|m| m.is_file())?;
124 let name = e.file_name().into_string().ok()?;
125 Some((name, m.modified().unwrap_or(SystemTime::UNIX_EPOCH)))
126 })
127 .filter(|(n, _)| !crate::atomic::is_temp_name(n))
128 .collect();
129 v.sort_by(|a, b| a.0.cmp(&b.0));
130 v
131 }
132 fn is_file(&self, path: &Path) -> bool {
133 path.is_file()
134 }
135}
136
137#[derive(Debug, Default)]
141pub struct MemoryConfig {
142 files: Mutex<BTreeMap<String, String>>,
143}
144
145impl MemoryConfig {
146 pub fn new() -> MemoryConfig {
147 MemoryConfig::default()
148 }
149}
150
151impl ConfigStore for MemoryConfig {
152 fn read(&self, name: &str) -> Option<String> {
153 self.files.lock().unwrap_or_else(PoisonError::into_inner).get(name).cloned()
154 }
155 fn write(&self, name: &str, data: &str) -> io::Result<()> {
156 check_name(name)?;
157 self.files.lock().unwrap_or_else(PoisonError::into_inner).insert(name.to_string(), data.to_string());
158 Ok(())
159 }
160 fn remove(&self, name: &str) -> io::Result<()> {
161 self.files.lock().unwrap_or_else(PoisonError::into_inner).remove(name);
162 Ok(())
163 }
164 fn list(&self, dir: &str) -> Vec<String> {
165 let pre = if dir.is_empty() { String::new() } else { format!("{}/", dir.trim_end_matches('/')) };
166 let m = self.files.lock().unwrap_or_else(PoisonError::into_inner);
167 m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect()
168 }
169}
170
171#[derive(Debug, Default)]
173pub struct MemFiles {
174 files: Mutex<BTreeMap<PathBuf, (Vec<u8>, SystemTime)>>,
175}
176
177impl MemFiles {
178 pub fn new() -> MemFiles {
179 MemFiles::default()
180 }
181}
182
183impl FileOps for MemFiles {
184 fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
185 let m = self.files.lock().unwrap_or_else(PoisonError::into_inner);
186 m.get(path).map(|f| f.0.clone()).ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, path.display().to_string()))
187 }
188 fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
189 let now = now_or_epoch();
190 self.files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (data.to_vec(), now));
191 Ok(())
192 }
193 fn remove(&self, path: &Path) -> io::Result<()> {
194 self.files.lock().unwrap_or_else(PoisonError::into_inner).remove(path);
195 Ok(())
196 }
197 fn list(&self, dir: &Path) -> Vec<(String, SystemTime)> {
198 let m = self.files.lock().unwrap_or_else(PoisonError::into_inner);
199 m.iter().filter(|(p, _)| p.parent() == Some(dir)).filter_map(|(p, f)| Some((p.file_name()?.to_str()?.to_string(), f.1))).collect()
200 }
201 fn is_file(&self, path: &Path) -> bool {
202 self.files.lock().unwrap_or_else(PoisonError::into_inner).contains_key(path)
203 }
204}
205
206pub(crate) fn now_or_epoch() -> SystemTime {
209 #[cfg(not(target_arch = "wasm32"))]
210 return SystemTime::now();
211 #[cfg(target_arch = "wasm32")]
212 SystemTime::UNIX_EPOCH
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn tmpdir(tag: &str) -> tempfile::TempDir {
220 tempfile::Builder::new().prefix(&format!("ashlar-backend-{tag}-")).tempdir().unwrap()
221 }
222
223 fn exercise(s: &dyn ConfigStore) {
224 assert_eq!(s.read("a.json"), None);
225 s.write("a.json", "1").unwrap();
226 s.write("sub/b.json", "2").unwrap();
227 s.write("sub/c.json", "3").unwrap();
228 assert_eq!(s.read("a.json").as_deref(), Some("1"));
229 assert_eq!(s.list(""), vec!["a.json"]);
230 assert_eq!(s.list("sub"), vec!["b.json", "c.json"]);
231 s.remove("sub/b.json").unwrap();
232 s.remove("sub/b.json").unwrap();
233 assert_eq!(s.list("sub"), vec!["c.json"]);
234 for bad in ["", "../x", "/etc/passwd", "a/../../x", "a//b", "./a", "a\\b", "a/."] {
235 assert!(s.write(bad, "x").is_err(), "{bad}");
236 assert_eq!(s.read(bad), None);
237 }
238 }
239
240 #[test]
241 fn memory_config() {
242 exercise(&MemoryConfig::new());
243 }
244
245 #[test]
246 fn dir_config() {
247 let tmp = tmpdir("cfg");
248 let d = tmp.path();
249 exercise(&DirConfig::new(d));
250 assert!(!d.parent().unwrap().join("x").exists());
251 }
252
253 fn exercise_files(f: &dyn FileOps, root: &Path) {
254 let p = root.join("dir/a.bin");
255 assert!(!f.is_file(&p));
256 assert!(f.read(&p).is_err());
257 f.write(&p, b"xy").unwrap();
258 assert!(f.is_file(&p));
259 assert_eq!(f.read(&p).unwrap(), b"xy");
260 f.write(&root.join("dir/b.bin"), b"z").unwrap();
261 let names: Vec<String> = f.list(&root.join("dir")).into_iter().map(|e| e.0).collect();
262 assert_eq!(names, vec!["a.bin", "b.bin"]);
263 f.remove(&p).unwrap();
264 f.remove(&p).unwrap();
265 assert!(!f.is_file(&p));
266 assert!(f.list(&root.join("missing")).is_empty());
267 }
268
269 #[test]
270 fn mem_files() {
271 exercise_files(&MemFiles::new(), Path::new("/virtual"));
272 }
273
274 #[test]
275 fn std_files() {
276 let tmp = tmpdir("files");
277 let d = tmp.path();
278 exercise_files(&StdFiles, d);
279 }
280}