use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::vault::{EntryKind, Vault, VaultPath, has_hidden_segment, resolve_under};
fn safe_join(vault: &dyn Vault, request_path: &str, exempt: &[VaultPath]) -> Option<VaultPath> {
let request = visible(VaultPath::new(request_path).ok()?, exempt)?;
visible(resolve_under(vault, &VaultPath::root(), &request)?, exempt)
}
fn visible(path: VaultPath, exempt: &[VaultPath]) -> Option<VaultPath> {
(!has_hidden_segment(&path, exempt)).then_some(path)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedPath {
StaticFile(PathBuf),
MarkdownFile(PathBuf),
DirectoryListing(PathBuf),
TagPage {
source: String,
value: String,
},
TagSourceIndex {
source: String,
},
NotFound,
Redirect(String),
}
#[derive(Debug, Clone, Copy)]
pub struct PathResolverConfig<'a> {
pub vault: &'a dyn Vault,
pub static_vault: Option<&'a dyn Vault>,
pub static_folder: &'a str,
pub markdown_extensions: &'a [String],
pub index_file: &'a str,
pub tag_sources: &'a [String],
pub exempt_hidden_dirs: &'a [VaultPath],
}
#[derive(Debug, Clone)]
pub struct OwnedPathResolverConfig {
pub vault: Arc<dyn Vault>,
pub static_vault: Option<Arc<dyn Vault>>,
pub static_folder: String,
pub markdown_extensions: Vec<String>,
pub index_file: String,
pub tag_sources: Vec<String>,
pub exempt_hidden_dirs: Vec<VaultPath>,
}
impl OwnedPathResolverConfig {
pub fn as_config(&self) -> PathResolverConfig<'_> {
PathResolverConfig {
vault: self.vault.as_ref(),
static_vault: self.static_vault.as_deref(),
static_folder: &self.static_folder,
markdown_extensions: &self.markdown_extensions,
index_file: &self.index_file,
tag_sources: &self.tag_sources,
exempt_hidden_dirs: &self.exempt_hidden_dirs,
}
}
pub fn for_repo(
repo: &crate::repo::Repo,
static_folder: &str,
markdown_extensions: &[String],
index_file: &str,
tag_sources: Vec<String>,
) -> Self {
Self {
vault: Arc::clone(repo.vault()),
static_vault: repo.static_vault().cloned(),
static_folder: static_folder.to_string(),
markdown_extensions: markdown_extensions.to_vec(),
index_file: index_file.to_string(),
tag_sources,
exempt_hidden_dirs: repo.exempt_hidden_dirs().to_vec(),
}
}
}
pub fn canonical_page_redirect(request_path: &str, canonical_url: &str) -> Option<String> {
let requested = format!("/{}", request_path.trim_start_matches('/'));
(requested != canonical_url).then(|| canonical_url.to_string())
}
pub fn normalize_link_target(href: &str) -> String {
let base = href.split('#').next().unwrap_or(href);
let base = base.split('?').next().unwrap_or(base);
let decoded = percent_encoding::percent_decode_str(base).decode_utf8_lossy();
decoded.trim_matches('/').to_string()
}
pub fn resolve_request_path(config: &PathResolverConfig, request_path: &str) -> ResolvedPath {
let vault = config.vault;
let mut static_checked = false;
if let Some(candidate) = safe_join(vault, request_path, config.exempt_hidden_dirs) {
let kind = vault
.stat(&candidate)
.ok()
.flatten()
.map(|entry| entry.kind);
if kind == Some(EntryKind::File) {
let name = Path::new(candidate.file_name().unwrap_or_default());
let key = vault.key(&candidate);
return if is_markdown_file(name, config.markdown_extensions) {
ResolvedPath::MarkdownFile(key)
} else {
ResolvedPath::StaticFile(key)
};
}
if kind == Some(EntryKind::Dir)
&& let Ok(index_path) = candidate.join(config.index_file)
&& vault.is_file(&index_path)
{
return ResolvedPath::MarkdownFile(vault.key(&index_path));
}
let index_stem = Path::new(config.index_file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("index");
if candidate.file_name() == Some(index_stem)
&& let Some(parent) = candidate.parent()
&& let Ok(index_path) = parent.join(config.index_file)
&& vault.is_file(&index_path)
{
let canonical = if parent.is_root() {
"/".to_string()
} else {
format!("/{parent}/")
};
return ResolvedPath::Redirect(canonical);
}
if let Some(md_path) = find_markdown_file(vault, &candidate, config.markdown_extensions) {
return ResolvedPath::MarkdownFile(vault.key(&md_path));
}
static_checked = true;
if let Some(static_path) = find_in_static_folder(config, request_path) {
return ResolvedPath::StaticFile(static_path);
}
if kind == Some(EntryKind::Dir) {
if let Ok(index_base) = candidate.join("index")
&& let Some(md_path) =
find_markdown_file(vault, &index_base, config.markdown_extensions)
{
return ResolvedPath::MarkdownFile(vault.key(&md_path));
}
return ResolvedPath::DirectoryListing(vault.key(&candidate));
}
}
if !static_checked && let Some(static_path) = find_in_static_folder(config, request_path) {
return ResolvedPath::StaticFile(static_path);
}
if let Some(tag_result) = try_resolve_tag_url(request_path, config.tag_sources) {
return tag_result;
}
ResolvedPath::NotFound
}
fn is_markdown_file(path: &Path, extensions: &[String]) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| extensions.iter().any(|md_ext| md_ext == ext))
.unwrap_or(false)
}
pub fn strip_trailing_separator(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
let trimmed = s.trim_end_matches(['/', std::path::MAIN_SEPARATOR]);
PathBuf::from(trimmed)
}
fn find_markdown_file(
vault: &dyn Vault,
base_path: &VaultPath,
extensions: &[String],
) -> Option<VaultPath> {
let file_name = base_path.file_name()?;
let parent = base_path.parent()?;
extensions
.iter()
.filter_map(|ext| parent.child(&format!("{file_name}.{ext}")).ok())
.find(|path| vault.is_file(path))
}
fn find_in_static_folder(config: &PathResolverConfig, request_path: &str) -> Option<PathBuf> {
let request = visible(VaultPath::new(request_path).ok()?, &[])?;
let (vault, static_dir) = match config.static_vault {
Some(overlay) => (overlay, VaultPath::root()),
None => (
config.vault,
crate::vault::configured_folder(config.vault, config.static_folder)?,
),
};
let static_dir = vault.canonicalize(&static_dir).ok()?;
let canonical = vault.canonicalize(&static_dir.join_path(&request)).ok()?;
(canonical.starts_with(&static_dir)
&& !hidden_below(&canonical, &static_dir)
&& vault.is_file(&canonical))
.then(|| vault.key(&canonical))
}
fn hidden_below(path: &VaultPath, base: &VaultPath) -> bool {
path.segments()
.skip(base.segments().count())
.any(crate::vault::is_hidden_segment)
}
fn try_resolve_tag_url(request_path: &str, tag_sources: &[String]) -> Option<ResolvedPath> {
if tag_sources.is_empty() {
return None;
}
let path = request_path.trim_matches('/');
if path.is_empty() {
return None;
}
let segments: Vec<&str> = path.split('/').collect();
match segments.len() {
1 => {
let source = segments[0].to_lowercase();
if tag_sources.iter().any(|s| s.to_lowercase() == source) {
Some(ResolvedPath::TagSourceIndex { source })
} else {
None
}
}
2 => {
let source = segments[0].to_lowercase();
let value = segments[1].to_lowercase();
if value.is_empty() {
return None;
}
if tag_sources.iter().any(|s| s.to_lowercase() == source) {
Some(ResolvedPath::TagPage { source, value })
} else {
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::LocalVault;
use std::fs;
use tempfile::TempDir;
fn safe_join_local(base: &Path, request: &str) -> Option<PathBuf> {
let vault = LocalVault::new(base);
safe_join(&vault, request, &[]).map(|p| vault.key(&p))
}
fn overlay_vault(root: &Path, static_folder: &str) -> Option<LocalVault> {
match crate::config::resolve_static_overlay(root, static_folder) {
Ok(crate::config::StaticOverlay::External(dir)) => Some(LocalVault::new(dir)),
_ => None,
}
}
struct TestFixture {
dir: TempDir,
vault: LocalVault,
extensions: Vec<String>,
tag_sources: Vec<String>,
}
impl TestFixture {
fn new() -> Self {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("static")).unwrap();
let vault = LocalVault::new(dir.path());
Self {
dir,
vault,
extensions: vec![String::from("md")],
tag_sources: vec![],
}
}
fn with_extensions(extensions: Vec<String>) -> Self {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("static")).unwrap();
let vault = LocalVault::new(dir.path());
Self {
dir,
vault,
extensions,
tag_sources: vec![],
}
}
fn with_tag_sources(tag_sources: Vec<String>) -> Self {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("static")).unwrap();
let vault = LocalVault::new(dir.path());
Self {
dir,
vault,
extensions: vec![String::from("md")],
tag_sources,
}
}
fn config(&self) -> PathResolverConfig<'_> {
PathResolverConfig {
vault: &self.vault,
static_vault: None,
static_folder: "static",
markdown_extensions: &self.extensions,
index_file: "index.md",
tag_sources: &self.tag_sources,
exempt_hidden_dirs: &[],
}
}
fn path(&self) -> &Path {
self.dir.path()
}
fn canonical_path(&self) -> PathBuf {
self.dir.path().canonicalize().unwrap()
}
}
#[test]
fn test_direct_markdown_file() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("readme.md"), "# Test").unwrap();
let result = resolve_request_path(&fixture.config(), "readme.md");
assert_eq!(
result,
ResolvedPath::MarkdownFile(fixture.canonical_path().join("readme.md"))
);
}
#[test]
fn test_direct_static_file() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("image.png"), "fake image").unwrap();
let result = resolve_request_path(&fixture.config(), "image.png");
assert_eq!(
result,
ResolvedPath::StaticFile(fixture.canonical_path().join("image.png"))
);
}
#[test]
fn test_directory_with_index() {
let fixture = TestFixture::new();
let subdir = fixture.path().join("docs");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join("index.md"), "# Docs").unwrap();
let result = resolve_request_path(&fixture.config(), "docs");
let expected = fixture.canonical_path().join("docs/index.md");
assert_eq!(result, ResolvedPath::MarkdownFile(expected));
}
#[test]
fn test_trailing_slash_to_markdown() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("about.md"), "# About").unwrap();
let result = resolve_request_path(&fixture.config(), "about/");
assert_eq!(
result,
ResolvedPath::MarkdownFile(fixture.canonical_path().join("about.md"))
);
}
#[test]
fn test_dotted_filename_trailing_slash_to_markdown() {
let fixture = TestFixture::new();
fs::write(
fixture.path().join("patrick-walsh-b.2010-03-03.md"),
"# Patrick",
)
.unwrap();
let result = resolve_request_path(&fixture.config(), "patrick-walsh-b.2010-03-03/");
assert_eq!(
result,
ResolvedPath::MarkdownFile(
fixture
.canonical_path()
.join("patrick-walsh-b.2010-03-03.md")
)
);
}
#[test]
fn test_dotted_filename_without_trailing_slash_to_markdown() {
let fixture = TestFixture::new();
fs::write(
fixture.path().join("patrick-walsh-b.2010-03-03.md"),
"# Patrick",
)
.unwrap();
let result = resolve_request_path(&fixture.config(), "patrick-walsh-b.2010-03-03");
assert_eq!(
result,
ResolvedPath::MarkdownFile(
fixture
.canonical_path()
.join("patrick-walsh-b.2010-03-03.md")
)
);
}
#[test]
fn test_multi_dot_filename_trailing_slash_to_markdown() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("report.2024.final.md"), "# Report").unwrap();
let result = resolve_request_path(&fixture.config(), "report.2024.final/");
assert_eq!(
result,
ResolvedPath::MarkdownFile(fixture.canonical_path().join("report.2024.final.md"))
);
}
#[test]
fn test_static_folder_file() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("static/style.css"), "body {}").unwrap();
let result = resolve_request_path(&fixture.config(), "style.css");
let expected = fixture
.path()
.join("static/style.css")
.canonicalize()
.unwrap();
assert_eq!(result, ResolvedPath::StaticFile(expected));
}
#[test]
fn test_static_folder_nested_path() {
let fixture = TestFixture::new();
fs::create_dir_all(fixture.path().join("static/images/blog")).unwrap();
fs::write(
fixture.path().join("static/images/blog/photo.png"),
"fake image",
)
.unwrap();
let result = resolve_request_path(&fixture.config(), "images/blog/photo.png");
let expected = fixture
.path()
.join("static/images/blog/photo.png")
.canonicalize()
.unwrap();
assert_eq!(result, ResolvedPath::StaticFile(expected));
}
#[test]
fn test_directory_listing() {
let fixture = TestFixture::new();
let subdir = fixture.path().join("posts");
fs::create_dir(&subdir).unwrap();
let result = resolve_request_path(&fixture.config(), "posts/");
let expected = fixture.canonical_path().join("posts");
assert_eq!(result, ResolvedPath::DirectoryListing(expected));
}
#[test]
fn test_not_found() {
let fixture = TestFixture::new();
let result = resolve_request_path(&fixture.config(), "nonexistent");
assert_eq!(result, ResolvedPath::NotFound);
}
#[test]
fn test_nested_directory_with_index() {
let fixture = TestFixture::new();
let nested = fixture.path().join("blog/2024");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("index.md"), "# Blog 2024").unwrap();
let result = resolve_request_path(&fixture.config(), "blog/2024");
let expected = fixture.canonical_path().join("blog/2024/index.md");
assert_eq!(result, ResolvedPath::MarkdownFile(expected));
}
#[test]
fn test_multiple_markdown_extensions() {
let fixture =
TestFixture::with_extensions(vec![String::from("md"), String::from("markdown")]);
fs::write(fixture.path().join("notes.markdown"), "# Notes").unwrap();
let result = resolve_request_path(&fixture.config(), "notes/");
assert_eq!(
result,
ResolvedPath::MarkdownFile(fixture.canonical_path().join("notes.markdown"))
);
}
#[test]
fn test_prefers_first_extension() {
let fixture =
TestFixture::with_extensions(vec![String::from("md"), String::from("markdown")]);
fs::write(fixture.path().join("test.md"), "# MD").unwrap();
fs::write(fixture.path().join("test.markdown"), "# Markdown").unwrap();
let result = resolve_request_path(&fixture.config(), "test/");
assert_eq!(
result,
ResolvedPath::MarkdownFile(fixture.canonical_path().join("test.md"))
);
}
#[test]
fn test_root_path_empty_string() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("index.md"), "# Home").unwrap();
let result = resolve_request_path(&fixture.config(), "");
assert_eq!(
result,
ResolvedPath::MarkdownFile(fixture.canonical_path().join("index.md"))
);
}
#[test]
fn test_is_markdown_file() {
let extensions = vec![String::from("md"), String::from("markdown")];
assert!(is_markdown_file(Path::new("test.md"), &extensions));
assert!(is_markdown_file(Path::new("test.markdown"), &extensions));
assert!(!is_markdown_file(Path::new("test.txt"), &extensions));
assert!(!is_markdown_file(Path::new("test"), &extensions));
}
#[test]
fn test_strip_trailing_separator() {
assert_eq!(
strip_trailing_separator(Path::new("/foo/bar/")),
PathBuf::from("/foo/bar")
);
assert_eq!(
strip_trailing_separator(Path::new("/foo/bar")),
PathBuf::from("/foo/bar")
);
assert_eq!(
strip_trailing_separator(Path::new("relative/")),
PathBuf::from("relative")
);
assert_eq!(
strip_trailing_separator(Path::new("/foo/bar//")),
PathBuf::from("/foo/bar")
);
}
#[cfg(windows)]
#[test]
fn test_strip_trailing_separator_windows_backslash() {
assert_eq!(
strip_trailing_separator(Path::new(r"\foo\bar\")),
PathBuf::from(r"\foo\bar")
);
assert_eq!(
strip_trailing_separator(Path::new(r"\foo\bar/")),
PathBuf::from(r"\foo\bar")
);
}
#[test]
fn test_normalize_link_target_plain_path() {
assert_eq!(normalize_link_target("docs/guide"), "docs/guide");
}
#[test]
fn test_normalize_link_target_decodes_encoded_spaces() {
assert_eq!(
normalize_link_target("/IronCore%20Swag%20T-shirts%20Gifts"),
"IronCore Swag T-shirts Gifts"
);
}
#[test]
fn test_normalize_link_target_strips_fragment() {
assert_eq!(normalize_link_target("/docs/guide/#section"), "docs/guide");
}
#[test]
fn test_normalize_link_target_strips_query() {
assert_eq!(normalize_link_target("/docs/guide/?x=1&y=2"), "docs/guide");
}
#[test]
fn test_normalize_link_target_strips_query_and_fragment_with_decoding() {
assert_eq!(
normalize_link_target("/My%20Page/?x=1#top"),
"My Page",
"fragment and query must be stripped before decoding/trimming"
);
}
#[test]
fn test_normalize_link_target_trims_leading_and_trailing_slashes() {
assert_eq!(normalize_link_target("/docs/guide/"), "docs/guide");
assert_eq!(normalize_link_target("docs/guide"), "docs/guide");
assert_eq!(normalize_link_target("/"), "");
}
#[test]
fn test_normalize_link_target_decodes_literal_percent_escape() {
assert_eq!(normalize_link_target("/100%25"), "100%");
}
#[test]
fn test_tag_source_index() {
let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
let result = resolve_request_path(&fixture.config(), "tags/");
assert_eq!(
result,
ResolvedPath::TagSourceIndex {
source: "tags".to_string()
}
);
}
#[test]
fn test_tag_source_index_without_trailing_slash() {
let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
let result = resolve_request_path(&fixture.config(), "tags");
assert_eq!(
result,
ResolvedPath::TagSourceIndex {
source: "tags".to_string()
}
);
}
#[test]
fn test_tag_page() {
let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
let result = resolve_request_path(&fixture.config(), "tags/rust/");
assert_eq!(
result,
ResolvedPath::TagPage {
source: "tags".to_string(),
value: "rust".to_string()
}
);
}
#[test]
fn test_tag_page_without_trailing_slash() {
let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
let result = resolve_request_path(&fixture.config(), "tags/rust");
assert_eq!(
result,
ResolvedPath::TagPage {
source: "tags".to_string(),
value: "rust".to_string()
}
);
}
#[test]
fn test_tag_url_case_insensitive_source() {
let fixture = TestFixture::with_tag_sources(vec!["Tags".to_string()]);
let result = resolve_request_path(&fixture.config(), "TAGS/rust/");
assert_eq!(
result,
ResolvedPath::TagPage {
source: "tags".to_string(),
value: "rust".to_string()
}
);
}
#[test]
fn test_tag_url_unknown_source_not_matched() {
let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
let result = resolve_request_path(&fixture.config(), "categories/rust/");
assert_eq!(result, ResolvedPath::NotFound);
}
#[test]
fn test_tag_url_no_sources_configured() {
let fixture = TestFixture::new(); let result = resolve_request_path(&fixture.config(), "tags/rust/");
assert_eq!(result, ResolvedPath::NotFound);
}
#[test]
fn test_tag_url_multiple_sources() {
let fixture = TestFixture::with_tag_sources(vec![
"tags".to_string(),
"performers".to_string(),
"taxonomy.categories".to_string(),
]);
assert_eq!(
resolve_request_path(&fixture.config(), "tags/rust/"),
ResolvedPath::TagPage {
source: "tags".to_string(),
value: "rust".to_string()
}
);
assert_eq!(
resolve_request_path(&fixture.config(), "performers/joshua_jay/"),
ResolvedPath::TagPage {
source: "performers".to_string(),
value: "joshua_jay".to_string()
}
);
assert_eq!(
resolve_request_path(&fixture.config(), "taxonomy.categories/"),
ResolvedPath::TagSourceIndex {
source: "taxonomy.categories".to_string()
}
);
}
#[test]
fn test_file_takes_precedence_over_tag_url() {
let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
fs::write(fixture.path().join("tags.md"), "# Real Tags Page").unwrap();
let result = resolve_request_path(&fixture.config(), "tags/");
assert_eq!(
result,
ResolvedPath::MarkdownFile(fixture.canonical_path().join("tags.md"))
);
}
#[test]
fn test_directory_takes_precedence_over_tag_url() {
let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
fs::create_dir(fixture.path().join("tags")).unwrap();
let result = resolve_request_path(&fixture.config(), "tags/");
assert_eq!(
result,
ResolvedPath::DirectoryListing(fixture.canonical_path().join("tags"))
);
}
#[test]
fn test_nested_tag_value_not_matched() {
let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
let result = resolve_request_path(&fixture.config(), "tags/rust/advanced/");
assert_eq!(result, ResolvedPath::NotFound);
}
#[test]
fn test_try_resolve_tag_url_directly() {
let sources = vec!["tags".to_string(), "performers".to_string()];
assert_eq!(
try_resolve_tag_url("tags/", &sources),
Some(ResolvedPath::TagSourceIndex {
source: "tags".to_string()
})
);
assert_eq!(
try_resolve_tag_url("tags/rust", &sources),
Some(ResolvedPath::TagPage {
source: "tags".to_string(),
value: "rust".to_string()
})
);
assert_eq!(try_resolve_tag_url("unknown/value", &sources), None);
assert_eq!(try_resolve_tag_url("", &sources), None);
assert_eq!(try_resolve_tag_url("tags/rust", &[]), None);
}
#[test]
fn test_non_canonical_index_redirects() {
let fixture = TestFixture::new();
let docs = fixture.path().join("docs");
fs::create_dir(&docs).unwrap();
fs::write(docs.join("index.md"), "# Docs Index").unwrap();
let result = resolve_request_path(&fixture.config(), "docs/index/");
assert_eq!(result, ResolvedPath::Redirect("/docs/".to_string()));
}
#[test]
fn test_root_index_redirects() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("index.md"), "# Home").unwrap();
let result = resolve_request_path(&fixture.config(), "index/");
assert_eq!(result, ResolvedPath::Redirect("/".to_string()));
}
#[test]
fn test_nested_index_redirects() {
let fixture = TestFixture::new();
let nested = fixture.path().join("a/b/c");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("index.md"), "# Nested").unwrap();
let result = resolve_request_path(&fixture.config(), "a/b/c/index/");
assert_eq!(result, ResolvedPath::Redirect("/a/b/c/".to_string()));
let ResolvedPath::Redirect(target) = result else {
panic!("expected a redirect");
};
assert!(
!target.contains('\\'),
"redirect target must not contain a backslash, got {target}"
);
}
#[test]
fn test_regular_file_named_index_no_redirect() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("index.md"), "# Regular Index").unwrap();
let docs = fixture.path().join("docs");
fs::create_dir(&docs).unwrap();
fs::write(docs.join("readme.md"), "# Readme").unwrap();
let result = resolve_request_path(&fixture.config(), "docs/readme/");
assert!(matches!(result, ResolvedPath::MarkdownFile(_)));
}
#[test]
fn test_index_without_trailing_slash_redirects() {
let fixture = TestFixture::new();
let docs = fixture.path().join("docs");
fs::create_dir(&docs).unwrap();
fs::write(docs.join("index.md"), "# Docs Index").unwrap();
let result = resolve_request_path(&fixture.config(), "docs/index");
assert_eq!(result, ResolvedPath::Redirect("/docs/".to_string()));
}
#[test]
fn test_path_traversal_blocked_with_dotdot() {
let fixture = TestFixture::new();
let attacks = vec![
"../../../etc/passwd",
"..%2F..%2F..%2Fetc/passwd",
"foo/../../../etc/passwd",
"foo/bar/../../../etc/passwd",
"....//....//etc/passwd",
];
for attack in attacks {
let result = resolve_request_path(&fixture.config(), attack);
assert_eq!(
result,
ResolvedPath::NotFound,
"Path traversal should be blocked for: {}",
attack
);
}
}
#[test]
fn test_path_traversal_blocked_in_static_folder() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("static/safe.txt"), "safe content").unwrap();
let attacks = vec![
"../readme.md", "../../etc/passwd", "foo/../../../etc/passwd",
];
for attack in &attacks {
let result = find_in_static_folder(&fixture.config(), attack);
assert!(
result.is_none(),
"Static folder path traversal should be blocked for: {}",
attack
);
}
let valid = find_in_static_folder(&fixture.config(), "safe.txt");
assert!(valid.is_some(), "Valid static file should be found");
}
#[test]
fn test_safe_join_blocks_traversal() {
let dir = TempDir::new().unwrap();
let base = dir.path();
fs::write(base.join("inside.txt"), "inside").unwrap();
let valid = safe_join_local(base, "inside.txt");
assert!(valid.is_some(), "Valid path should work");
assert!(valid.unwrap().ends_with("inside.txt"));
let attack = safe_join_local(base, "../../../etc/passwd");
assert!(attack.is_none(), "Path traversal should be blocked");
let attack2 = safe_join_local(base, "foo/../../../etc/passwd");
assert!(
attack2.is_none(),
"Complex path traversal should be blocked"
);
}
#[test]
fn test_safe_join_allows_internal_dotdot() {
let dir = TempDir::new().unwrap();
let base = dir.path();
fs::create_dir_all(base.join("foo/bar")).unwrap();
fs::write(base.join("foo/sibling.txt"), "sibling").unwrap();
let valid = safe_join_local(base, "foo/bar/../sibling.txt");
assert!(valid.is_some(), "Internal navigation should work");
let resolved = valid.unwrap();
assert!(
resolved.ends_with("sibling.txt"),
"Should resolve to sibling.txt, got: {:?}",
resolved
);
}
#[test]
fn test_path_traversal_returns_not_found_not_error() {
let fixture = TestFixture::new();
let result = resolve_request_path(&fixture.config(), "../../../../etc/passwd");
assert_eq!(result, ResolvedPath::NotFound);
}
#[test]
fn test_symlink_escape_blocked() {
let dir = TempDir::new().unwrap();
let base = dir.path();
fs::create_dir(base.join("static")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let link_path = base.join("static/escape");
if symlink("/tmp", &link_path).is_ok() {
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let vault = LocalVault::new(base);
let config = PathResolverConfig {
vault: &vault,
static_vault: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
let result = find_in_static_folder(&config, "escape/some_file");
assert!(result.is_none(), "Symlink escape should be blocked");
}
}
}
#[cfg(unix)]
#[test]
fn test_safe_join_blocks_symlink_to_absolute_path_outside_base() {
use std::os::unix::fs::symlink;
let outside = TempDir::new().unwrap();
let secret = outside.path().join("secret.txt");
fs::write(&secret, "top secret").unwrap();
let dir = TempDir::new().unwrap();
let base = dir.path();
symlink(&secret, base.join("passwd")).unwrap();
assert_eq!(
safe_join_local(base, "passwd"),
None,
"a symlink resolving outside the base must not be joined"
);
}
#[cfg(unix)]
#[test]
fn test_safe_join_blocks_relative_symlink_outside_base() {
use std::os::unix::fs::symlink;
let outer = TempDir::new().unwrap();
fs::write(outer.path().join("outside.txt"), "top secret").unwrap();
let base = outer.path().join("repo/nested");
fs::create_dir_all(&base).unwrap();
symlink("../../outside.txt", base.join("escape.txt")).unwrap();
assert_eq!(
safe_join_local(&base, "escape.txt"),
None,
"a relative symlink resolving outside the base must not be joined"
);
}
#[cfg(unix)]
#[test]
fn test_safe_join_allows_symlink_inside_base() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let base = dir.path();
fs::write(base.join("real.txt"), "inside").unwrap();
symlink("real.txt", base.join("alias.txt")).unwrap();
let joined = safe_join_local(base, "alias.txt").expect("in-base symlink should resolve");
assert_eq!(joined, base.canonicalize().unwrap().join("real.txt"));
}
#[test]
fn test_safe_join_existing_file_and_missing_sibling() {
let dir = TempDir::new().unwrap();
let base = dir.path();
let canonical = base.canonicalize().unwrap();
fs::create_dir(base.join("docs")).unwrap();
fs::write(base.join("docs/guide.md"), "# Guide").unwrap();
assert_eq!(
safe_join_local(base, "docs/guide.md"),
Some(canonical.join("docs/guide.md")),
"an existing in-base file must resolve"
);
assert_eq!(
safe_join_local(base, "docs/guide"),
Some(canonical.join("docs/guide")),
"a not-yet-existing name under an in-base parent must resolve"
);
}
#[test]
fn test_find_in_static_folder_serves_peer_overlay_but_contains_requests() {
let outer = TempDir::new().unwrap();
let peer = outer.path().join("static");
fs::create_dir(&peer).unwrap();
fs::write(peer.join("logo.png"), "PNG").unwrap();
fs::write(outer.path().join("id_rsa"), "PRIVATE KEY").unwrap();
let base = outer.path().join("content");
fs::create_dir(&base).unwrap();
let canonical = base.canonicalize().unwrap();
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let vault = LocalVault::new(&base);
let peer_vault = overlay_vault(&canonical, "../static").expect("peer overlay accepted");
let peer_overlay = PathResolverConfig {
vault: &vault,
static_vault: Some(&peer_vault),
static_folder: "../static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
assert_eq!(
find_in_static_folder(&peer_overlay, "logo.png"),
Some(peer.canonicalize().unwrap().join("logo.png")),
"a peer static folder must serve its own files"
);
assert!(
matches!(
resolve_request_path(&peer_overlay, "logo.png"),
ResolvedPath::StaticFile(_)
),
"the resolver must route the peer overlay's files as static files"
);
for attack in ["../id_rsa", "../../etc/passwd", "sub/../../id_rsa"] {
assert_eq!(
find_in_static_folder(&peer_overlay, attack),
None,
"a request path must not climb out of the static overlay: {attack}"
);
}
let absolute = peer.to_string_lossy().into_owned();
let absolute_vault = overlay_vault(&canonical, &absolute).expect("absolute overlay");
let absolute_overlay = PathResolverConfig {
static_folder: &absolute,
static_vault: Some(&absolute_vault),
..peer_overlay
};
assert_eq!(
find_in_static_folder(&absolute_overlay, "logo.png"),
Some(peer.canonicalize().unwrap().join("logo.png")),
"an absolute static folder must serve its own files"
);
assert_eq!(
find_in_static_folder(&absolute_overlay, "../id_rsa"),
None,
"an absolute static folder must still contain request paths"
);
}
#[cfg(unix)]
#[test]
fn test_find_in_static_folder_blocks_symlink_out_of_overlay() {
use std::os::unix::fs::symlink;
let outer = TempDir::new().unwrap();
let secrets = outer.path().join("secrets");
fs::create_dir(&secrets).unwrap();
fs::write(secrets.join("passwd"), "root:x:0:0").unwrap();
let base = outer.path().join("content");
fs::create_dir(&base).unwrap();
let canonical = base.canonicalize().unwrap();
let peer = outer.path().join("static");
fs::create_dir(&peer).unwrap();
symlink(secrets.join("passwd"), peer.join("passwd")).unwrap();
symlink(&secrets, peer.join("leak")).unwrap();
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let vault = LocalVault::new(&base);
let peer_vault = overlay_vault(&canonical, "../static").expect("peer overlay accepted");
let config = PathResolverConfig {
vault: &vault,
static_vault: Some(&peer_vault),
static_folder: "../static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
for attack in ["passwd", "leak/passwd"] {
assert_eq!(
find_in_static_folder(&config, attack),
None,
"a symlink out of the static overlay must not be served: {attack}"
);
assert_eq!(
resolve_request_path(&config, attack),
ResolvedPath::NotFound,
"the resolver must 404 a symlink out of the static overlay: {attack}"
);
}
}
#[test]
fn test_precedence_base_dir_over_static() {
let fixture = TestFixture::new();
fs::write(fixture.path().join("image.png"), "direct").unwrap();
fs::write(fixture.path().join("static/image.png"), "static").unwrap();
let result = resolve_request_path(&fixture.config(), "image.png");
assert_eq!(
result,
ResolvedPath::StaticFile(fixture.canonical_path().join("image.png"))
);
let resolved_path = match result {
ResolvedPath::StaticFile(p) => p,
_ => panic!("Expected StaticFile"),
};
let content = fs::read_to_string(resolved_path).unwrap();
assert_eq!(
content, "direct",
"Should serve file from base_dir, not static folder"
);
}
#[test]
fn test_safe_join_failure_static_fallback() {
let fixture = TestFixture::new();
fs::create_dir_all(fixture.path().join("static/images/blog")).unwrap();
fs::write(fixture.path().join("static/images/blog/photo.png"), "image").unwrap();
let result = resolve_request_path(&fixture.config(), "images/blog/photo.png");
let expected = fixture
.path()
.join("static/images/blog/photo.png")
.canonicalize()
.unwrap();
assert_eq!(result, ResolvedPath::StaticFile(expected));
}
#[test]
fn test_empty_static_folder_config() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("static")).unwrap();
fs::write(dir.path().join("static/file.txt"), "content").unwrap();
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let vault = LocalVault::new(dir.path());
let config = PathResolverConfig {
vault: &vault,
static_vault: None,
static_folder: "", markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
let result = resolve_request_path(&config, "file.txt");
assert_eq!(result, ResolvedPath::NotFound);
}
#[test]
fn test_deeply_nested_static_path() {
let fixture = TestFixture::new();
fs::create_dir_all(fixture.path().join("static/a/b/c/d/e")).unwrap();
fs::write(fixture.path().join("static/a/b/c/d/e/deep.png"), "deep").unwrap();
let result = resolve_request_path(&fixture.config(), "a/b/c/d/e/deep.png");
let expected = fixture
.path()
.join("static/a/b/c/d/e/deep.png")
.canonicalize()
.unwrap();
assert_eq!(result, ResolvedPath::StaticFile(expected));
}
#[test]
fn test_static_folder_with_trailing_slash_request() {
let fixture = TestFixture::new();
fs::create_dir_all(fixture.path().join("static/images")).unwrap();
fs::write(fixture.path().join("static/images/photo.png"), "img").unwrap();
let result = resolve_request_path(&fixture.config(), "images/photo.png/");
let expected = fixture
.path()
.join("static/images/photo.png")
.canonicalize()
.unwrap();
assert_eq!(result, ResolvedPath::StaticFile(expected));
}
#[test]
fn test_static_folder_url_encoded_spaces() {
let fixture = TestFixture::new();
fs::create_dir_all(fixture.path().join("static/my images")).unwrap();
fs::write(
fixture.path().join("static/my images/photo file.jpg"),
"img",
)
.unwrap();
let result = resolve_request_path(&fixture.config(), "my images/photo file.jpg");
let expected = fixture
.path()
.join("static/my images/photo file.jpg")
.canonicalize()
.unwrap();
assert_eq!(result, ResolvedPath::StaticFile(expected));
}
fn mem_fixture() -> crate::vault::MemVault {
crate::vault::MemVault::new()
.with_file("readme.md", "# R")
.with_file("docs/index.md", "# D")
.with_file("docs/guide.md", "# G")
.with_file("notes/a.b.md", "# AB")
.with_file("img.png", "png")
.with_file("static/images/photo.png", "img")
.with_file("static/readme.md", "# shadowed")
.with_dir("empty")
}
#[test]
fn test_mem_vault_resolves_every_kind() {
let vault = mem_fixture();
let extensions = vec![String::from("md")];
let tag_sources = vec![String::from("tags")];
let config = PathResolverConfig {
vault: &vault,
static_vault: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
let root = PathBuf::from(crate::vault::MemVault::DEFAULT_ROOT);
let key = |rel: &str| VaultPath::new(rel).unwrap().to_native(&root);
let cases = [
("readme.md", ResolvedPath::MarkdownFile(key("readme.md"))),
("readme/", ResolvedPath::MarkdownFile(key("readme.md"))),
("readme", ResolvedPath::MarkdownFile(key("readme.md"))),
("docs", ResolvedPath::MarkdownFile(key("docs/index.md"))),
("docs/", ResolvedPath::MarkdownFile(key("docs/index.md"))),
("docs/index/", ResolvedPath::Redirect("/docs/".to_string())),
(
"docs/guide/",
ResolvedPath::MarkdownFile(key("docs/guide.md")),
),
(
"notes/a.b/",
ResolvedPath::MarkdownFile(key("notes/a.b.md")),
),
("img.png", ResolvedPath::StaticFile(key("img.png"))),
(
"images/photo.png",
ResolvedPath::StaticFile(key("static/images/photo.png")),
),
("empty/", ResolvedPath::DirectoryListing(key("empty"))),
("notes", ResolvedPath::DirectoryListing(key("notes"))),
("", ResolvedPath::DirectoryListing(root.clone())),
("missing/", ResolvedPath::NotFound),
("../readme.md", ResolvedPath::NotFound),
("/readme.md", ResolvedPath::NotFound),
(
"tags/rust/",
ResolvedPath::TagPage {
source: "tags".to_string(),
value: "rust".to_string(),
},
),
];
for (request, expected) in cases {
assert_eq!(
resolve_request_path(&config, request),
expected,
"{request:?}"
);
}
}
#[test]
fn test_mem_vault_external_overlay() {
let vault = mem_fixture();
let overlay = crate::vault::MemVault::with_root("/mbr-mem-overlay")
.with_file("logo.png", "PNG")
.with_file("css/site.css", "body{}");
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let config = PathResolverConfig {
vault: &vault,
static_vault: Some(&overlay),
static_folder: "../static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
assert_eq!(
resolve_request_path(&config, "css/site.css"),
ResolvedPath::StaticFile(
PathBuf::from("/mbr-mem-overlay")
.join("css")
.join("site.css")
)
);
assert_eq!(
resolve_request_path(&config, "images/photo.png"),
ResolvedPath::NotFound
);
assert_eq!(find_in_static_folder(&config, "../logo.png"), None);
}
#[test]
fn test_root_request_never_probes_a_sibling_of_the_repository() {
let outer = TempDir::new().unwrap();
let repo = outer.path().join("notes");
fs::create_dir(&repo).unwrap();
fs::write(outer.path().join("notes.md"), "# Outside the repository").unwrap();
let vault = LocalVault::new(&repo);
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let config = PathResolverConfig {
vault: &vault,
static_vault: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
assert_eq!(
resolve_request_path(&config, ""),
ResolvedPath::DirectoryListing(vault.root().to_path_buf())
);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use crate::vault::LocalVault;
use proptest::prelude::*;
use std::fs;
use tempfile::TempDir;
fn path_component_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9_-]{1,12}"
}
fn extension_strategy() -> impl Strategy<Value = String> {
"[a-z]{1,5}"
}
proptest! {
#[test]
fn prop_is_markdown_file_deterministic(
filename in path_component_strategy(),
ext in extension_strategy(),
extensions in proptest::collection::vec(extension_strategy(), 1..4)
) {
let path = PathBuf::from(format!("{}.{}", filename, ext));
let result1 = is_markdown_file(&path, &extensions);
let result2 = is_markdown_file(&path, &extensions);
prop_assert_eq!(result1, result2);
}
#[test]
fn prop_is_markdown_file_matches_extension(
filename in path_component_strategy(),
extensions in proptest::collection::vec(extension_strategy(), 1..4)
) {
if let Some(ext) = extensions.first() {
let path = PathBuf::from(format!("{}.{}", filename, ext));
prop_assert!(is_markdown_file(&path, &extensions));
}
}
#[test]
fn prop_strip_trailing_separator_idempotent(
components in proptest::collection::vec(path_component_strategy(), 1..5)
) {
let path_str = format!("/{}/", components.join("/"));
let path = Path::new(&path_str);
let once = strip_trailing_separator(path);
let twice = strip_trailing_separator(&once);
prop_assert_eq!(once, twice);
}
#[test]
fn prop_strip_trailing_separator_no_trailing(
components in proptest::collection::vec(path_component_strategy(), 1..5)
) {
let path_str = format!("/{}/", components.join("/"));
let path = Path::new(&path_str);
let result = strip_trailing_separator(path);
let result_str = result.to_string_lossy();
prop_assert!(
!result_str.ends_with('/'),
"Result {:?} should not end with /",
result_str
);
prop_assert!(
!result_str.ends_with(std::path::MAIN_SEPARATOR),
"Result {:?} should not end with the platform separator",
result_str
);
}
#[test]
fn prop_path_resolution_deterministic(
request_path in proptest::collection::vec(path_component_strategy(), 0..3)
) {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("static")).unwrap();
fs::write(dir.path().join("test.md"), "# Test").unwrap();
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let vault = LocalVault::new(dir.path());
let config = PathResolverConfig {
vault: &vault,
static_vault: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
let path_str = request_path.join("/");
let result1 = resolve_request_path(&config, &path_str);
let result2 = resolve_request_path(&config, &path_str);
prop_assert_eq!(result1, result2);
}
#[test]
fn prop_path_traversal_no_panic(
prefix in proptest::collection::vec(path_component_strategy(), 0..2),
suffix in proptest::collection::vec(path_component_strategy(), 0..2)
) {
let dir = TempDir::new().unwrap();
let base_dir = dir.path();
fs::create_dir(base_dir.join("static")).unwrap();
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let vault = LocalVault::new(base_dir);
let config = PathResolverConfig {
vault: &vault,
static_vault: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
let attack_paths = vec![
format!("{}/../{}", prefix.join("/"), suffix.join("/")),
format!("../{}", suffix.join("/")),
format!("{}/../../{}", prefix.join("/"), suffix.join("/")),
];
for attack_path in attack_paths {
let result1 = resolve_request_path(&config, &attack_path);
let result2 = resolve_request_path(&config, &attack_path);
prop_assert_eq!(result1, result2, "Results should be deterministic for {:?}", attack_path);
}
}
#[test]
fn prop_resolved_paths_stay_inside_the_vault(
request in "[a-z./\\\\%:C]{0,24}"
) {
let vault = crate::vault::MemVault::new()
.with_file("a/b.md", "# B")
.with_file("static/c.png", "c")
.with_file("d.md", "# D");
let extensions = vec![String::from("md")];
let tag_sources: Vec<String> = vec![];
let config = PathResolverConfig {
vault: &vault,
static_vault: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: &[],
};
match resolve_request_path(&config, &request) {
ResolvedPath::StaticFile(p)
| ResolvedPath::MarkdownFile(p)
| ResolvedPath::DirectoryListing(p) => {
prop_assert!(
p.starts_with(crate::vault::MemVault::DEFAULT_ROOT),
"{:?} resolved to {:?}", request, p
);
}
ResolvedPath::Redirect(url) => {
prop_assert!(url.starts_with('/') && !url.contains(".."), "{:?}", url);
}
_ => {}
}
}
}
}
#[cfg(test)]
mod hidden_and_mount_tests {
use super::*;
use crate::vault::LocalVault;
#[cfg(unix)]
use crate::vault::MountPolicy;
use std::fs;
fn visible_tempdir() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("mbr-resolver-test-")
.tempdir()
.unwrap()
}
fn resolve(
vault: &dyn Vault,
static_vault: Option<&dyn Vault>,
exempt: &[VaultPath],
request: &str,
) -> ResolvedPath {
let extensions = vec![String::from("md")];
let tag_sources = vec![String::from("tags")];
let config = PathResolverConfig {
vault,
static_vault,
static_folder: if static_vault.is_some() {
"../static"
} else {
"static"
},
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
exempt_hidden_dirs: exempt,
};
resolve_request_path(&config, request)
}
#[test]
fn hidden_files_and_directories_are_never_resolved() {
let dir = visible_tempdir();
let root = dir.path().canonicalize().unwrap();
for (path, body) in [
(".env", "SECRET=1"),
(".git/config", "[remote]"),
(".git/notes.md", "# in git"),
("docs/.private/plan.md", "# plan"),
("docs/.hidden.png", "png"),
("docs/visible.md", "# ok"),
("static/.htpasswd", "user:hash"),
("static/images/.DS_Store", "junk"),
("static/images/logo.png", "png"),
("static/.well-known/security.txt", "Contact: x"),
(".mbr/config.toml", "edit_token_hash = 'x'"),
] {
let file = root.join(path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(file, body).unwrap();
}
let vault = LocalVault::new(&root);
for request in [
".env",
".git/config",
".git",
".git/",
".git/notes/",
"docs/.private/plan/",
"docs/.private/plan.md",
"docs/.hidden.png",
".htpasswd",
"static/.htpasswd",
"images/.DS_Store",
".mbr/config.toml",
"docs/../.env",
] {
assert_eq!(
resolve(&vault, None, &[], request),
ResolvedPath::NotFound,
"{request} must not resolve"
);
}
assert!(matches!(
resolve(&vault, None, &[], "docs/visible/"),
ResolvedPath::MarkdownFile(_)
));
assert!(matches!(
resolve(&vault, None, &[], "images/logo.png"),
ResolvedPath::StaticFile(_)
));
assert!(matches!(
resolve(&vault, None, &[], ".well-known/security.txt"),
ResolvedPath::StaticFile(_)
));
assert!(matches!(
resolve(&vault, None, &[], "tags/.net/"),
ResolvedPath::TagPage { .. }
));
}
#[test]
fn an_explicitly_named_hidden_directory_is_served() {
let dir = visible_tempdir();
let root = dir.path().canonicalize().unwrap();
fs::create_dir_all(root.join(".scratch/.git")).unwrap();
fs::write(root.join(".scratch/alpha.md"), "# Alpha").unwrap();
fs::write(root.join(".scratch/.git/config"), "x").unwrap();
let vault = LocalVault::new(&root);
let exempt = [VaultPath::new(".scratch").unwrap()];
assert!(matches!(
resolve(&vault, None, &exempt, ".scratch/alpha/"),
ResolvedPath::MarkdownFile(_)
));
assert_eq!(
resolve(&vault, None, &exempt, ".scratch/.git/config"),
ResolvedPath::NotFound
);
assert_eq!(
resolve(&vault, None, &[], ".scratch/alpha/"),
ResolvedPath::NotFound
);
}
#[cfg(unix)]
#[test]
fn a_visible_link_to_a_hidden_directory_is_not_resolved() {
let dir = visible_tempdir();
let root = dir.path().canonicalize().unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".git/config"), "x").unwrap();
std::os::unix::fs::symlink(root.join(".git"), root.join("notes")).unwrap();
let vault = LocalVault::new(&root);
assert_eq!(
resolve(&vault, None, &[], "notes/config"),
ResolvedPath::NotFound
);
}
#[cfg(unix)]
struct MountSite {
_dir: tempfile::TempDir,
base: PathBuf,
content: PathBuf,
overlay: PathBuf,
movies: PathBuf,
extra: PathBuf,
}
#[cfg(unix)]
impl MountSite {
fn new() -> Self {
use std::os::unix::fs::symlink;
let dir = visible_tempdir();
let base = dir.path().canonicalize().unwrap();
let content = base.join("site/content");
let overlay = base.join("site/static");
let movies = base.join("movies");
let extra = base.join("extra");
for (path, body) in [
("site/content/index.md", "# Home"),
("site/content/notes/a.md", "# A"),
("site/content/.env", "SECRET=1"),
("site/static/images/logo.png", "png"),
("movies/clip.mp4", "clip"),
("movies/notes.md", "# Mounted"),
("movies/sub/deep.mp4", "deep"),
("movies/.secret/key", "key"),
("movies/.dotfile", "dot"),
("extra/x.mp4", "x"),
("home/diary.md", "diary"),
(".hidden/key", "key"),
("secret.txt", "secret"),
] {
let file = base.join(path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(file, body).unwrap();
}
symlink(&movies, overlay.join("videos")).unwrap();
symlink(&movies, content.join("media")).unwrap();
symlink(&extra, movies.join("more")).unwrap();
symlink(content.join("notes"), movies.join("back")).unwrap();
symlink(&movies, movies.join("loop")).unwrap();
symlink("/", overlay.join("fsroot")).unwrap();
symlink(base.join("home"), overlay.join("home")).unwrap();
symlink(&base, content.join("up")).unwrap();
symlink("../..", movies.join("climb")).unwrap();
symlink(base.join(".hidden"), overlay.join("hidden")).unwrap();
Self {
_dir: dir,
base,
content,
overlay,
movies,
extra,
}
}
fn policy(&self, other: &Path) -> MountPolicy {
MountPolicy::default()
.with_home(Some(self.base.join("home")))
.with_other_root(other)
}
fn vaults(&self) -> (LocalVault, LocalVault) {
(
LocalVault::with_mounts(&self.content, self.policy(&self.overlay)),
LocalVault::with_mounts(
&self.overlay,
self.policy(&self.content).with_markdown_root(&self.content),
),
)
}
fn allowed(&self, key: &Path) -> bool {
let Ok(real) = key.canonicalize() else {
return false;
};
[&self.content, &self.overlay, &self.movies, &self.extra]
.iter()
.any(|root| {
real.strip_prefix(root).is_ok_and(|below| {
!below.components().any(|c| {
c.as_os_str()
.to_str()
.is_none_or(crate::vault::is_hidden_segment)
})
})
})
}
}
#[cfg(unix)]
#[test]
fn mounts_are_served_and_refused_targets_are_not() {
let site = MountSite::new();
let (vault, overlay) = site.vaults();
let served = |request: &str| match resolve(&vault, Some(&overlay), &[], request) {
ResolvedPath::StaticFile(key) | ResolvedPath::MarkdownFile(key) => {
Some(fs::read_to_string(key).unwrap())
}
_ => None,
};
assert_eq!(served("videos/clip.mp4").as_deref(), Some("clip"));
assert_eq!(served("videos/sub/deep.mp4").as_deref(), Some("deep"));
assert_eq!(served("videos/more/x.mp4").as_deref(), Some("x"));
assert_eq!(served("media/clip.mp4").as_deref(), Some("clip"));
assert_eq!(served("media/notes/").as_deref(), Some("# Mounted"));
assert_eq!(served("media/back/a/").as_deref(), Some("# A"));
assert_eq!(served("media/loop/loop/clip.mp4").as_deref(), Some("clip"));
for refused in [
"videos/.secret/key",
"videos/.dotfile",
"media/.secret/key",
"fsroot/etc/hosts",
"home/diary.md",
"up/secret.txt",
"up/home/diary.md",
"media/climb/secret.txt",
"videos/back/a.md",
"hidden/key",
"videos/../../secret.txt",
"videos/%2e%2e/%2e%2e/secret.txt",
".env",
] {
assert_eq!(served(refused), None, "{refused} must not be served");
}
let mut targets: Vec<PathBuf> = vault
.mounts()
.into_iter()
.chain(overlay.mounts())
.map(|m| m.target)
.collect();
targets.sort();
targets.dedup();
let mut expected = vec![site.movies.clone(), site.extra.clone()];
expected.sort();
assert_eq!(
targets, expected,
"only the two legitimate targets are mounted"
);
}
#[cfg(unix)]
mod prop {
use super::*;
use proptest::prelude::*;
const SEGMENTS: &[&str] = &[
"videos",
"media",
"more",
"back",
"loop",
"climb",
"up",
"fsroot",
"home",
"hidden",
"sub",
"notes",
"images",
"etc",
"clip.mp4",
"deep.mp4",
"x.mp4",
"logo.png",
"a",
"a.md",
"index",
"secret.txt",
"diary.md",
"key",
".secret",
".dotfile",
".env",
".hidden",
"..",
".",
"%2e%2e",
"static",
"site",
"content",
"movies",
"extra",
];
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn prop_nothing_outside_the_served_roots_resolves(
requests in proptest::collection::vec(
(proptest::collection::vec(proptest::sample::select(SEGMENTS), 0..7), any::<bool>()),
1..12,
)
) {
let site = MountSite::new();
let (vault, overlay) = site.vaults();
for (segments, trailing) in requests {
let mut request = segments.join("/");
if trailing {
request.push('/');
}
match resolve(&vault, Some(&overlay), &[], &request) {
ResolvedPath::StaticFile(key)
| ResolvedPath::MarkdownFile(key)
| ResolvedPath::DirectoryListing(key) => {
prop_assert!(site.allowed(&key), "{request:?} served {key:?}");
}
_ => {}
}
}
for mount in vault.mounts().into_iter().chain(overlay.mounts()) {
prop_assert!(
mount.target == site.movies || mount.target == site.extra,
"unexpected mount {mount:?}"
);
}
}
}
}
}