Skip to main content

mbr_core/vault/
mount.rs

1//! Read-only **symlink mounts**: a symlink inside a [`LocalVault`] whose
2//! target lies outside it, served as a vault of its own.
3//!
4//! The case this exists for is `static/videos -> ~/Movies`: media the author
5//! keeps elsewhere, linked into the site. Before mounts the link was refused
6//! (the server answered 404, and the static build placed it anyway — the two
7//! disagreed). A mount makes it reachable from both, safely:
8//!
9//! - **Addressed at the link.** A file at `<target>/clip.mp4` is the vault
10//!   path `static/videos/clip.mp4` — the link's canonical parent plus its own
11//!   name — so its index key, URL, `raw_path` and every other derived value are
12//!   exactly what a real directory at that place would produce. Nothing
13//!   downstream learns that the bytes live elsewhere.
14//! - **No escape.** [`super::Vault::canonicalize`] maps a canonical path back
15//!   into the vault only when it is under the root or under an accepted
16//!   mount's target, so `..` and nested links cannot leave a mount. A nested
17//!   link to somewhere else is judged as a mount of its own, by the same rules.
18//! - **Each target once.** Two links to one target, or a link cycle, map to
19//!   the *first* mount registered for it, so the scanner's visited set (keyed
20//!   by canonical vault path) walks it once.
21//! - **Refused targets** are those [`crate::config::external_folder_refusal`]
22//!   names — the same policy the external static overlay answers to — plus
23//!   targets that cannot be read. A refusal is logged once per link and the
24//!   link stays `OutsideRoot`, exactly as before mounts.
25//! - **Read-only.** Every write through the vault into a mount is refused with
26//!   [`super::VaultError::ReadOnly`] ([`super::Vault::is_read_only`]).
27//!
28//! Mounts are discovered **lazily**, by `canonicalize`, the first time a path
29//! through the link is resolved — the scanner's descent, a request, a walk.
30//! That puts the cost on the error path only: a repository without links out
31//! pays nothing, not a syscall per file and not a pre-scan.
32
33use std::collections::HashSet;
34use std::fs;
35use std::path::{Path, PathBuf};
36use std::sync::{Mutex, PoisonError, RwLock};
37
38use super::{VaultError, VaultPath};
39use crate::config::external_folder_refusal_with_home;
40
41/// One accepted mount.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Mount {
44    /// Where the link is: its canonical parent in the vault plus its own name.
45    /// Every path inside the mount is this plus the path below the target.
46    pub location: VaultPath,
47    /// The link's canonical target.
48    pub target: PathBuf,
49    /// Whether the target is a directory. A file link is a mount of one file.
50    pub is_dir: bool,
51}
52
53/// What a vault may mount: the policy inputs, fixed at construction.
54#[derive(Debug, Clone, Default)]
55pub struct MountPolicy {
56    /// Canonical roots already being served besides the vault's own — the
57    /// markdown root and the external static overlay. A mount may never
58    /// contain one.
59    pub other_roots: Vec<PathBuf>,
60    /// `$HOME`, canonical. A mount may be neither it nor an ancestor of it.
61    pub home: Option<PathBuf>,
62    /// The markdown root, canonical, for a vault that is *not* it (the
63    /// external static overlay). A mount may not point into it: the overlay
64    /// serves files raw, so a link from the overlay into the root would serve
65    /// markdown *sources* — frontmatter included, which `site.json` filters
66    /// (contact emails and phones) — beside the rendered pages. In the
67    /// repository's own vault a link into the root is simply a path inside it.
68    pub markdown_root: Option<PathBuf>,
69}
70
71impl MountPolicy {
72    /// The policy for a vault served alongside `other_roots`, with `$HOME`
73    /// read from the environment (`USERPROFILE` on Windows).
74    pub fn new(other_roots: Vec<PathBuf>) -> Self {
75        Self {
76            other_roots,
77            home: crate::config::home_dir().map(|home| home.canonicalize().unwrap_or(home)),
78            markdown_root: None,
79        }
80    }
81
82    /// Replaces `$HOME`, for tests.
83    #[must_use]
84    pub fn with_home(mut self, home: Option<PathBuf>) -> Self {
85        self.home = home;
86        self
87    }
88
89    /// Sets the markdown root a mount may not point into (see the field).
90    #[must_use]
91    pub fn with_markdown_root(mut self, root: impl Into<PathBuf>) -> Self {
92        self.markdown_root = Some(root.into());
93        self
94    }
95
96    /// Adds a served root a mount may not contain.
97    #[must_use]
98    pub fn with_other_root(mut self, root: impl Into<PathBuf>) -> Self {
99        self.other_roots.push(root.into());
100        self
101    }
102}
103
104/// The mounts of one vault, growing as links are discovered.
105#[derive(Debug)]
106pub(super) struct MountTable {
107    policy: MountPolicy,
108    /// Accepted mounts. Read on the `OutsideRoot` path and by writes; written
109    /// once per discovered link.
110    mounts: RwLock<Vec<Mount>>,
111    /// Links already refused (and logged), so each is reported once.
112    refused: Mutex<HashSet<VaultPath>>,
113}
114
115impl MountTable {
116    pub(super) fn new(policy: MountPolicy) -> Self {
117        Self {
118            policy,
119            mounts: RwLock::new(Vec::new()),
120            refused: Mutex::new(HashSet::new()),
121        }
122    }
123
124    pub(super) fn snapshot(&self) -> Vec<Mount> {
125        self.mounts
126            .read()
127            .unwrap_or_else(PoisonError::into_inner)
128            .clone()
129    }
130
131    /// The vault path of `canonical`: under `root`, or under the target of the
132    /// accepted mount nearest to it (the longest target prefix, so a nested
133    /// mount wins over an enclosing one). `None` when it is under neither.
134    pub(super) fn map(&self, root: &Path, canonical: &Path) -> Option<VaultPath> {
135        if canonical.starts_with(root) {
136            return VaultPath::from_absolute(root, canonical).ok();
137        }
138        let mounts = self.mounts.read().unwrap_or_else(PoisonError::into_inner);
139        let mount = mounts
140            .iter()
141            .filter(|mount| canonical.starts_with(&mount.target))
142            .max_by_key(|mount| mount.target.components().count())?;
143        let below = VaultPath::from_absolute(&mount.target, canonical).ok()?;
144        Some(mount.location.join_path(&below))
145    }
146
147    /// Whether `path` is a mount's location or inside one, lexically.
148    pub(super) fn contains(&self, path: &VaultPath) -> bool {
149        self.mounts
150            .read()
151            .unwrap_or_else(PoisonError::into_inner)
152            .iter()
153            .any(|mount| path.starts_with(&mount.location))
154    }
155
156    /// Resolves `path`, whose canonical form `canonical` is under neither the
157    /// root nor any accepted mount, by discovering the link it leaves through.
158    ///
159    /// Each turn finds the first prefix of `path` that leaves (its parent maps
160    /// into the vault, it does not) and judges that link as a mount. A path can
161    /// leave more than once — a link inside a mount to somewhere else — so the
162    /// loop repeats, at most once per segment.
163    pub(super) fn resolve(
164        &self,
165        root: &Path,
166        native: impl Fn(&VaultPath) -> PathBuf,
167        path: &VaultPath,
168        mut canonical: PathBuf,
169    ) -> Result<VaultPath, VaultError> {
170        let outside = |canonical: &Path| VaultError::OutsideRoot {
171            path: canonical.display().to_string(),
172        };
173        for _ in 0..=path.segments().count() {
174            if let Some(mapped) = self.map(root, &canonical) {
175                return Ok(mapped);
176            }
177            let (location, target) = self
178                .escape_point(root, &native, path)?
179                .ok_or_else(|| outside(&canonical))?;
180            if !self.admit(root, &location, &target) {
181                return Err(outside(&canonical));
182            }
183            canonical = native(path)
184                .canonicalize()
185                .map_err(|e| VaultError::from_io(path, e))?;
186        }
187        Err(outside(&canonical))
188    }
189
190    /// The first prefix of `path` that resolves out of the vault, as the
191    /// link's location (canonical parent + name) and its canonical target.
192    fn escape_point(
193        &self,
194        root: &Path,
195        native: &impl Fn(&VaultPath) -> PathBuf,
196        path: &VaultPath,
197    ) -> Result<Option<(VaultPath, PathBuf)>, VaultError> {
198        let mut parent = VaultPath::root();
199        let mut prefix = VaultPath::root();
200        for segment in path.segments() {
201            prefix = prefix.child(segment)?;
202            let canonical = native(&prefix)
203                .canonicalize()
204                .map_err(|e| VaultError::from_io(&prefix, e))?;
205            match self.map(root, &canonical) {
206                Some(mapped) => parent = mapped,
207                None => return Ok(Some((parent.child(segment)?, canonical))),
208            }
209        }
210        Ok(None)
211    }
212
213    /// Judges the link at `location` (in the vault rooted at `root`) to
214    /// `target`, registering it when the policy allows. Returns whether
215    /// `target` is now mounted — by this link or an earlier one.
216    fn admit(&self, root: &Path, location: &VaultPath, target: &Path) -> bool {
217        if self
218            .mounts
219            .read()
220            .unwrap_or_else(PoisonError::into_inner)
221            .iter()
222            .any(|mount| mount.target == target)
223        {
224            return true;
225        }
226        if self
227            .refused
228            .lock()
229            .unwrap_or_else(PoisonError::into_inner)
230            .contains(location)
231        {
232            return false;
233        }
234        let link = location.to_native(root);
235        match self.judge(root, target) {
236            Ok(is_dir) => {
237                let mut mounts = self.mounts.write().unwrap_or_else(PoisonError::into_inner);
238                if !mounts.iter().any(|mount| mount.target == target) {
239                    tracing::info!(
240                        "Serving external folder {} via {} (read-only)",
241                        target.display(),
242                        link.display()
243                    );
244                    mounts.push(Mount {
245                        location: location.clone(),
246                        target: target.to_path_buf(),
247                        is_dir,
248                    });
249                }
250                true
251            }
252            Err(reason) => {
253                let first = self
254                    .refused
255                    .lock()
256                    .unwrap_or_else(PoisonError::into_inner)
257                    .insert(location.clone());
258                if first {
259                    tracing::warn!(
260                        "Not serving {} (a link to {}): {reason}",
261                        link.display(),
262                        target.display()
263                    );
264                }
265                false
266            }
267        }
268    }
269
270    /// The policy verdict for `target`: `Ok(is_dir)` when it may be mounted.
271    fn judge(&self, root: &Path, target: &Path) -> Result<bool, String> {
272        let protected: Vec<&Path> = std::iter::once(root)
273            .chain(self.policy.other_roots.iter().map(PathBuf::as_path))
274            .collect();
275        let metadata = fs::metadata(target).map_err(|e| format!("it cannot be read: {e}"))?;
276        if let Some(markdown_root) = &self.policy.markdown_root
277            && target.starts_with(markdown_root)
278        {
279            return Err(
280                "it is inside the markdown root, whose files are served as pages, not as raw \
281                 static files"
282                    .to_string(),
283            );
284        }
285        // One policy for both kinds: a *file* target can contain nothing, so
286        // of the rules only the hidden-name one can ever refuse it.
287        if let Some(refusal) =
288            external_folder_refusal_with_home(target, &protected, self.policy.home.as_deref())
289        {
290            return Err(refusal.to_string());
291        }
292        if metadata.is_dir() {
293            fs::read_dir(target).map_err(|e| format!("it cannot be read: {e}"))?;
294        }
295        Ok(metadata.is_dir())
296    }
297}
298
299/// Real symlinks, so Unix only (Windows needs Developer Mode or elevation to
300/// create one). Every temp dir here is made with a visible prefix: `tempfile`'s
301/// default `.tmpXXXX` names are *hidden*, and a mount target under one is
302/// refused by the hidden-directory rule — which is the rule working, not the
303/// fixture.
304#[cfg(all(test, unix))]
305mod tests {
306    use super::*;
307    use crate::vault::{LocalVault, Vault, resolve_under, walk_files};
308    use std::os::unix::fs::symlink;
309
310    fn vp(s: &str) -> VaultPath {
311        VaultPath::new(s).unwrap()
312    }
313
314    fn visible_tempdir() -> tempfile::TempDir {
315        tempfile::Builder::new()
316            .prefix("mbr-mount-test-")
317            .tempdir()
318            .unwrap()
319    }
320
321    /// `<base>/repo` (the vault, `docs/a.md`), `<base>/movies` (the target:
322    /// `clip.mp4`, `sub/deep.mp4`, `.secret/key`, `.dotfile`, `notes.md`), a
323    /// fake `<base>/home`, and `repo/videos -> movies`.
324    struct Fixture {
325        _dir: tempfile::TempDir,
326        base: PathBuf,
327        root: PathBuf,
328        movies: PathBuf,
329    }
330
331    impl Fixture {
332        fn new() -> Self {
333            let dir = visible_tempdir();
334            let base = dir.path().canonicalize().unwrap();
335            let root = base.join("repo");
336            let movies = base.join("movies");
337            for d in [
338                root.join("docs"),
339                movies.join("sub"),
340                movies.join(".secret"),
341                base.join("home"),
342            ] {
343                fs::create_dir_all(d).unwrap();
344            }
345            fs::write(root.join("docs/a.md"), "# A").unwrap();
346            fs::write(movies.join("clip.mp4"), "clip").unwrap();
347            fs::write(movies.join("sub/deep.mp4"), "deep").unwrap();
348            fs::write(movies.join(".secret/key"), "key").unwrap();
349            fs::write(movies.join(".dotfile"), "dot").unwrap();
350            fs::write(movies.join("notes.md"), "# Notes").unwrap();
351            symlink(&movies, root.join("videos")).unwrap();
352            Self {
353                _dir: dir,
354                base,
355                root,
356                movies,
357            }
358        }
359
360        fn policy(&self) -> MountPolicy {
361            MountPolicy::default().with_home(Some(self.base.join("home")))
362        }
363
364        fn vault(&self) -> LocalVault {
365            LocalVault::with_mounts(&self.root, self.policy())
366        }
367
368        fn link(&self, target: impl AsRef<Path>, at: &str) {
369            symlink(target, self.root.join(at)).unwrap();
370        }
371    }
372
373    #[test]
374    fn a_directory_link_out_is_served_at_the_link() {
375        let fx = Fixture::new();
376        let vault = fx.vault();
377        assert_eq!(
378            vault.canonicalize(&vp("videos/clip.mp4")).unwrap(),
379            vp("videos/clip.mp4")
380        );
381        assert_eq!(
382            vault.canonicalize(&vp("videos/sub/deep.mp4")).unwrap(),
383            vp("videos/sub/deep.mp4")
384        );
385        assert_eq!(
386            vault.mounts(),
387            vec![Mount {
388                location: vp("videos"),
389                target: fx.movies.clone(),
390                is_dir: true,
391            }]
392        );
393        // Keyed, located and read exactly like an in-root file.
394        let key = vault.key(&vp("videos/clip.mp4"));
395        assert_eq!(key, fx.root.join("videos").join("clip.mp4"));
396        assert_eq!(vault.vault_path(&key).unwrap(), vp("videos/clip.mp4"));
397        assert_eq!(vault.read(&vp("videos/clip.mp4")).unwrap(), b"clip");
398        // The resolver's containment answers, including the probe for a name
399        // that does not exist yet.
400        let root = VaultPath::root();
401        assert_eq!(
402            resolve_under(&vault, &root, &vp("videos/clip.mp4")),
403            Some(vp("videos/clip.mp4"))
404        );
405        assert_eq!(
406            resolve_under(&vault, &root, &vp("videos/missing.mp4")),
407            Some(vp("videos/missing.mp4"))
408        );
409        assert!(vault.is_read_only(&vp("videos")));
410        assert!(vault.is_read_only(&vp("videos/clip.mp4")));
411        assert!(!vault.is_read_only(&vp("docs/a.md")));
412        assert!(!vault.is_read_only(&vp("videosx/a.md")));
413    }
414
415    /// Without `with_mounts`, a link out stays refused — the rule every
416    /// non-repository vault (QuickLook, tests, link tools) keeps.
417    #[test]
418    fn a_plain_local_vault_still_refuses_links_out() {
419        let fx = Fixture::new();
420        let vault = LocalVault::new(&fx.root);
421        assert!(matches!(
422            vault.canonicalize(&vp("videos/clip.mp4")),
423            Err(VaultError::OutsideRoot { .. })
424        ));
425        assert!(vault.mounts().is_empty());
426    }
427
428    #[test]
429    fn a_nested_link_out_of_a_mount_is_a_mount_of_its_own() {
430        let fx = Fixture::new();
431        let extra = fx.base.join("extra");
432        fs::create_dir(&extra).unwrap();
433        fs::write(extra.join("x.mp4"), "x").unwrap();
434        symlink(&extra, fx.movies.join("more")).unwrap();
435        let vault = fx.vault();
436        assert_eq!(
437            vault.canonicalize(&vp("videos/more/x.mp4")).unwrap(),
438            vp("videos/more/x.mp4")
439        );
440        let locations: Vec<String> = vault
441            .mounts()
442            .into_iter()
443            .map(|m| m.location.to_string())
444            .collect();
445        assert_eq!(locations, vec!["videos", "videos/more"]);
446    }
447
448    /// `..` is resolved physically by the OS, after links: a link *to* the
449    /// target's parent is judged like any other link — here it contains the
450    /// repository root, so it is refused. (Climbing back *into* the root that
451    /// way lands inside the vault and is simply a path in it.)
452    #[test]
453    fn a_link_climbing_out_of_a_mount_is_judged_not_followed() {
454        let fx = Fixture::new();
455        fs::write(fx.base.join("secret.txt"), "s").unwrap();
456        symlink("..", fx.movies.join("up")).unwrap();
457        let vault = fx.vault();
458        assert!(matches!(
459            vault.canonicalize(&vp("videos/up/secret.txt")),
460            Err(VaultError::OutsideRoot { .. })
461        ));
462        assert_eq!(
463            vault.canonicalize(&vp("videos/up/repo/docs/a.md")).unwrap(),
464            vp("docs/a.md")
465        );
466        assert_eq!(vault.mounts().len(), 1, "only `videos` itself");
467    }
468
469    #[test]
470    fn a_link_from_a_mount_back_into_the_repository_resolves_inside_it() {
471        let fx = Fixture::new();
472        symlink(fx.root.join("docs"), fx.movies.join("back")).unwrap();
473        let vault = fx.vault();
474        vault.canonicalize(&vp("videos/clip.mp4")).unwrap();
475        assert_eq!(
476            vault.canonicalize(&vp("videos/back/a.md")).unwrap(),
477            vp("docs/a.md")
478        );
479        assert_eq!(vault.mounts().len(), 1, "no mount for a link into the root");
480    }
481
482    #[test]
483    fn a_cycle_through_a_mount_maps_back_and_is_walked_once() {
484        let fx = Fixture::new();
485        symlink(&fx.movies, fx.movies.join("loop")).unwrap();
486        symlink(fx.movies.join("sub"), fx.movies.join("sub/again")).unwrap();
487        let vault = fx.vault();
488        assert_eq!(
489            vault
490                .canonicalize(&vp("videos/loop/loop/clip.mp4"))
491                .unwrap(),
492            vp("videos/clip.mp4")
493        );
494        let mut found: Vec<String> = walk_files(&vault, |_| true)
495            .into_iter()
496            .map(|e| e.path.to_string())
497            .collect();
498        found.sort();
499        assert_eq!(
500            found,
501            vec![
502                "docs/a.md",
503                "videos/.dotfile",
504                "videos/.secret/key",
505                "videos/clip.mp4",
506                "videos/notes.md",
507                "videos/sub/deep.mp4",
508            ],
509            "each directory once, under its canonical (mount) path"
510        );
511        assert_eq!(vault.mounts().len(), 1);
512    }
513
514    #[test]
515    fn two_links_to_one_target_share_the_first_mount() {
516        let fx = Fixture::new();
517        fx.link(&fx.movies, "videos2");
518        let vault = fx.vault();
519        assert_eq!(
520            vault.canonicalize(&vp("videos/clip.mp4")).unwrap(),
521            vp("videos/clip.mp4")
522        );
523        assert_eq!(
524            vault.canonicalize(&vp("videos2/clip.mp4")).unwrap(),
525            vp("videos/clip.mp4"),
526            "the second link resolves to the first mount's location"
527        );
528        assert_eq!(vault.mounts().len(), 1);
529    }
530
531    #[test]
532    fn refused_targets_stay_outside_the_vault() {
533        let fx = Fixture::new();
534        fs::create_dir(fx.base.join(".hidden")).unwrap();
535        fs::write(fx.base.join(".hidden/key"), "k").unwrap();
536        fs::write(fx.base.join("secret.txt"), "s").unwrap();
537        fs::write(fx.base.join("home/diary.md"), "d").unwrap();
538        fx.link("/", "fsroot");
539        fx.link(fx.base.join("home"), "home");
540        fx.link(&fx.base, "ancestor");
541        fx.link(fx.base.join(".hidden"), "hidden");
542        fx.link(fx.base.join(".hidden/key"), "key.txt");
543        let vault = fx.vault();
544        for path in [
545            "fsroot/etc/hosts",
546            "home/diary.md",
547            "ancestor/secret.txt",
548            "hidden/key",
549            "key.txt",
550        ] {
551            assert!(
552                matches!(
553                    vault.canonicalize(&vp(path)),
554                    Err(VaultError::OutsideRoot { .. })
555                ),
556                "{path} must stay refused"
557            );
558            assert_eq!(
559                resolve_under(&vault, &VaultPath::root(), &vp(path)),
560                None,
561                "{path}"
562            );
563        }
564        assert!(vault.mounts().is_empty(), "{:?}", vault.mounts());
565    }
566
567    /// The hidden rule only counts components below the point the target
568    /// shares with the root, so a repository living inside a hidden directory
569    /// can still mount a sibling there.
570    #[test]
571    fn a_repository_inside_a_hidden_directory_can_mount_a_sibling() {
572        let dir = visible_tempdir();
573        let base = dir.path().canonicalize().unwrap().join(".notes");
574        fs::create_dir_all(base.join("repo")).unwrap();
575        fs::create_dir_all(base.join("media")).unwrap();
576        fs::write(base.join("media/x.png"), "x").unwrap();
577        symlink(base.join("media"), base.join("repo/media")).unwrap();
578        let vault = LocalVault::with_mounts(base.join("repo"), MountPolicy::default());
579        assert_eq!(
580            vault.canonicalize(&vp("media/x.png")).unwrap(),
581            vp("media/x.png")
582        );
583    }
584
585    #[test]
586    fn an_unreadable_target_is_refused_not_fatal() {
587        use std::os::unix::fs::PermissionsExt;
588        let fx = Fixture::new();
589        let locked = fx.base.join("locked");
590        fs::create_dir(&locked).unwrap();
591        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
592        fx.link(&locked, "locked");
593        // Root ignores the mode; there is nothing to assert then.
594        if fs::read_dir(&locked).is_err() {
595            let vault = fx.vault();
596            assert!(vault.canonicalize(&vp("locked")).is_err());
597            assert!(vault.mounts().is_empty());
598        }
599        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
600    }
601
602    #[test]
603    fn a_file_link_out_is_a_read_only_file_mount() {
604        let fx = Fixture::new();
605        fx.link(fx.movies.join("clip.mp4"), "demo.mp4");
606        let vault = fx.vault();
607        assert_eq!(vault.canonicalize(&vp("demo.mp4")).unwrap(), vp("demo.mp4"));
608        assert_eq!(
609            vault.mounts(),
610            vec![Mount {
611                location: vp("demo.mp4"),
612                target: fx.movies.join("clip.mp4"),
613                is_dir: false,
614            }]
615        );
616        assert!(vault.is_read_only(&vp("demo.mp4")));
617        assert!(matches!(
618            vault.write_atomic(&vp("demo.mp4"), b"x"),
619            Err(VaultError::ReadOnly { .. })
620        ));
621        assert_eq!(fs::read(fx.movies.join("clip.mp4")).unwrap(), b"clip");
622    }
623
624    #[test]
625    fn writes_into_a_mount_are_refused_however_they_are_spelled() {
626        let fx = Fixture::new();
627        // An in-root alias of the root, so `alias/videos/...` reaches the
628        // mount through a second link.
629        fx.link(&fx.root, "alias");
630        let read_only =
631            |result: Result<(), VaultError>| matches!(result, Err(VaultError::ReadOnly { .. }));
632        // A fresh vault: nothing discovered yet, so the write check itself has
633        // to find the mount.
634        let vault = fx.vault();
635        assert!(read_only(vault.write_atomic(&vp("videos/new.md"), b"x")));
636        let vault = fx.vault();
637        assert!(read_only(
638            vault.write_atomic(&vp("alias/videos/new.md"), b"x")
639        ));
640        assert!(read_only(vault.write_atomic(&vp("videos/clip.mp4"), b"x")));
641        assert!(read_only(vault.create_dir_all(&vp("videos/newdir/deeper"))));
642        assert!(read_only(vault.remove_file(&vp("videos/clip.mp4"))));
643        assert!(read_only(vault.remove_file(&vp("videos"))));
644        assert!(read_only(
645            vault.rename(&vp("docs/a.md"), &vp("videos/a.md"))
646        ));
647        assert!(read_only(
648            vault.rename(&vp("videos/clip.mp4"), &vp("docs/c.mp4"))
649        ));
650        assert!(!fx.movies.join("new.md").exists());
651        assert!(!fx.movies.join("newdir").exists());
652        assert!(!fx.movies.join("a.md").exists());
653        assert_eq!(fs::read(fx.movies.join("clip.mp4")).unwrap(), b"clip");
654        assert!(fx.root.join("docs/a.md").exists());
655        assert!(fx.root.join("videos").exists(), "the link itself survives");
656        // Writes elsewhere are untouched by any of this.
657        vault.write_atomic(&vp("docs/b.md"), b"# B").unwrap();
658        assert_eq!(fs::read(fx.root.join("docs/b.md")).unwrap(), b"# B");
659    }
660
661    /// A refused link can never be written through either: the write check
662    /// canonicalizes, and the refusal is `OutsideRoot`.
663    #[test]
664    fn writes_through_a_refused_link_are_refused() {
665        let fx = Fixture::new();
666        fs::create_dir(fx.base.join(".hidden")).unwrap();
667        fx.link(fx.base.join(".hidden"), "hidden");
668        let vault = fx.vault();
669        assert!(matches!(
670            vault.write_atomic(&vp("hidden/planted"), b"x"),
671            Err(VaultError::OutsideRoot { .. })
672        ));
673        assert!(!fx.base.join(".hidden/planted").exists());
674    }
675
676    #[test]
677    fn the_shared_policy_refuses_each_kind_of_target() {
678        use crate::config::{ExternalFolderRefusal as R, external_folder_refusal_with_home};
679        let root = Path::new("/srv/sites/blog/content");
680        let home = Some(Path::new("/home/me"));
681        let refusal = |dir: &str| external_folder_refusal_with_home(Path::new(dir), &[root], home);
682        assert_eq!(refusal("/"), Some(R::FilesystemRoot));
683        assert_eq!(refusal("/home/me"), Some(R::HomeDir));
684        assert_eq!(refusal("/home"), Some(R::HomeDir));
685        assert_eq!(
686            refusal("/srv/sites"),
687            Some(R::ContainsRoot(root.to_path_buf()))
688        );
689        assert_eq!(refusal("/home/me/.ssh"), Some(R::Hidden(".ssh".into())));
690        assert_eq!(
691            refusal("/home/me/.config/app/media"),
692            Some(R::Hidden(".config".into()))
693        );
694        assert_eq!(
695            refusal("/srv/sites/blog/.git"),
696            Some(R::Hidden(".git".into()))
697        );
698        assert_eq!(refusal("/home/me/Movies"), None);
699        assert_eq!(refusal("/srv/media"), None);
700        assert_eq!(refusal("/srv/sites/blog/static"), None);
701    }
702}