Skip to main content

mbr_core/vault/
mem.rs

1//! [`MemVault`]: a vault held entirely in memory.
2//!
3//! For tests above all — a repository built from a handful of
4//! `with_file(path, contents)` calls instead of a temp directory — and, because
5//! it has **no** [`Vault::local_path`], the proof that a consumer works without
6//! a real filesystem: every fast path that needs one has to fall back to
7//! reading through the vault here. It also models the two things a cloud-backed
8//! vault adds: files that are listed but not downloaded
9//! ([`MemVault::set_availability`]) and download requests
10//! ([`MemVault::download_requests`]).
11//!
12//! Always compiled (not test-only) so the integration tests of the other
13//! crates can build one; it is small, and unreferenced code is dropped at link
14//! time.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::io;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::time::SystemTime;
21
22use parking_lot::RwLock;
23
24use super::{Availability, Entry, EntryKind, Vault, VaultError, VaultPath};
25
26#[derive(Debug, Clone)]
27struct MemFile {
28    bytes: Arc<[u8]>,
29    modified: SystemTime,
30    created: SystemTime,
31    availability: Availability,
32}
33
34#[derive(Debug, Default)]
35struct MemState {
36    files: BTreeMap<VaultPath, MemFile>,
37    /// Every directory, including the root and every ancestor of every file.
38    dirs: BTreeSet<VaultPath>,
39    /// Directories whose listing is not on the device: listed by their
40    /// parent, but `list_dir` fails `NotDownloaded`.
41    unlisted_dirs: BTreeSet<VaultPath>,
42    downloads: Vec<VaultPath>,
43    /// When set, [`Vault::request_download`] only records the request: the
44    /// provider never delivers (offline, or a slow download).
45    downloads_held: bool,
46}
47
48impl MemState {
49    fn add_ancestors(&mut self, path: &VaultPath) {
50        let mut parent = path.parent();
51        while let Some(dir) = parent {
52            parent = dir.parent();
53            if !self.dirs.insert(dir) {
54                break;
55            }
56        }
57    }
58}
59
60/// An in-memory vault. Interior mutability, so it can be shared as
61/// `Arc<dyn Vault>` and still written to.
62#[derive(Debug)]
63pub struct MemVault {
64    root: PathBuf,
65    state: RwLock<MemState>,
66}
67
68impl Default for MemVault {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74/// Parses a test-supplied path, panicking on a bad one — these are fixture
75/// literals, so a bad one is a bug in the test.
76fn fixture_path(path: &str) -> VaultPath {
77    VaultPath::new(path).unwrap_or_else(|e| panic!("invalid MemVault path {path:?}: {e}"))
78}
79
80impl MemVault {
81    /// The default namespace root. Deliberately a path no real repository
82    /// would sit at, so a key that is accidentally opened as a file fails
83    /// loudly instead of reading something.
84    pub const DEFAULT_ROOT: &'static str = "/mbr-mem-vault";
85
86    /// An empty vault (just the root directory).
87    pub fn new() -> Self {
88        Self::with_root(Self::DEFAULT_ROOT)
89    }
90
91    /// An empty vault whose [`Vault::root`] is `root`.
92    pub fn with_root(root: impl Into<PathBuf>) -> Self {
93        let state = MemState {
94            dirs: BTreeSet::from([VaultPath::root()]),
95            ..MemState::default()
96        };
97        Self {
98            root: root.into(),
99            state: RwLock::new(state),
100        }
101    }
102
103    /// Builder form of [`Self::insert_file`].
104    #[must_use]
105    pub fn with_file(self, path: &str, contents: impl AsRef<[u8]>) -> Self {
106        self.insert_file(path, contents);
107        self
108    }
109
110    /// Builder form of [`Self::insert_dir`].
111    #[must_use]
112    pub fn with_dir(self, path: &str) -> Self {
113        self.insert_dir(path);
114        self
115    }
116
117    /// Creates or replaces a file (and its ancestor directories).
118    ///
119    /// # Panics
120    ///
121    /// When `path` is not a valid [`VaultPath`].
122    pub fn insert_file(&self, path: &str, contents: impl AsRef<[u8]>) {
123        let path = fixture_path(path);
124        let now = SystemTime::now();
125        let mut state = self.state.write();
126        state.add_ancestors(&path);
127        let created = state.files.get(&path).map_or(now, |f| f.created);
128        state.files.insert(
129            path,
130            MemFile {
131                bytes: Arc::from(contents.as_ref()),
132                modified: now,
133                created,
134                availability: Availability::Local,
135            },
136        );
137    }
138
139    /// Creates a directory (and its ancestors).
140    ///
141    /// # Panics
142    ///
143    /// When `path` is not a valid [`VaultPath`].
144    pub fn insert_dir(&self, path: &str) {
145        let path = fixture_path(path);
146        let mut state = self.state.write();
147        state.add_ancestors(&path);
148        state.dirs.insert(path);
149    }
150
151    /// Marks a file as listed-but-not-downloaded (or back).
152    ///
153    /// # Panics
154    ///
155    /// When there is no such file.
156    pub fn set_availability(&self, path: &str, availability: Availability) {
157        let path = fixture_path(path);
158        let mut state = self.state.write();
159        let file = state
160            .files
161            .get_mut(&path)
162            .unwrap_or_else(|| panic!("no MemVault file {path}"));
163        file.availability = availability;
164    }
165
166    /// Marks a directory as one whose *listing* is not downloaded (or back):
167    /// its parent lists it, `stat` finds it, but [`Vault::list_dir`] fails
168    /// [`VaultError::NotDownloaded`] — an iCloud Drive folder on iOS that
169    /// nothing has listed yet. Unlike a file's, a directory's listing does not
170    /// arrive on [`Vault::request_download`]: the request is recorded and the
171    /// test makes it arrive, as the provider would, some time later.
172    ///
173    /// # Panics
174    ///
175    /// When there is no such directory.
176    pub fn set_dir_availability(&self, path: &str, availability: Availability) {
177        let path = fixture_path(path);
178        let mut state = self.state.write();
179        assert!(state.dirs.contains(&path), "no MemVault directory {path}");
180        match availability {
181            Availability::NotDownloaded => state.unlisted_dirs.insert(path),
182            Availability::Local | Availability::Unknown => state.unlisted_dirs.remove(&path),
183        };
184    }
185
186    /// Sets a file's modification time.
187    ///
188    /// # Panics
189    ///
190    /// When there is no such file.
191    pub fn set_modified(&self, path: &str, modified: SystemTime) {
192        let path = fixture_path(path);
193        let mut state = self.state.write();
194        let file = state
195            .files
196            .get_mut(&path)
197            .unwrap_or_else(|| panic!("no MemVault file {path}"));
198        file.modified = modified;
199    }
200
201    /// A file's current bytes, for assertions.
202    pub fn contents(&self, path: &str) -> Option<Vec<u8>> {
203        let path = VaultPath::new(path).ok()?;
204        self.state.read().files.get(&path).map(|f| f.bytes.to_vec())
205    }
206
207    /// Whether a requested download stays pending (`true`) or completes at
208    /// once (`false`, the default). Models a provider that is offline or slow.
209    pub fn hold_downloads(&self, held: bool) {
210        self.state.write().downloads_held = held;
211    }
212
213    /// Every path passed to [`Vault::request_download`] so far, in order.
214    pub fn download_requests(&self) -> Vec<VaultPath> {
215        self.state.read().downloads.clone()
216    }
217
218    fn file_entry(path: &VaultPath, file: &MemFile) -> Entry {
219        Entry {
220            path: path.clone(),
221            kind: EntryKind::File,
222            size: file.bytes.len() as u64,
223            modified: file.modified,
224            created: Some(file.created),
225            availability: file.availability,
226        }
227    }
228
229    fn dir_entry(path: &VaultPath) -> Entry {
230        Entry {
231            path: path.clone(),
232            kind: EntryKind::Dir,
233            size: 0,
234            modified: SystemTime::UNIX_EPOCH,
235            created: None,
236            availability: Availability::Local,
237        }
238    }
239
240    fn readable(&self, path: &VaultPath) -> Result<Arc<[u8]>, VaultError> {
241        let state = self.state.read();
242        match state.files.get(path) {
243            Some(file) if file.availability == Availability::NotDownloaded => {
244                Err(VaultError::NotDownloaded {
245                    path: path.to_string(),
246                })
247            }
248            Some(file) => Ok(Arc::clone(&file.bytes)),
249            None if state.dirs.contains(path) => Err(VaultError::Io {
250                path: path.to_string(),
251                source: io::Error::new(io::ErrorKind::IsADirectory, "is a directory"),
252            }),
253            None => Err(VaultError::not_found(path)),
254        }
255    }
256}
257
258impl Vault for MemVault {
259    fn root(&self) -> &Path {
260        &self.root
261    }
262
263    fn stat(&self, path: &VaultPath) -> Result<Option<Entry>, VaultError> {
264        let state = self.state.read();
265        if let Some(file) = state.files.get(path) {
266            return Ok(Some(Self::file_entry(path, file)));
267        }
268        Ok(state.dirs.contains(path).then(|| Self::dir_entry(path)))
269    }
270
271    fn list_dir(&self, dir: &VaultPath) -> Result<Vec<Entry>, VaultError> {
272        let state = self.state.read();
273        if !state.dirs.contains(dir) {
274            return Err(if state.files.contains_key(dir) {
275                VaultError::Io {
276                    path: dir.to_string(),
277                    source: io::Error::new(io::ErrorKind::NotADirectory, "not a directory"),
278                }
279            } else {
280                VaultError::not_found(dir)
281            });
282        }
283        if state.unlisted_dirs.contains(dir) {
284            return Err(VaultError::NotDownloaded {
285                path: dir.to_string(),
286            });
287        }
288        let is_child = |p: &VaultPath| p.parent().as_ref() == Some(dir);
289        let dirs = state
290            .dirs
291            .iter()
292            .filter(|p| !p.is_root() && is_child(p))
293            .map(Self::dir_entry);
294        let files = state
295            .files
296            .iter()
297            .filter(|(p, _)| is_child(p))
298            .map(|(p, f)| Self::file_entry(p, f));
299        Ok(dirs.chain(files).collect())
300    }
301
302    fn canonicalize(&self, path: &VaultPath) -> Result<VaultPath, VaultError> {
303        let state = self.state.read();
304        if state.files.contains_key(path) || state.dirs.contains(path) {
305            Ok(path.clone())
306        } else {
307            Err(VaultError::not_found(path))
308        }
309    }
310
311    fn read(&self, path: &VaultPath) -> Result<Vec<u8>, VaultError> {
312        self.readable(path).map(|bytes| bytes.to_vec())
313    }
314
315    fn read_prefix(&self, path: &VaultPath, max_len: usize) -> Result<Vec<u8>, VaultError> {
316        self.readable(path)
317            .map(|bytes| bytes[..bytes.len().min(max_len)].to_vec())
318    }
319
320    fn availability(&self, path: &VaultPath) -> Availability {
321        let state = self.state.read();
322        match state.files.get(path) {
323            Some(file) => file.availability,
324            None if state.dirs.contains(path) => Availability::Local,
325            None => Availability::Unknown,
326        }
327    }
328
329    fn write_atomic(&self, path: &VaultPath, bytes: &[u8]) -> Result<(), VaultError> {
330        let mut state = self.state.write();
331        let parent = path.parent().ok_or_else(|| VaultError::Io {
332            path: path.to_string(),
333            source: io::Error::new(io::ErrorKind::IsADirectory, "is a directory"),
334        })?;
335        if !state.dirs.contains(&parent) {
336            return Err(VaultError::not_found(&parent));
337        }
338        if state.dirs.contains(path) {
339            return Err(VaultError::Io {
340                path: path.to_string(),
341                source: io::Error::new(io::ErrorKind::IsADirectory, "is a directory"),
342            });
343        }
344        let now = SystemTime::now();
345        let created = state.files.get(path).map_or(now, |f| f.created);
346        state.files.insert(
347            path.clone(),
348            MemFile {
349                bytes: Arc::from(bytes),
350                modified: now,
351                created,
352                availability: Availability::Local,
353            },
354        );
355        Ok(())
356    }
357
358    fn create_dir_all(&self, path: &VaultPath) -> Result<(), VaultError> {
359        let mut state = self.state.write();
360        if let Some(blocker) = path.ancestors().find(|a| state.files.contains_key(a)) {
361            return Err(VaultError::Io {
362                path: blocker.to_string(),
363                source: io::Error::new(io::ErrorKind::NotADirectory, "not a directory"),
364            });
365        }
366        state.add_ancestors(path);
367        state.dirs.insert(path.clone());
368        Ok(())
369    }
370
371    fn remove_file(&self, path: &VaultPath) -> Result<(), VaultError> {
372        self.state
373            .write()
374            .files
375            .remove(path)
376            .map(|_| ())
377            .ok_or_else(|| VaultError::not_found(path))
378    }
379
380    fn rename(&self, from: &VaultPath, to: &VaultPath) -> Result<(), VaultError> {
381        let mut state = self.state.write();
382        let parent = to.parent().ok_or_else(|| VaultError::not_found(to))?;
383        if !state.dirs.contains(&parent) {
384            return Err(VaultError::not_found(&parent));
385        }
386        let file = state
387            .files
388            .remove(from)
389            .ok_or_else(|| VaultError::not_found(from))?;
390        state.files.insert(to.clone(), file);
391        Ok(())
392    }
393
394    fn request_download(&self, paths: &[VaultPath]) {
395        let mut state = self.state.write();
396        let deliver = !state.downloads_held;
397        for path in paths {
398            if let Some(file) = state.files.get_mut(path).filter(|_| deliver) {
399                file.availability = Availability::Local;
400            }
401            state.downloads.push(path.clone());
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn vp(s: &str) -> VaultPath {
411        VaultPath::new(s).unwrap()
412    }
413
414    #[test]
415    fn files_imply_their_directories() {
416        let vault = MemVault::new().with_file("a/b/c.md", "# C");
417        assert!(vault.is_dir(&vp("a")));
418        assert!(vault.is_dir(&vp("a/b")));
419        assert!(vault.is_file(&vp("a/b/c.md")));
420        assert!(vault.is_dir(&VaultPath::root()));
421        assert_eq!(vault.stat(&vp("a/x")).unwrap(), None);
422    }
423
424    #[test]
425    fn list_dir_lists_immediate_children_only() {
426        let vault = MemVault::new()
427            .with_file("a/b/c.md", "c")
428            .with_file("a/d.md", "d")
429            .with_dir("a/empty");
430        let mut names: Vec<String> = vault
431            .list_dir(&vp("a"))
432            .unwrap()
433            .into_iter()
434            .map(|e| e.path.to_string())
435            .collect();
436        names.sort();
437        assert_eq!(names, vec!["a/b", "a/d.md", "a/empty"]);
438        let top: Vec<String> = vault
439            .list_dir(&VaultPath::root())
440            .unwrap()
441            .into_iter()
442            .map(|e| e.path.to_string())
443            .collect();
444        assert_eq!(top, vec!["a"]);
445        assert!(vault.list_dir(&vp("missing")).unwrap_err().is_not_found());
446        assert!(vault.list_dir(&vp("a/d.md")).is_err());
447    }
448
449    #[test]
450    fn reads_prefixes_and_utf8() {
451        let vault = MemVault::new().with_file("x.md", "hello");
452        assert_eq!(vault.read(&vp("x.md")).unwrap(), b"hello");
453        assert_eq!(vault.read_prefix(&vp("x.md"), 2).unwrap(), b"he");
454        assert_eq!(vault.read_prefix(&vp("x.md"), 99).unwrap(), b"hello");
455        assert_eq!(vault.read_to_string(&vp("x.md")).unwrap(), "hello");
456        assert!(vault.read(&vp("y.md")).unwrap_err().is_not_found());
457        assert_eq!(vault.local_path(&vp("x.md")), None);
458    }
459
460    #[test]
461    fn not_downloaded_files_are_listed_but_unreadable_until_requested() {
462        let vault = MemVault::new().with_file("cloud.md", "# Cloud");
463        vault.set_availability("cloud.md", Availability::NotDownloaded);
464        let entry = vault.stat(&vp("cloud.md")).unwrap().unwrap();
465        assert_eq!(entry.availability, Availability::NotDownloaded);
466        assert_eq!(entry.size, 7);
467        assert!(matches!(
468            vault.read(&vp("cloud.md")),
469            Err(VaultError::NotDownloaded { .. })
470        ));
471        vault.request_download(&[vp("cloud.md")]);
472        assert_eq!(vault.download_requests(), vec![vp("cloud.md")]);
473        assert_eq!(vault.read(&vp("cloud.md")).unwrap(), b"# Cloud");
474    }
475
476    #[test]
477    fn held_downloads_are_recorded_but_never_delivered() {
478        let vault = MemVault::new().with_file("cloud.md", "# Cloud");
479        vault.set_availability("cloud.md", Availability::NotDownloaded);
480        vault.hold_downloads(true);
481        vault.request_download(&[vp("cloud.md")]);
482        assert_eq!(vault.download_requests(), vec![vp("cloud.md")]);
483        assert!(matches!(
484            vault.read(&vp("cloud.md")),
485            Err(VaultError::NotDownloaded { .. })
486        ));
487        vault.hold_downloads(false);
488        vault.request_download(&[vp("cloud.md")]);
489        assert_eq!(vault.read(&vp("cloud.md")).unwrap(), b"# Cloud");
490    }
491
492    #[test]
493    fn write_atomic_semantics_match_the_local_vault() {
494        let vault = MemVault::new().with_file("docs/a.md", "old");
495        vault.write_atomic(&vp("docs/a.md"), b"new").unwrap();
496        assert_eq!(vault.contents("docs/a.md").unwrap(), b"new");
497        vault.write_atomic(&vp("docs/b.md"), b"b").unwrap();
498        assert!(vault.is_file(&vp("docs/b.md")));
499        // Parent must exist, as for `atomic_write`.
500        assert!(
501            vault
502                .write_atomic(&vp("nope/x.md"), b"x")
503                .unwrap_err()
504                .is_not_found()
505        );
506        // Cannot replace a directory.
507        assert!(vault.write_atomic(&vp("docs"), b"x").is_err());
508        vault.create_dir_all(&vp("nope/deeper")).unwrap();
509        vault.write_atomic(&vp("nope/deeper/x.md"), b"x").unwrap();
510        // A file in the way of a directory.
511        assert!(vault.create_dir_all(&vp("docs/a.md/sub")).is_err());
512    }
513
514    #[test]
515    fn rename_and_remove() {
516        let vault = MemVault::new().with_file("a.md", "a").with_dir("d");
517        vault.rename(&vp("a.md"), &vp("d/a.md")).unwrap();
518        assert!(vault.is_file(&vp("d/a.md")));
519        assert!(!vault.is_file(&vp("a.md")));
520        assert!(vault.rename(&vp("a.md"), &vp("d/b.md")).is_err());
521        assert!(vault.rename(&vp("d/a.md"), &vp("nope/a.md")).is_err());
522        vault.remove_file(&vp("d/a.md")).unwrap();
523        assert!(vault.remove_file(&vp("d/a.md")).is_err());
524    }
525
526    #[test]
527    fn canonicalize_is_identity_for_existing_paths() {
528        let vault = MemVault::new().with_file("a/b.md", "b");
529        assert_eq!(vault.canonicalize(&vp("a/b.md")).unwrap(), vp("a/b.md"));
530        assert_eq!(vault.canonicalize(&vp("a")).unwrap(), vp("a"));
531        assert!(
532            vault
533                .canonicalize(&vp("a/c.md"))
534                .unwrap_err()
535                .is_not_found()
536        );
537    }
538
539    #[test]
540    fn keys_live_under_the_virtual_root() {
541        let vault = MemVault::new();
542        let key = vault.key(&vp("a/b.md"));
543        assert!(key.starts_with(MemVault::DEFAULT_ROOT));
544        assert_eq!(vault.vault_path(&key).unwrap(), vp("a/b.md"));
545    }
546}