1use 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#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Mount {
44 pub location: VaultPath,
47 pub target: PathBuf,
49 pub is_dir: bool,
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct MountPolicy {
56 pub other_roots: Vec<PathBuf>,
60 pub home: Option<PathBuf>,
62 pub markdown_root: Option<PathBuf>,
69}
70
71impl MountPolicy {
72 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 #[must_use]
84 pub fn with_home(mut self, home: Option<PathBuf>) -> Self {
85 self.home = home;
86 self
87 }
88
89 #[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 #[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#[derive(Debug)]
106pub(super) struct MountTable {
107 policy: MountPolicy,
108 mounts: RwLock<Vec<Mount>>,
111 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 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 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 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 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 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 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 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#[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 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 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 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 #[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 #[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 #[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 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 fx.link(&fx.root, "alias");
630 let read_only =
631 |result: Result<(), VaultError>| matches!(result, Err(VaultError::ReadOnly { .. }));
632 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 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 #[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}