use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError, RwLock};
use super::{VaultError, VaultPath};
use crate::config::external_folder_refusal_with_home;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount {
pub location: VaultPath,
pub target: PathBuf,
pub is_dir: bool,
}
#[derive(Debug, Clone, Default)]
pub struct MountPolicy {
pub other_roots: Vec<PathBuf>,
pub home: Option<PathBuf>,
pub markdown_root: Option<PathBuf>,
}
impl MountPolicy {
pub fn new(other_roots: Vec<PathBuf>) -> Self {
Self {
other_roots,
home: crate::config::home_dir().map(|home| home.canonicalize().unwrap_or(home)),
markdown_root: None,
}
}
#[must_use]
pub fn with_home(mut self, home: Option<PathBuf>) -> Self {
self.home = home;
self
}
#[must_use]
pub fn with_markdown_root(mut self, root: impl Into<PathBuf>) -> Self {
self.markdown_root = Some(root.into());
self
}
#[must_use]
pub fn with_other_root(mut self, root: impl Into<PathBuf>) -> Self {
self.other_roots.push(root.into());
self
}
}
#[derive(Debug)]
pub(super) struct MountTable {
policy: MountPolicy,
mounts: RwLock<Vec<Mount>>,
refused: Mutex<HashSet<VaultPath>>,
}
impl MountTable {
pub(super) fn new(policy: MountPolicy) -> Self {
Self {
policy,
mounts: RwLock::new(Vec::new()),
refused: Mutex::new(HashSet::new()),
}
}
pub(super) fn snapshot(&self) -> Vec<Mount> {
self.mounts
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub(super) fn map(&self, root: &Path, canonical: &Path) -> Option<VaultPath> {
if canonical.starts_with(root) {
return VaultPath::from_absolute(root, canonical).ok();
}
let mounts = self.mounts.read().unwrap_or_else(PoisonError::into_inner);
let mount = mounts
.iter()
.filter(|mount| canonical.starts_with(&mount.target))
.max_by_key(|mount| mount.target.components().count())?;
let below = VaultPath::from_absolute(&mount.target, canonical).ok()?;
Some(mount.location.join_path(&below))
}
pub(super) fn contains(&self, path: &VaultPath) -> bool {
self.mounts
.read()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.any(|mount| path.starts_with(&mount.location))
}
pub(super) fn resolve(
&self,
root: &Path,
native: impl Fn(&VaultPath) -> PathBuf,
path: &VaultPath,
mut canonical: PathBuf,
) -> Result<VaultPath, VaultError> {
let outside = |canonical: &Path| VaultError::OutsideRoot {
path: canonical.display().to_string(),
};
for _ in 0..=path.segments().count() {
if let Some(mapped) = self.map(root, &canonical) {
return Ok(mapped);
}
let (location, target) = self
.escape_point(root, &native, path)?
.ok_or_else(|| outside(&canonical))?;
if !self.admit(root, &location, &target) {
return Err(outside(&canonical));
}
canonical = native(path)
.canonicalize()
.map_err(|e| VaultError::from_io(path, e))?;
}
Err(outside(&canonical))
}
fn escape_point(
&self,
root: &Path,
native: &impl Fn(&VaultPath) -> PathBuf,
path: &VaultPath,
) -> Result<Option<(VaultPath, PathBuf)>, VaultError> {
let mut parent = VaultPath::root();
let mut prefix = VaultPath::root();
for segment in path.segments() {
prefix = prefix.child(segment)?;
let canonical = native(&prefix)
.canonicalize()
.map_err(|e| VaultError::from_io(&prefix, e))?;
match self.map(root, &canonical) {
Some(mapped) => parent = mapped,
None => return Ok(Some((parent.child(segment)?, canonical))),
}
}
Ok(None)
}
fn admit(&self, root: &Path, location: &VaultPath, target: &Path) -> bool {
if self
.mounts
.read()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.any(|mount| mount.target == target)
{
return true;
}
if self
.refused
.lock()
.unwrap_or_else(PoisonError::into_inner)
.contains(location)
{
return false;
}
let link = location.to_native(root);
match self.judge(root, target) {
Ok(is_dir) => {
let mut mounts = self.mounts.write().unwrap_or_else(PoisonError::into_inner);
if !mounts.iter().any(|mount| mount.target == target) {
tracing::info!(
"Serving external folder {} via {} (read-only)",
target.display(),
link.display()
);
mounts.push(Mount {
location: location.clone(),
target: target.to_path_buf(),
is_dir,
});
}
true
}
Err(reason) => {
let first = self
.refused
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(location.clone());
if first {
tracing::warn!(
"Not serving {} (a link to {}): {reason}",
link.display(),
target.display()
);
}
false
}
}
}
fn judge(&self, root: &Path, target: &Path) -> Result<bool, String> {
let protected: Vec<&Path> = std::iter::once(root)
.chain(self.policy.other_roots.iter().map(PathBuf::as_path))
.collect();
let metadata = fs::metadata(target).map_err(|e| format!("it cannot be read: {e}"))?;
if let Some(markdown_root) = &self.policy.markdown_root
&& target.starts_with(markdown_root)
{
return Err(
"it is inside the markdown root, whose files are served as pages, not as raw \
static files"
.to_string(),
);
}
if let Some(refusal) =
external_folder_refusal_with_home(target, &protected, self.policy.home.as_deref())
{
return Err(refusal.to_string());
}
if metadata.is_dir() {
fs::read_dir(target).map_err(|e| format!("it cannot be read: {e}"))?;
}
Ok(metadata.is_dir())
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::vault::{LocalVault, Vault, resolve_under, walk_files};
use std::os::unix::fs::symlink;
fn vp(s: &str) -> VaultPath {
VaultPath::new(s).unwrap()
}
fn visible_tempdir() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("mbr-mount-test-")
.tempdir()
.unwrap()
}
struct Fixture {
_dir: tempfile::TempDir,
base: PathBuf,
root: PathBuf,
movies: PathBuf,
}
impl Fixture {
fn new() -> Self {
let dir = visible_tempdir();
let base = dir.path().canonicalize().unwrap();
let root = base.join("repo");
let movies = base.join("movies");
for d in [
root.join("docs"),
movies.join("sub"),
movies.join(".secret"),
base.join("home"),
] {
fs::create_dir_all(d).unwrap();
}
fs::write(root.join("docs/a.md"), "# A").unwrap();
fs::write(movies.join("clip.mp4"), "clip").unwrap();
fs::write(movies.join("sub/deep.mp4"), "deep").unwrap();
fs::write(movies.join(".secret/key"), "key").unwrap();
fs::write(movies.join(".dotfile"), "dot").unwrap();
fs::write(movies.join("notes.md"), "# Notes").unwrap();
symlink(&movies, root.join("videos")).unwrap();
Self {
_dir: dir,
base,
root,
movies,
}
}
fn policy(&self) -> MountPolicy {
MountPolicy::default().with_home(Some(self.base.join("home")))
}
fn vault(&self) -> LocalVault {
LocalVault::with_mounts(&self.root, self.policy())
}
fn link(&self, target: impl AsRef<Path>, at: &str) {
symlink(target, self.root.join(at)).unwrap();
}
}
#[test]
fn a_directory_link_out_is_served_at_the_link() {
let fx = Fixture::new();
let vault = fx.vault();
assert_eq!(
vault.canonicalize(&vp("videos/clip.mp4")).unwrap(),
vp("videos/clip.mp4")
);
assert_eq!(
vault.canonicalize(&vp("videos/sub/deep.mp4")).unwrap(),
vp("videos/sub/deep.mp4")
);
assert_eq!(
vault.mounts(),
vec![Mount {
location: vp("videos"),
target: fx.movies.clone(),
is_dir: true,
}]
);
let key = vault.key(&vp("videos/clip.mp4"));
assert_eq!(key, fx.root.join("videos").join("clip.mp4"));
assert_eq!(vault.vault_path(&key).unwrap(), vp("videos/clip.mp4"));
assert_eq!(vault.read(&vp("videos/clip.mp4")).unwrap(), b"clip");
let root = VaultPath::root();
assert_eq!(
resolve_under(&vault, &root, &vp("videos/clip.mp4")),
Some(vp("videos/clip.mp4"))
);
assert_eq!(
resolve_under(&vault, &root, &vp("videos/missing.mp4")),
Some(vp("videos/missing.mp4"))
);
assert!(vault.is_read_only(&vp("videos")));
assert!(vault.is_read_only(&vp("videos/clip.mp4")));
assert!(!vault.is_read_only(&vp("docs/a.md")));
assert!(!vault.is_read_only(&vp("videosx/a.md")));
}
#[test]
fn a_plain_local_vault_still_refuses_links_out() {
let fx = Fixture::new();
let vault = LocalVault::new(&fx.root);
assert!(matches!(
vault.canonicalize(&vp("videos/clip.mp4")),
Err(VaultError::OutsideRoot { .. })
));
assert!(vault.mounts().is_empty());
}
#[test]
fn a_nested_link_out_of_a_mount_is_a_mount_of_its_own() {
let fx = Fixture::new();
let extra = fx.base.join("extra");
fs::create_dir(&extra).unwrap();
fs::write(extra.join("x.mp4"), "x").unwrap();
symlink(&extra, fx.movies.join("more")).unwrap();
let vault = fx.vault();
assert_eq!(
vault.canonicalize(&vp("videos/more/x.mp4")).unwrap(),
vp("videos/more/x.mp4")
);
let locations: Vec<String> = vault
.mounts()
.into_iter()
.map(|m| m.location.to_string())
.collect();
assert_eq!(locations, vec!["videos", "videos/more"]);
}
#[test]
fn a_link_climbing_out_of_a_mount_is_judged_not_followed() {
let fx = Fixture::new();
fs::write(fx.base.join("secret.txt"), "s").unwrap();
symlink("..", fx.movies.join("up")).unwrap();
let vault = fx.vault();
assert!(matches!(
vault.canonicalize(&vp("videos/up/secret.txt")),
Err(VaultError::OutsideRoot { .. })
));
assert_eq!(
vault.canonicalize(&vp("videos/up/repo/docs/a.md")).unwrap(),
vp("docs/a.md")
);
assert_eq!(vault.mounts().len(), 1, "only `videos` itself");
}
#[test]
fn a_link_from_a_mount_back_into_the_repository_resolves_inside_it() {
let fx = Fixture::new();
symlink(fx.root.join("docs"), fx.movies.join("back")).unwrap();
let vault = fx.vault();
vault.canonicalize(&vp("videos/clip.mp4")).unwrap();
assert_eq!(
vault.canonicalize(&vp("videos/back/a.md")).unwrap(),
vp("docs/a.md")
);
assert_eq!(vault.mounts().len(), 1, "no mount for a link into the root");
}
#[test]
fn a_cycle_through_a_mount_maps_back_and_is_walked_once() {
let fx = Fixture::new();
symlink(&fx.movies, fx.movies.join("loop")).unwrap();
symlink(fx.movies.join("sub"), fx.movies.join("sub/again")).unwrap();
let vault = fx.vault();
assert_eq!(
vault
.canonicalize(&vp("videos/loop/loop/clip.mp4"))
.unwrap(),
vp("videos/clip.mp4")
);
let mut found: Vec<String> = walk_files(&vault, |_| true)
.into_iter()
.map(|e| e.path.to_string())
.collect();
found.sort();
assert_eq!(
found,
vec![
"docs/a.md",
"videos/.dotfile",
"videos/.secret/key",
"videos/clip.mp4",
"videos/notes.md",
"videos/sub/deep.mp4",
],
"each directory once, under its canonical (mount) path"
);
assert_eq!(vault.mounts().len(), 1);
}
#[test]
fn two_links_to_one_target_share_the_first_mount() {
let fx = Fixture::new();
fx.link(&fx.movies, "videos2");
let vault = fx.vault();
assert_eq!(
vault.canonicalize(&vp("videos/clip.mp4")).unwrap(),
vp("videos/clip.mp4")
);
assert_eq!(
vault.canonicalize(&vp("videos2/clip.mp4")).unwrap(),
vp("videos/clip.mp4"),
"the second link resolves to the first mount's location"
);
assert_eq!(vault.mounts().len(), 1);
}
#[test]
fn refused_targets_stay_outside_the_vault() {
let fx = Fixture::new();
fs::create_dir(fx.base.join(".hidden")).unwrap();
fs::write(fx.base.join(".hidden/key"), "k").unwrap();
fs::write(fx.base.join("secret.txt"), "s").unwrap();
fs::write(fx.base.join("home/diary.md"), "d").unwrap();
fx.link("/", "fsroot");
fx.link(fx.base.join("home"), "home");
fx.link(&fx.base, "ancestor");
fx.link(fx.base.join(".hidden"), "hidden");
fx.link(fx.base.join(".hidden/key"), "key.txt");
let vault = fx.vault();
for path in [
"fsroot/etc/hosts",
"home/diary.md",
"ancestor/secret.txt",
"hidden/key",
"key.txt",
] {
assert!(
matches!(
vault.canonicalize(&vp(path)),
Err(VaultError::OutsideRoot { .. })
),
"{path} must stay refused"
);
assert_eq!(
resolve_under(&vault, &VaultPath::root(), &vp(path)),
None,
"{path}"
);
}
assert!(vault.mounts().is_empty(), "{:?}", vault.mounts());
}
#[test]
fn a_repository_inside_a_hidden_directory_can_mount_a_sibling() {
let dir = visible_tempdir();
let base = dir.path().canonicalize().unwrap().join(".notes");
fs::create_dir_all(base.join("repo")).unwrap();
fs::create_dir_all(base.join("media")).unwrap();
fs::write(base.join("media/x.png"), "x").unwrap();
symlink(base.join("media"), base.join("repo/media")).unwrap();
let vault = LocalVault::with_mounts(base.join("repo"), MountPolicy::default());
assert_eq!(
vault.canonicalize(&vp("media/x.png")).unwrap(),
vp("media/x.png")
);
}
#[test]
fn an_unreadable_target_is_refused_not_fatal() {
use std::os::unix::fs::PermissionsExt;
let fx = Fixture::new();
let locked = fx.base.join("locked");
fs::create_dir(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
fx.link(&locked, "locked");
if fs::read_dir(&locked).is_err() {
let vault = fx.vault();
assert!(vault.canonicalize(&vp("locked")).is_err());
assert!(vault.mounts().is_empty());
}
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn a_file_link_out_is_a_read_only_file_mount() {
let fx = Fixture::new();
fx.link(fx.movies.join("clip.mp4"), "demo.mp4");
let vault = fx.vault();
assert_eq!(vault.canonicalize(&vp("demo.mp4")).unwrap(), vp("demo.mp4"));
assert_eq!(
vault.mounts(),
vec![Mount {
location: vp("demo.mp4"),
target: fx.movies.join("clip.mp4"),
is_dir: false,
}]
);
assert!(vault.is_read_only(&vp("demo.mp4")));
assert!(matches!(
vault.write_atomic(&vp("demo.mp4"), b"x"),
Err(VaultError::ReadOnly { .. })
));
assert_eq!(fs::read(fx.movies.join("clip.mp4")).unwrap(), b"clip");
}
#[test]
fn writes_into_a_mount_are_refused_however_they_are_spelled() {
let fx = Fixture::new();
fx.link(&fx.root, "alias");
let read_only =
|result: Result<(), VaultError>| matches!(result, Err(VaultError::ReadOnly { .. }));
let vault = fx.vault();
assert!(read_only(vault.write_atomic(&vp("videos/new.md"), b"x")));
let vault = fx.vault();
assert!(read_only(
vault.write_atomic(&vp("alias/videos/new.md"), b"x")
));
assert!(read_only(vault.write_atomic(&vp("videos/clip.mp4"), b"x")));
assert!(read_only(vault.create_dir_all(&vp("videos/newdir/deeper"))));
assert!(read_only(vault.remove_file(&vp("videos/clip.mp4"))));
assert!(read_only(vault.remove_file(&vp("videos"))));
assert!(read_only(
vault.rename(&vp("docs/a.md"), &vp("videos/a.md"))
));
assert!(read_only(
vault.rename(&vp("videos/clip.mp4"), &vp("docs/c.mp4"))
));
assert!(!fx.movies.join("new.md").exists());
assert!(!fx.movies.join("newdir").exists());
assert!(!fx.movies.join("a.md").exists());
assert_eq!(fs::read(fx.movies.join("clip.mp4")).unwrap(), b"clip");
assert!(fx.root.join("docs/a.md").exists());
assert!(fx.root.join("videos").exists(), "the link itself survives");
vault.write_atomic(&vp("docs/b.md"), b"# B").unwrap();
assert_eq!(fs::read(fx.root.join("docs/b.md")).unwrap(), b"# B");
}
#[test]
fn writes_through_a_refused_link_are_refused() {
let fx = Fixture::new();
fs::create_dir(fx.base.join(".hidden")).unwrap();
fx.link(fx.base.join(".hidden"), "hidden");
let vault = fx.vault();
assert!(matches!(
vault.write_atomic(&vp("hidden/planted"), b"x"),
Err(VaultError::OutsideRoot { .. })
));
assert!(!fx.base.join(".hidden/planted").exists());
}
#[test]
fn the_shared_policy_refuses_each_kind_of_target() {
use crate::config::{ExternalFolderRefusal as R, external_folder_refusal_with_home};
let root = Path::new("/srv/sites/blog/content");
let home = Some(Path::new("/home/me"));
let refusal = |dir: &str| external_folder_refusal_with_home(Path::new(dir), &[root], home);
assert_eq!(refusal("/"), Some(R::FilesystemRoot));
assert_eq!(refusal("/home/me"), Some(R::HomeDir));
assert_eq!(refusal("/home"), Some(R::HomeDir));
assert_eq!(
refusal("/srv/sites"),
Some(R::ContainsRoot(root.to_path_buf()))
);
assert_eq!(refusal("/home/me/.ssh"), Some(R::Hidden(".ssh".into())));
assert_eq!(
refusal("/home/me/.config/app/media"),
Some(R::Hidden(".config".into()))
);
assert_eq!(
refusal("/srv/sites/blog/.git"),
Some(R::Hidden(".git".into()))
);
assert_eq!(refusal("/home/me/Movies"), None);
assert_eq!(refusal("/srv/media"), None);
assert_eq!(refusal("/srv/sites/blog/static"), None);
}
}