use std::path::{Path, PathBuf};
fn safe_join(
base_dir: &Path,
canonical_base_dir: Option<&Path>,
request_path: &str,
) -> Option<PathBuf> {
let owned_canonical;
let canonical_base = match canonical_base_dir {
Some(cached) => cached,
None => {
owned_canonical = base_dir.canonicalize().ok()?;
&owned_canonical
}
};
let candidate = canonical_base.join(request_path);
if let Ok(canonical) = candidate.canonicalize()
&& canonical.starts_with(canonical_base)
{
return Some(canonical);
}
if let Some(parent) = candidate.parent()
&& let Ok(canonical_parent) = parent.canonicalize()
&& canonical_parent.starts_with(canonical_base)
&& let Some(filename) = candidate.file_name()
{
return Some(canonical_parent.join(filename));
}
None
}
#[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)]
pub struct PathResolverConfig<'a> {
pub base_dir: &'a Path,
pub canonical_base_dir: Option<&'a Path>,
pub static_folder: &'a str,
pub markdown_extensions: &'a [String],
pub index_file: &'a str,
pub tag_sources: &'a [String],
}
pub fn resolve_request_path(config: &PathResolverConfig, request_path: &str) -> ResolvedPath {
if let Some(candidate_path) =
safe_join(config.base_dir, config.canonical_base_dir, request_path)
{
if candidate_path.is_file() {
return if is_markdown_file(&candidate_path, config.markdown_extensions) {
ResolvedPath::MarkdownFile(candidate_path)
} else {
ResolvedPath::StaticFile(candidate_path)
};
}
if candidate_path.is_dir() {
let index_path = candidate_path.join(config.index_file);
if index_path.is_file() {
return ResolvedPath::MarkdownFile(index_path);
}
}
let candidate_base = strip_trailing_separator(&candidate_path);
let index_stem = Path::new(config.index_file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("index");
if let Some(file_name) = candidate_base.file_name().and_then(|f| f.to_str())
&& file_name == index_stem
{
if let Some(parent) = candidate_base.parent() {
let index_path = parent.join(config.index_file);
if index_path.is_file() {
let owned_base;
let canonical_base = match config.canonical_base_dir {
Some(cached) => Some(cached),
None => {
owned_base = config.base_dir.canonicalize().ok();
owned_base.as_deref()
}
};
let canonical = canonical_base
.and_then(|base| pathdiff::diff_paths(parent, base))
.map(|p| {
let s = p.to_string_lossy();
if s.is_empty() {
"/".to_string()
} else {
format!("/{}/", s)
}
})
.unwrap_or_else(|| "/".to_string());
return ResolvedPath::Redirect(canonical);
}
}
}
if let Some(md_path) = find_markdown_file(&candidate_base, config.markdown_extensions) {
return ResolvedPath::MarkdownFile(md_path);
}
if let Some(static_path) = find_in_static_folder(config, request_path) {
return ResolvedPath::StaticFile(static_path);
}
if candidate_base.is_dir() {
let index_base = candidate_base.join("index");
if let Some(md_path) = find_markdown_file(&index_base, config.markdown_extensions) {
return ResolvedPath::MarkdownFile(md_path);
}
return ResolvedPath::DirectoryListing(candidate_base);
}
}
if 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)
}
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(base_path: &Path, extensions: &[String]) -> Option<PathBuf> {
extensions
.iter()
.map(|ext| {
let mut path = base_path.to_path_buf();
path.set_extension(ext);
path
})
.find(|path| path.is_file())
}
fn find_in_static_folder(config: &PathResolverConfig, request_path: &str) -> Option<PathBuf> {
let static_dir = match config.canonical_base_dir {
Some(cached) => {
let dir = cached.join(config.static_folder);
dir.canonicalize().ok()?
}
None => config
.base_dir
.join(config.static_folder)
.canonicalize()
.ok()?,
};
let candidate = static_dir.join(request_path);
let canonical = candidate.canonicalize().ok()?;
if canonical.starts_with(&static_dir) && canonical.is_file() {
Some(canonical)
} else {
None
}
}
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 std::fs;
use tempfile::TempDir;
struct TestFixture {
dir: TempDir,
canonical: PathBuf,
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 canonical = dir.path().canonicalize().unwrap();
Self {
dir,
canonical,
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 canonical = dir.path().canonicalize().unwrap();
Self {
dir,
canonical,
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 canonical = dir.path().canonicalize().unwrap();
Self {
dir,
canonical,
extensions: vec![String::from("md")],
tag_sources,
}
}
fn config(&self) -> PathResolverConfig<'_> {
PathResolverConfig {
base_dir: self.dir.path(),
canonical_base_dir: Some(&self.canonical),
static_folder: "static",
markdown_extensions: &self.extensions,
index_file: "index.md",
tag_sources: &self.tag_sources,
}
}
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_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")
);
}
#[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()));
}
#[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(base, None, "inside.txt");
assert!(valid.is_some(), "Valid path should work");
assert!(valid.unwrap().ends_with("inside.txt"));
let attack = safe_join(base, None, "../../../etc/passwd");
assert!(attack.is_none(), "Path traversal should be blocked");
let attack2 = safe_join(base, None, "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(base, None, "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 config = PathResolverConfig {
base_dir: base,
canonical_base_dir: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
};
let result = find_in_static_folder(&config, "escape/some_file");
assert!(result.is_none(), "Symlink escape should be blocked");
}
}
}
#[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 config = PathResolverConfig {
base_dir: dir.path(),
canonical_base_dir: None,
static_folder: "", markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
};
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/");
#[cfg(target_os = "macos")]
{
let expected = fixture
.path()
.join("static/images/photo.png")
.canonicalize()
.unwrap();
assert_eq!(result, ResolvedPath::StaticFile(expected));
}
#[cfg(target_os = "linux")]
{
assert_eq!(result, ResolvedPath::NotFound);
}
}
#[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));
}
}
#[cfg(test)]
mod proptests {
use super::*;
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
);
}
#[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 config = PathResolverConfig {
base_dir: dir.path(),
canonical_base_dir: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
};
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 config = PathResolverConfig {
base_dir,
canonical_base_dir: None,
static_folder: "static",
markdown_extensions: &extensions,
index_file: "index.md",
tag_sources: &tag_sources,
};
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);
}
}
}
}