Skip to main content

mbr_core/vault/
local.rs

1//! [`LocalVault`]: a vault that is a directory on the local filesystem.
2//!
3//! This is the desktop's storage, and it is meant to behave exactly as the
4//! `std::fs` code it replaced: symlinks are followed on every read and listing,
5//! containment is decided by canonicalizing ([`Vault::canonicalize`]), and
6//! writes are temp-file-plus-rename ([`atomic_write`]).
7
8use std::fs::{self, File};
9use std::io::{self, Read, Seek, SeekFrom, Write};
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::SystemTime;
14
15use super::mount::{Mount, MountPolicy, MountTable};
16use super::{Availability, Entry, EntryKind, Vault, VaultError, VaultPath};
17
18/// Decides a file's [`Availability`] from the metadata a listing or `stat`
19/// already fetched, so knowing it costs no extra system call.
20///
21/// The hook that lets a platform crate teach a [`LocalVault`] about files that
22/// are listed but not downloaded (Apple's dataless files, `SF_DATALESS` in
23/// `st_flags`) without a platform `cfg` in this crate. See
24/// [`LocalVault::with_availability_probe`].
25pub type AvailabilityProbe = fn(&fs::Metadata) -> Availability;
26
27/// A repository directory on the local filesystem.
28#[derive(Debug, Clone)]
29pub struct LocalVault {
30    /// Canonicalized at construction — see [`LocalVault::new`].
31    root: PathBuf,
32    /// Read-only symlink mounts ([`super::mount`]), when enabled with
33    /// [`LocalVault::with_mounts`]. `None` keeps the original rule: a link
34    /// out of the root is [`VaultError::OutsideRoot`], full stop.
35    mounts: Option<Arc<MountTable>>,
36    /// Classifies each listed or stat'ed file; `None` (the desktop) reports
37    /// every file [`Availability::Local`], and [`Vault::availability`] does no
38    /// I/O at all.
39    availability_probe: Option<AvailabilityProbe>,
40}
41
42impl LocalVault {
43    /// A vault rooted at `root`, **canonicalized** when it exists.
44    ///
45    /// Canonical because every path this vault produces is relativized against
46    /// it: `canonicalize` on a child returns a canonical path, and on macOS a
47    /// temp dir (`/var/…`) or on Windows any path (`\\?\C:\…`) differs from its
48    /// canonical spelling, which would make every child look like it is outside
49    /// the root. Falls back to the path as given when it cannot be
50    /// canonicalized (it does not exist yet); every operation then fails with
51    /// the real error rather than here.
52    pub fn new(root: impl Into<PathBuf>) -> Self {
53        let root = root.into();
54        Self {
55            root: root.canonicalize().unwrap_or(root),
56            mounts: None,
57            availability_probe: None,
58        }
59    }
60
61    /// Classifies files with `probe` (see [`AvailabilityProbe`]) in every
62    /// [`Entry`] this vault produces and in [`Vault::availability`].
63    #[must_use]
64    pub fn with_availability_probe(mut self, probe: AvailabilityProbe) -> Self {
65        self.availability_probe = Some(probe);
66        self
67    }
68
69    /// A vault rooted at `root` (canonicalized, as [`Self::new`]) that serves
70    /// links out of it as read-only **mounts** under `policy` — see
71    /// [`super::mount`]. This is what the repository and its external static
72    /// overlay are built with ([`crate::repo::Repo::init`]).
73    pub fn with_mounts(root: impl Into<PathBuf>, policy: MountPolicy) -> Self {
74        Self {
75            mounts: Some(Arc::new(MountTable::new(policy))),
76            ..Self::new(root)
77        }
78    }
79
80    /// Refuses a write to `path` that would land in a mount or outside the
81    /// vault.
82    ///
83    /// Judged on the deepest **existing** ancestor's canonical path as well as
84    /// on `path` as spelled: a write reaching a mount through some other link
85    /// (`alias/videos/x` with `alias -> static`) is caught too, and so is one
86    /// through a link the vault has not discovered yet (canonicalizing is what
87    /// discovers it). Callers already resolve their targets this way; this is
88    /// the vault's own guarantee rather than theirs. One `canonicalize` per
89    /// write — writes are rare, reads never pay it.
90    fn check_writable(&self, path: &VaultPath) -> Result<(), VaultError> {
91        let existing = path
92            .ancestors()
93            .find(|ancestor| fs::symlink_metadata(self.native(ancestor)).is_ok())
94            .unwrap_or_default();
95        let canonical = match self.canonicalize(&existing) {
96            Ok(canonical) => canonical,
97            // A dangling link, or a race with a delete: the write itself
98            // reports whatever it hits.
99            Err(VaultError::NotFound { .. }) => return Ok(()),
100            Err(e) => return Err(e),
101        };
102        if self.is_read_only(path) || self.is_read_only(&canonical) {
103            return Err(VaultError::ReadOnly {
104                path: path.to_string(),
105            });
106        }
107        Ok(())
108    }
109
110    /// The native path for `path`.
111    pub fn native(&self, path: &VaultPath) -> PathBuf {
112        path.to_native(&self.root)
113    }
114
115    fn entry(&self, path: VaultPath, metadata: &fs::Metadata) -> Entry {
116        let availability = match self.availability_probe {
117            Some(probe) if !metadata.is_dir() => probe(metadata),
118            _ => Availability::Local,
119        };
120        Entry {
121            path,
122            kind: if metadata.is_dir() {
123                EntryKind::Dir
124            } else {
125                EntryKind::File
126            },
127            size: metadata.len(),
128            modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
129            created: metadata.created().ok(),
130            availability,
131        }
132    }
133}
134
135impl Vault for LocalVault {
136    fn root(&self) -> &Path {
137        &self.root
138    }
139
140    fn stat(&self, path: &VaultPath) -> Result<Option<Entry>, VaultError> {
141        match fs::metadata(self.native(path)) {
142            Ok(metadata) => Ok(Some(self.entry(path.clone(), &metadata))),
143            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
144            Err(e) => Err(VaultError::from_io(path, e)),
145        }
146    }
147
148    fn list_dir(&self, dir: &VaultPath) -> Result<Vec<Entry>, VaultError> {
149        let native = self.native(dir);
150        let entries = fs::read_dir(&native).map_err(|e| VaultError::from_io(dir, e))?;
151        Ok(entries
152            .filter_map(|entry| {
153                let entry = entry.ok()?;
154                let name = entry.file_name();
155                let Some(name) = name.to_str() else {
156                    tracing::debug!("Skipping non-UTF-8 name in {}", native.display());
157                    return None;
158                };
159                let path = match dir.child(name) {
160                    Ok(path) => path,
161                    Err(e) => {
162                        tracing::debug!("Skipping {:?} in {}: {e}", name, native.display());
163                        return None;
164                    }
165                };
166                // `fs::metadata` follows symlinks, as `WalkDir::follow_links`
167                // did. A broken link fails here and is skipped, as `WalkDir`'s
168                // error entries were.
169                let metadata = fs::metadata(entry.path()).ok()?;
170                Some(self.entry(path, &metadata))
171            })
172            .collect())
173    }
174
175    fn canonicalize(&self, path: &VaultPath) -> Result<VaultPath, VaultError> {
176        let canonical = self
177            .native(path)
178            .canonicalize()
179            .map_err(|e| VaultError::from_io(path, e))?;
180        // The common case, and the only one a vault without links out ever
181        // takes: one `realpath`, no lock, no table.
182        if canonical.starts_with(&self.root) {
183            return VaultPath::from_absolute(&self.root, &canonical).map_err(|_| {
184                VaultError::OutsideRoot {
185                    path: canonical.display().to_string(),
186                }
187            });
188        }
189        match &self.mounts {
190            Some(table) => table.resolve(&self.root, |p| self.native(p), path, canonical),
191            None => Err(VaultError::OutsideRoot {
192                path: canonical.display().to_string(),
193            }),
194        }
195    }
196
197    fn read(&self, path: &VaultPath) -> Result<Vec<u8>, VaultError> {
198        fs::read(self.native(path)).map_err(|e| VaultError::from_io(path, e))
199    }
200
201    fn read_prefix(&self, path: &VaultPath, max_len: usize) -> Result<Vec<u8>, VaultError> {
202        read_prefix_native(&self.native(path), max_len).map_err(|e| VaultError::from_io(path, e))
203    }
204
205    fn read_range(&self, path: &VaultPath, offset: u64, len: usize) -> Result<Vec<u8>, VaultError> {
206        read_range_native(&self.native(path), offset, len).map_err(|e| VaultError::from_io(path, e))
207    }
208
209    fn availability(&self, path: &VaultPath) -> Availability {
210        match self.availability_probe {
211            None => Availability::Local,
212            Some(probe) => match fs::metadata(self.native(path)) {
213                Ok(metadata) if metadata.is_dir() => Availability::Local,
214                Ok(metadata) => probe(&metadata),
215                Err(_) => Availability::Unknown,
216            },
217        }
218    }
219
220    fn write_atomic(&self, path: &VaultPath, bytes: &[u8]) -> Result<(), VaultError> {
221        self.check_writable(path)?;
222        atomic_write(&self.native(path), bytes).map_err(|e| VaultError::from_io(path, e))
223    }
224
225    fn create_dir_all(&self, path: &VaultPath) -> Result<(), VaultError> {
226        self.check_writable(path)?;
227        fs::create_dir_all(self.native(path)).map_err(|e| VaultError::from_io(path, e))
228    }
229
230    fn remove_file(&self, path: &VaultPath) -> Result<(), VaultError> {
231        self.check_writable(path)?;
232        fs::remove_file(self.native(path)).map_err(|e| VaultError::from_io(path, e))
233    }
234
235    fn rename(&self, from: &VaultPath, to: &VaultPath) -> Result<(), VaultError> {
236        self.check_writable(from)?;
237        self.check_writable(to)?;
238        fs::rename(self.native(from), self.native(to)).map_err(|e| VaultError::from_io(from, e))
239    }
240
241    fn local_path(&self, path: &VaultPath) -> Option<PathBuf> {
242        Some(self.native(path))
243    }
244
245    fn is_read_only(&self, path: &VaultPath) -> bool {
246        self.mounts
247            .as_ref()
248            .is_some_and(|table| table.contains(path))
249    }
250
251    fn mounts(&self) -> Vec<Mount> {
252        self.mounts
253            .as_ref()
254            .map(|table| table.snapshot())
255            .unwrap_or_default()
256    }
257}
258
259/// At most the first `max_len` bytes of the file at `path`.
260///
261/// Sized from the open handle's metadata, so a short file costs one exact read
262/// and no second path lookup — the frontmatter scan does this for every
263/// markdown file in the repository.
264pub fn read_prefix_native(path: &Path, max_len: usize) -> io::Result<Vec<u8>> {
265    let mut file = File::open(path)?;
266    let len = file.metadata().map(|m| m.len() as usize).unwrap_or(0);
267    let mut buffer = vec![0u8; len.min(max_len)];
268    file.read_exact(&mut buffer)?;
269    Ok(buffer)
270}
271
272/// At most `len` bytes of the file at `path`, starting at `offset`; fewer at
273/// the end of the file, none past it.
274pub fn read_range_native(path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
275    let mut file = File::open(path)?;
276    file.seek(SeekFrom::Start(offset))?;
277    let mut buffer = Vec::with_capacity(len.min(1 << 20));
278    file.take(len as u64).read_to_end(&mut buffer)?;
279    Ok(buffer)
280}
281
282/// Creates a fresh temp file beside a write target, for a write-then-rename.
283///
284/// The name is hidden (leading dot, so the scanner and watcher skip it),
285/// derived from the target's, and unique per process and call — `create_new`
286/// guarantees no two writers ever share one, which a fixed `.{name}.mbr-tmp`
287/// did not: concurrent writers truncated each other's temp file and the loser's
288/// rename failed with `ENOENT`.
289pub fn create_unique_temp_file(dir: &Path, file_name: &str) -> io::Result<(PathBuf, File)> {
290    static COUNTER: AtomicU64 = AtomicU64::new(0);
291    // A leftover from a crashed process can only collide on a reused pid;
292    // move past it rather than fail.
293    const ATTEMPTS: u32 = 16;
294    let pid = std::process::id();
295    let mut last_error = None;
296    for _ in 0..ATTEMPTS {
297        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
298        let path = dir.join(format!(".{file_name}.{pid}-{n}.mbr-tmp"));
299        match fs::OpenOptions::new()
300            .write(true)
301            .create_new(true)
302            .open(&path)
303        {
304            Ok(file) => return Ok((path, file)),
305            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last_error = Some(e),
306            Err(e) => return Err(e),
307        }
308    }
309    Err(last_error.unwrap_or_else(|| io::Error::other("no free temp file name")))
310}
311
312/// Atomically writes `bytes` to `path` (temp file in the same dir + rename).
313///
314/// The temp file has a name of its own (see [`create_unique_temp_file`]), and
315/// when `path` already exists it takes over that file's permissions — a rename
316/// replaces the inode, and a note that was `0600` must not come back `0644`.
317/// The temp file is removed on any failure. Blocking.
318///
319/// Does not lock: callers that read before writing hold a per-file lock across
320/// both (`mbr_server::file_write::FileWriteLocks`).
321pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
322    let parent = path.parent().unwrap_or_else(|| Path::new("."));
323    let file_name = path
324        .file_name()
325        .and_then(|n| n.to_str())
326        .unwrap_or("file.md");
327    let (tmp, mut file) = create_unique_temp_file(parent, file_name)?;
328    let written = file
329        .write_all(bytes)
330        .and_then(|()| match fs::metadata(path) {
331            Ok(existing) => file.set_permissions(existing.permissions()),
332            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
333            Err(e) => Err(e),
334        })
335        .and_then(|()| {
336            drop(file);
337            fs::rename(&tmp, path)
338        });
339    if written.is_err() {
340        let _ = fs::remove_file(&tmp);
341    }
342    written
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn vp(s: &str) -> VaultPath {
350        VaultPath::new(s).unwrap()
351    }
352
353    fn fixture() -> (tempfile::TempDir, LocalVault) {
354        let dir = tempfile::tempdir().expect("temp dir");
355        fs::create_dir_all(dir.path().join("docs/sub")).unwrap();
356        fs::write(dir.path().join("docs/a.md"), "# A\n").unwrap();
357        fs::write(dir.path().join("docs/sub/b.md"), "# B\n").unwrap();
358        fs::write(dir.path().join("top.txt"), "top").unwrap();
359        let vault = LocalVault::new(dir.path());
360        (dir, vault)
361    }
362
363    #[test]
364    fn root_is_canonical() {
365        let (dir, vault) = fixture();
366        assert_eq!(vault.root(), dir.path().canonicalize().unwrap());
367    }
368
369    #[test]
370    fn stat_reports_kind_size_and_absence() {
371        let (_dir, vault) = fixture();
372        let a = vault.stat(&vp("docs/a.md")).unwrap().unwrap();
373        assert!(a.is_file());
374        assert_eq!(a.size, 4);
375        assert_eq!(a.availability, Availability::Local);
376        assert!(vault.stat(&vp("docs")).unwrap().unwrap().is_dir());
377        assert!(vault.stat(&VaultPath::root()).unwrap().unwrap().is_dir());
378        assert_eq!(vault.stat(&vp("missing.md")).unwrap(), None);
379        assert!(vault.is_file(&vp("top.txt")));
380        assert!(vault.is_dir(&vp("docs/sub")));
381        assert!(!vault.is_dir(&vp("top.txt")));
382    }
383
384    #[test]
385    fn list_dir_returns_children_with_their_kinds() {
386        let (_dir, vault) = fixture();
387        let mut names: Vec<(String, EntryKind)> = vault
388            .list_dir(&vp("docs"))
389            .unwrap()
390            .into_iter()
391            .map(|e| (e.path.to_string(), e.kind))
392            .collect();
393        names.sort();
394        assert_eq!(
395            names,
396            vec![
397                ("docs/a.md".to_string(), EntryKind::File),
398                ("docs/sub".to_string(), EntryKind::Dir),
399            ]
400        );
401        assert!(vault.list_dir(&vp("missing")).unwrap_err().is_not_found());
402    }
403
404    #[test]
405    fn reads_whole_files_and_prefixes() {
406        let (_dir, vault) = fixture();
407        assert_eq!(vault.read(&vp("docs/a.md")).unwrap(), b"# A\n");
408        assert_eq!(vault.read_to_string(&vp("docs/a.md")).unwrap(), "# A\n");
409        assert_eq!(vault.read_prefix(&vp("docs/a.md"), 2).unwrap(), b"# ");
410        assert_eq!(vault.read_prefix(&vp("docs/a.md"), 100).unwrap(), b"# A\n");
411        assert!(vault.read(&vp("missing")).unwrap_err().is_not_found());
412    }
413
414    #[test]
415    fn read_range_reads_a_window_and_stops_at_the_end() {
416        let (_dir, vault) = fixture();
417        let path = vp("top.txt");
418        assert_eq!(vault.read_range(&path, 1, 2).unwrap(), b"op");
419        assert_eq!(vault.read_range(&path, 2, 100).unwrap(), b"p");
420        assert!(vault.read_range(&path, 10, 5).unwrap().is_empty());
421        assert!(
422            vault
423                .read_range(&vp("nope"), 0, 1)
424                .unwrap_err()
425                .is_not_found()
426        );
427    }
428
429    #[test]
430    fn availability_is_local_without_a_probe_and_probed_with_one() {
431        let (dir, vault) = fixture();
432        assert_eq!(vault.availability(&vp("top.txt")), Availability::Local);
433        let probed = LocalVault::new(dir.path()).with_availability_probe(|metadata| {
434            if metadata.len() == 3 {
435                Availability::NotDownloaded
436            } else {
437                Availability::Local
438            }
439        });
440        assert_eq!(
441            probed.availability(&vp("top.txt")),
442            Availability::NotDownloaded
443        );
444        assert_eq!(probed.availability(&vp("docs/a.md")), Availability::Local);
445        assert_eq!(probed.availability(&vp("docs")), Availability::Local);
446        assert_eq!(probed.availability(&vp("missing")), Availability::Unknown);
447        let listed = probed.list_dir(&VaultPath::root()).unwrap();
448        let top = listed.iter().find(|e| e.name() == "top.txt").unwrap();
449        assert_eq!(top.availability, Availability::NotDownloaded);
450        assert_eq!(
451            probed.stat(&vp("top.txt")).unwrap().unwrap().availability,
452            Availability::NotDownloaded
453        );
454    }
455
456    #[test]
457    fn invalid_utf8_is_invalid_data() {
458        let (dir, vault) = fixture();
459        fs::write(dir.path().join("bin.md"), [0xff, 0xfe, 0x00]).unwrap();
460        let err = vault.read_to_string(&vp("bin.md")).unwrap_err();
461        assert_eq!(io::Error::from(err).kind(), io::ErrorKind::InvalidData);
462    }
463
464    #[test]
465    fn local_path_and_keys_are_the_native_paths() {
466        let (_dir, vault) = fixture();
467        let native = vault.root().join("docs").join("a.md");
468        assert_eq!(vault.local_path(&vp("docs/a.md")), Some(native.clone()));
469        assert_eq!(vault.key(&vp("docs/a.md")), native);
470        assert_eq!(vault.vault_path(&native).unwrap(), vp("docs/a.md"));
471        let outside = std::env::temp_dir().join("elsewhere.md");
472        assert!(matches!(
473            vault.vault_path(&outside),
474            Err(VaultError::OutsideRoot { .. })
475        ));
476    }
477
478    #[test]
479    fn write_atomic_replaces_creates_and_leaves_no_temp_files() {
480        let (dir, vault) = fixture();
481        vault.write_atomic(&vp("docs/a.md"), b"# A2\n").unwrap();
482        assert_eq!(
483            fs::read_to_string(dir.path().join("docs/a.md")).unwrap(),
484            "# A2\n"
485        );
486        vault.write_atomic(&vp("docs/new.md"), b"new").unwrap();
487        assert_eq!(fs::read(dir.path().join("docs/new.md")).unwrap(), b"new");
488        let leftovers: Vec<_> = fs::read_dir(dir.path().join("docs"))
489            .unwrap()
490            .filter_map(|e| e.ok()?.file_name().into_string().ok())
491            .filter(|n| n.ends_with(".mbr-tmp"))
492            .collect();
493        assert!(leftovers.is_empty(), "{leftovers:?}");
494    }
495
496    #[test]
497    fn write_atomic_needs_an_existing_parent() {
498        let (dir, vault) = fixture();
499        assert!(vault.write_atomic(&vp("nope/x.md"), b"x").is_err());
500        assert!(!dir.path().join("nope").exists());
501        vault.create_dir_all(&vp("nope/deeper")).unwrap();
502        vault.write_atomic(&vp("nope/deeper/x.md"), b"x").unwrap();
503        assert!(vault.is_file(&vp("nope/deeper/x.md")));
504    }
505
506    #[cfg(unix)]
507    #[test]
508    fn write_atomic_keeps_the_file_permissions() {
509        use std::os::unix::fs::PermissionsExt;
510        let (dir, vault) = fixture();
511        let path = dir.path().join("docs/a.md");
512        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
513        vault.write_atomic(&vp("docs/a.md"), b"secret").unwrap();
514        let mode = fs::metadata(&path).unwrap().permissions().mode();
515        assert_eq!(mode & 0o777, 0o600);
516    }
517
518    #[test]
519    fn rename_and_remove() {
520        let (_dir, vault) = fixture();
521        vault.rename(&vp("top.txt"), &vp("docs/top.txt")).unwrap();
522        assert!(!vault.is_file(&vp("top.txt")));
523        assert!(vault.is_file(&vp("docs/top.txt")));
524        vault.remove_file(&vp("docs/top.txt")).unwrap();
525        assert_eq!(vault.stat(&vp("docs/top.txt")).unwrap(), None);
526    }
527
528    #[test]
529    fn canonicalize_existing_and_missing() {
530        let (_dir, vault) = fixture();
531        assert_eq!(
532            vault.canonicalize(&vp("docs/./a.md")).unwrap(),
533            vp("docs/a.md")
534        );
535        assert_eq!(
536            vault.canonicalize(&VaultPath::root()).unwrap(),
537            VaultPath::root()
538        );
539        assert!(
540            vault
541                .canonicalize(&vp("missing"))
542                .unwrap_err()
543                .is_not_found()
544        );
545    }
546
547    /// Symlink containment is decided by canonicalizing, exactly where the
548    /// resolver's `safe_join` decided it.
549    #[cfg(unix)]
550    mod symlinks {
551        use super::*;
552        use crate::vault::resolve_under;
553        use std::os::unix::fs::symlink;
554
555        fn with_links() -> (tempfile::TempDir, tempfile::TempDir, LocalVault) {
556            let (dir, vault) = fixture();
557            let outside = tempfile::tempdir().unwrap();
558            fs::write(outside.path().join("secret.md"), "# Secret").unwrap();
559            fs::create_dir(outside.path().join("dir")).unwrap();
560            fs::write(outside.path().join("dir/inner.md"), "# Inner").unwrap();
561            // File and directory links out of the vault.
562            symlink(outside.path().join("secret.md"), dir.path().join("leak.md")).unwrap();
563            symlink(outside.path().join("dir"), dir.path().join("leakdir")).unwrap();
564            // Links that stay inside.
565            symlink(dir.path().join("docs/a.md"), dir.path().join("alias.md")).unwrap();
566            symlink(dir.path().join("docs/sub"), dir.path().join("subalias")).unwrap();
567            // A dangling link.
568            symlink(
569                dir.path().join("nowhere.md"),
570                dir.path().join("dangling.md"),
571            )
572            .unwrap();
573            (dir, outside, vault)
574        }
575
576        #[test]
577        fn canonicalize_refuses_links_out_of_the_vault() {
578            let (_dir, _outside, vault) = with_links();
579            assert!(matches!(
580                vault.canonicalize(&vp("leak.md")),
581                Err(VaultError::OutsideRoot { .. })
582            ));
583            assert!(matches!(
584                vault.canonicalize(&vp("leakdir")),
585                Err(VaultError::OutsideRoot { .. })
586            ));
587            assert!(matches!(
588                vault.canonicalize(&vp("leakdir/inner.md")),
589                Err(VaultError::OutsideRoot { .. })
590            ));
591        }
592
593        #[test]
594        fn canonicalize_resolves_links_that_stay_inside() {
595            let (_dir, _outside, vault) = with_links();
596            assert_eq!(
597                vault.canonicalize(&vp("alias.md")).unwrap(),
598                vp("docs/a.md")
599            );
600            assert_eq!(
601                vault.canonicalize(&vp("subalias/b.md")).unwrap(),
602                vp("docs/sub/b.md")
603            );
604        }
605
606        /// `resolve_under` must not fall through to the "missing file" branch
607        /// for an *existing* link out of the vault — that branch validates only
608        /// the parent and would hand back the unresolved link.
609        #[test]
610        fn resolve_under_refuses_escaping_links_and_follows_internal_ones() {
611            let (_dir, _outside, vault) = with_links();
612            let root = VaultPath::root();
613            assert_eq!(resolve_under(&vault, &root, &vp("leak.md")), None);
614            assert_eq!(resolve_under(&vault, &root, &vp("leakdir/inner.md")), None);
615            // A probe *through* an escaping directory link is refused too: the
616            // parent canonicalizes outside.
617            assert_eq!(resolve_under(&vault, &root, &vp("leakdir/new.md")), None);
618            assert_eq!(
619                resolve_under(&vault, &root, &vp("alias.md")),
620                Some(vp("docs/a.md"))
621            );
622            // A link inside the vault but outside a sub-base is refused for
623            // that base (the static-folder case).
624            symlink(
625                vault.root().join("docs/a.md"),
626                vault.root().join("docs/sub/up.md"),
627            )
628            .unwrap();
629            assert_eq!(resolve_under(&vault, &vp("docs/sub"), &vp("up.md")), None);
630            assert_eq!(
631                resolve_under(&vault, &vp("docs/sub"), &vp("b.md")),
632                Some(vp("docs/sub/b.md"))
633            );
634        }
635
636        /// A dangling link is "does not exist": the probe answer is its own
637        /// (in-vault) path, as `safe_join` answered.
638        #[test]
639        fn dangling_links_resolve_like_missing_files() {
640            let (_dir, _outside, vault) = with_links();
641            assert_eq!(
642                resolve_under(&vault, &VaultPath::root(), &vp("dangling.md")),
643                Some(vp("dangling.md"))
644            );
645            assert_eq!(vault.stat(&vp("dangling.md")).unwrap(), None);
646        }
647
648        /// Listing follows links (the scanner always did) and skips broken
649        /// ones; deciding whether to *descend* is the caller's job, through
650        /// `canonicalize`.
651        #[test]
652        fn list_dir_follows_links_and_skips_dangling_ones() {
653            let (_dir, _outside, vault) = with_links();
654            let entries = vault.list_dir(&VaultPath::root()).unwrap();
655            let kind = |name: &str| entries.iter().find(|e| e.name() == name).map(|e| e.kind);
656            assert_eq!(kind("leak.md"), Some(EntryKind::File));
657            assert_eq!(kind("leakdir"), Some(EntryKind::Dir));
658            assert_eq!(kind("subalias"), Some(EntryKind::Dir));
659            assert_eq!(kind("dangling.md"), None);
660        }
661
662        /// A symlinked *root* is canonicalized at construction, so children
663        /// relativize against the same spelling `canonicalize` produces.
664        #[test]
665        fn a_symlinked_root_still_contains_its_children() {
666            let (dir, _vault) = fixture();
667            let holder = tempfile::tempdir().unwrap();
668            let link = holder.path().join("vault-link");
669            symlink(dir.path(), &link).unwrap();
670            let vault = LocalVault::new(&link);
671            assert_eq!(
672                vault.canonicalize(&vp("docs/a.md")).unwrap(),
673                vp("docs/a.md")
674            );
675        }
676    }
677}