Skip to main content

ashlar_fs/
backend.rs

1//! Where small persistent files live. Code that keeps settings, keymaps or a session sentinel sees
2//! only these traits; the app picks the backend: a folder on disk ([`DirConfig`], [`StdFiles`]),
3//! memory for tests and headless runs ([`MemoryConfig`], [`MemFiles`]), or browser storage (OPFS)
4//! on the web, added later as another implementation.
5
6use 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
14/// Named text blobs (`settings.json`, `keymap.json`, `session.lock`). Names are relative,
15/// `/`-separated, and never climb out of the store (`..`, absolute paths and empty components are
16/// refused).
17pub trait ConfigStore: Send + Sync {
18    /// The blob, or `None` when it doesn't exist or can't be read.
19    fn read(&self, name: &str) -> Option<String>;
20    /// Replace the blob atomically.
21    fn write(&self, name: &str, data: &str) -> io::Result<()>;
22    /// Remove the blob; removing a missing blob is not an error.
23    fn remove(&self, name: &str) -> io::Result<()>;
24    /// Names of the blobs directly in `dir` (`""` for the top level), sorted.
25    fn list(&self, dir: &str) -> Vec<String>;
26}
27
28/// The file operations autosave and recovery need, by path.
29pub trait FileOps: Send + Sync {
30    fn read(&self, path: &Path) -> io::Result<Vec<u8>>;
31    /// Write `data` to `path` atomically, creating its folder.
32    fn write(&self, path: &Path, data: &[u8]) -> io::Result<()>;
33    /// Remove a file; removing a missing file is not an error.
34    fn remove(&self, path: &Path) -> io::Result<()>;
35    /// Files directly in `dir`: (file name, modified time), sorted by name.
36    fn list(&self, dir: &Path) -> Vec<(String, SystemTime)>;
37    fn is_file(&self, path: &Path) -> bool;
38}
39
40/// Check a [`ConfigStore`] name: relative, no `..`, no empty or `.` components.
41pub 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// ------------------------------------------------------------------ disk
62
63/// Blobs as files in one directory, written atomically.
64#[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/// [`FileOps`] on the file system.
102#[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// ------------------------------------------------------------------ memory
138
139/// Blobs in memory (tests, headless sessions).
140#[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/// Files in memory, keyed by path (tests; a stand-in for browser storage).
172#[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
206/// `SystemTime::now()`, or the epoch where there is no clock (`wasm32-unknown-unknown` panics on
207/// `now()`).
208pub(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}