Skip to main content

mbr_core/
path_resolver.rs

1//! Path resolution logic for the mbr server.
2//!
3//! This module contains pure functions for determining what resource to serve
4//! based on a URL path. By keeping this logic separate from I/O, it becomes
5//! easily testable. Every filesystem question it asks goes through a
6//! [`Vault`]: `stat` for the probes, [`resolve_under`] for containment.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use crate::vault::{EntryKind, Vault, VaultPath, has_hidden_segment, resolve_under};
12
13/// Safely resolves a request path inside the vault, preventing path traversal.
14///
15/// Returns `None` if the path is not a valid [`VaultPath`] (it climbs above
16/// the root, or is absolute), resolves outside the vault once symlinks are
17/// followed, or is **hidden** — see [`visible`]. See [`resolve_under`] for
18/// the exact containment answers.
19///
20/// # Security
21///
22/// This function guards against path traversal attacks by:
23/// 1. Normalizing the request lexically, refusing any `..` that would climb
24///    above the root
25/// 2. Canonicalizing the joined path inside the vault
26/// 3. Verifying the resolved path is still inside the vault
27/// 4. Refusing a hidden request *and* a hidden resolution
28fn safe_join(vault: &dyn Vault, request_path: &str, exempt: &[VaultPath]) -> Option<VaultPath> {
29    let request = visible(VaultPath::new(request_path).ok()?, exempt)?;
30    visible(resolve_under(vault, &VaultPath::root(), &request)?, exempt)
31}
32
33/// `path`, unless it has a hidden segment the user did not name
34/// ([`has_hidden_segment`]).
35///
36/// Hidden files are never served — not from the repository, the static
37/// overlay or a symlink mount — because dot paths are where a repository keeps
38/// credentials and tooling state (`.env`, `.git/config`). Checked on the
39/// request (`/.env`) and again on what it resolved to, so a link that is not
40/// itself hidden cannot reach a hidden target (`notes -> .git`). `/.mbr/*` never
41/// gets here: those are routes, matched before the page handler.
42fn visible(path: VaultPath, exempt: &[VaultPath]) -> Option<VaultPath> {
43    (!has_hidden_segment(&path, exempt)).then_some(path)
44}
45
46/// The result of resolving a URL path to a resource.
47///
48/// Paths are index keys ([`Vault::key`]) — for a [`crate::vault::LocalVault`],
49/// canonical filesystem paths, as they have always been.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ResolvedPath {
52    /// Serve a static file directly (non-markdown)
53    StaticFile(PathBuf),
54    /// Render a markdown file
55    MarkdownFile(PathBuf),
56    /// Generate a directory listing
57    DirectoryListing(PathBuf),
58    /// Render a tag page listing all pages with this tag
59    TagPage {
60        /// The tag source (e.g., "tags", "performers", "taxonomy.tags")
61        source: String,
62        /// The normalized tag value (e.g., "rust", "joshua_jay")
63        value: String,
64    },
65    /// Render a tag source index listing all tags from this source
66    TagSourceIndex {
67        /// The tag source (e.g., "tags", "performers")
68        source: String,
69    },
70    /// Resource not found
71    NotFound,
72    /// Redirect to canonical URL (e.g., /x/index/ → /x/)
73    Redirect(String),
74}
75
76/// Configuration for path resolution.
77#[derive(Debug, Clone, Copy)]
78pub struct PathResolverConfig<'a> {
79    /// The repository ([`crate::repo::Repo::vault`]).
80    pub vault: &'a dyn Vault,
81    /// The external static overlay ([`crate::repo::Repo::static_vault`]), when
82    /// `static_folder` resolves outside the repository. Takes precedence over
83    /// `static_folder`, which then only names it.
84    pub static_vault: Option<&'a dyn Vault>,
85    /// The configured static folder. Without a `static_vault`, it is placed
86    /// inside `vault` ([`crate::vault::configured_folder`]); a value that lands
87    /// nowhere in the vault disables the overlay.
88    pub static_folder: &'a str,
89    pub markdown_extensions: &'a [String],
90    pub index_file: &'a str,
91    /// Valid tag source URL identifiers (e.g., ["tags", "performers", "taxonomy.tags"])
92    /// Used to detect tag page URLs like /tags/rust/
93    pub tag_sources: &'a [String],
94    /// Hidden directories that may be served anyway, as vault paths: the ones
95    /// the user named on the command line (`Config::explicit_hidden_dirs`,
96    /// [`crate::repo::Repo::exempt_hidden_dirs`]). Empty almost always.
97    pub exempt_hidden_dirs: &'a [VaultPath],
98}
99
100/// Owned counterpart of [`PathResolverConfig`].
101///
102/// [`PathResolverConfig`] borrows everything, which is right for a request
103/// handler but impossible for anything that must outlive the borrow — notably
104/// the `'static` closure [`crate::link_transform::LinkTransformConfig`] carries
105/// to answer "does this link target resolve to a markdown page?". Owning the
106/// values costs a handful of small clones (and two `Arc` bumps) per page render
107/// and keeps a single definition of the resolution inputs.
108#[derive(Debug, Clone)]
109pub struct OwnedPathResolverConfig {
110    pub vault: Arc<dyn Vault>,
111    pub static_vault: Option<Arc<dyn Vault>>,
112    pub static_folder: String,
113    pub markdown_extensions: Vec<String>,
114    pub index_file: String,
115    pub tag_sources: Vec<String>,
116    pub exempt_hidden_dirs: Vec<VaultPath>,
117}
118
119impl OwnedPathResolverConfig {
120    /// Borrows this configuration as the form [`resolve_request_path`] takes.
121    pub fn as_config(&self) -> PathResolverConfig<'_> {
122        PathResolverConfig {
123            vault: self.vault.as_ref(),
124            static_vault: self.static_vault.as_deref(),
125            static_folder: &self.static_folder,
126            markdown_extensions: &self.markdown_extensions,
127            index_file: &self.index_file,
128            tag_sources: &self.tag_sources,
129            exempt_hidden_dirs: &self.exempt_hidden_dirs,
130        }
131    }
132
133    /// The configuration for `repo`'s storage — its vault and static overlay —
134    /// with the given resolution settings.
135    pub fn for_repo(
136        repo: &crate::repo::Repo,
137        static_folder: &str,
138        markdown_extensions: &[String],
139        index_file: &str,
140        tag_sources: Vec<String>,
141    ) -> Self {
142        Self {
143            vault: Arc::clone(repo.vault()),
144            static_vault: repo.static_vault().cloned(),
145            static_folder: static_folder.to_string(),
146            markdown_extensions: markdown_extensions.to_vec(),
147            index_file: index_file.to_string(),
148            tag_sources,
149            exempt_hidden_dirs: repo.exempt_hidden_dirs().to_vec(),
150        }
151    }
152}
153
154/// Decides whether a request that resolved to a markdown page arrived at a
155/// non-canonical URL, and if so what to redirect to.
156///
157/// Markdown pages are served at directory-style URLs (`docs/guide.md` →
158/// `/docs/guide/`). Serving the same page at `/docs/guide` with a 200 is not
159/// harmless: the browser's base for resolving that page's own relative links
160/// becomes `/docs/` instead of `/docs/guide/`, so every `../`-prefixed href the
161/// renderer emitted lands one directory too high and 404s. The defect surfaces
162/// one click *after* the wrong URL, which is what makes it so hard to trace.
163///
164/// `request_path` is the path axum's catch-all captured: percent-decoded and
165/// without a leading slash. `canonical_url` is the page's canonical URL as
166/// produced by [`crate::repo::build_markdown_url_path`] (leading and trailing
167/// slash). Returns `None` when the request already used the canonical URL.
168///
169/// Note this deliberately also catches `/docs/guide.md`: an extension-bearing
170/// URL is just another non-canonical spelling of the same page, and leaving it
171/// at a 200 has the identical relative-link consequence.
172pub fn canonical_page_redirect(request_path: &str, canonical_url: &str) -> Option<String> {
173    let requested = format!("/{}", request_path.trim_start_matches('/'));
174    (requested != canonical_url).then(|| canonical_url.to_string())
175}
176
177/// Normalizes an authored link target (href) into the request-path form that
178/// [`resolve_request_path`] expects.
179///
180/// Live requests reach the server through axum's `extract::Path`, which
181/// percent-decodes the URL path before `resolve_request_path` ever sees it.
182/// Any code that feeds *authored* hrefs (still percent-encoded, possibly
183/// carrying fragments or query strings) into the resolver must apply this
184/// identical normalization, or its results diverge from what the server
185/// actually serves. That divergence previously caused bogus "broken internal
186/// link" 404 reports in the GUI error panel for links like
187/// `/IronCore%20Swag%20T-shirts%20Gifts/` pointing at
188/// `IronCore Swag T-shirts Gifts.md`.
189///
190/// Mirrors exactly what a real HTTP request undergoes:
191/// 1. Strip the fragment (`#...`), then the query string (`?...`).
192/// 2. Percent-decode (lossy UTF-8, matching axum's decoding).
193/// 3. Trim leading and trailing `/`.
194///
195/// Note: valid percent escapes are always decoded, so a literal `%` must be
196/// authored as `%25` (e.g. `100%25` normalizes to `100%`).
197pub fn normalize_link_target(href: &str) -> String {
198    let base = href.split('#').next().unwrap_or(href);
199    let base = base.split('?').next().unwrap_or(base);
200    let decoded = percent_encoding::percent_decode_str(base).decode_utf8_lossy();
201    decoded.trim_matches('/').to_string()
202}
203
204/// Resolves a URL path to determine what resource should be served.
205///
206/// This is a pure function that performs filesystem checks (through
207/// `config.vault`) but no I/O operations like reading file contents. It
208/// determines the type of resource to serve.
209///
210/// # Resolution Order
211///
212/// 1. Direct file match in base_dir → StaticFile
213/// 2. Directory with configured index file (e.g., index.md) → MarkdownFile
214/// 3. Path with trailing slash matching a markdown file (e.g., /foo/ → foo.md) → MarkdownFile
215/// 4. File in static folder → StaticFile
216/// 5. Directory with index.{markdown_ext} → MarkdownFile
217/// 6. Directory without index → DirectoryListing
218/// 7. Tag source index (e.g., /tags/) → TagSourceIndex (if source matches config)
219/// 8. Tag page (e.g., /tags/rust/) → TagPage (if source matches config)
220/// 9. Nothing matches → NotFound
221///
222/// Note: Filesystem paths (steps 1-6) always take precedence over tag URLs (steps 7-8).
223/// If a file or directory named "tags" exists, it will be served instead of the tag index.
224///
225/// # Security
226///
227/// Path traversal attacks (e.g., `../../../etc/passwd`) are blocked by validating
228/// that all resolved paths remain within the configured base directory.
229pub fn resolve_request_path(config: &PathResolverConfig, request_path: &str) -> ResolvedPath {
230    let vault = config.vault;
231    // Step 4 and step 4b ask the same question; answer it at most once.
232    let mut static_checked = false;
233
234    // Use safe_join to prevent path traversal attacks
235    // If the path would escape base_dir, skip to tag resolution or NotFound
236    if let Some(candidate) = safe_join(vault, request_path, config.exempt_hidden_dirs) {
237        // One `stat` answers both "is it a file" and "is it a directory".
238        let kind = vault
239            .stat(&candidate)
240            .ok()
241            .flatten()
242            .map(|entry| entry.kind);
243
244        // 1. Direct file match
245        if kind == Some(EntryKind::File) {
246            let name = Path::new(candidate.file_name().unwrap_or_default());
247            let key = vault.key(&candidate);
248            return if is_markdown_file(name, config.markdown_extensions) {
249                ResolvedPath::MarkdownFile(key)
250            } else {
251                ResolvedPath::StaticFile(key)
252            };
253        }
254
255        // 2. Directory with configured index file
256        if kind == Some(EntryKind::Dir)
257            && let Ok(index_path) = candidate.join(config.index_file)
258            && vault.is_file(&index_path)
259        {
260            return ResolvedPath::MarkdownFile(vault.key(&index_path));
261        }
262
263        // 3a. Check for non-canonical index URL (e.g., /x/index/ should redirect to /x/)
264        // This must come before step 3 to catch URLs like /docs/index/ before they resolve
265        let index_stem = Path::new(config.index_file)
266            .file_stem()
267            .and_then(|s| s.to_str())
268            .unwrap_or("index");
269
270        if candidate.file_name() == Some(index_stem)
271            && let Some(parent) = candidate.parent()
272            && let Ok(index_path) = parent.join(config.index_file)
273            && vault.is_file(&index_path)
274        {
275            // Build canonical URL: /x/index/ → /x/. The parent is already the
276            // canonical vault path, which is `/`-separated on every platform.
277            let canonical = if parent.is_root() {
278                "/".to_string()
279            } else {
280                format!("/{parent}/")
281            };
282            return ResolvedPath::Redirect(canonical);
283        }
284
285        // 3. Try markdown extensions on base path (for /foo/ → foo.md)
286        if let Some(md_path) = find_markdown_file(vault, &candidate, config.markdown_extensions) {
287            return ResolvedPath::MarkdownFile(vault.key(&md_path));
288        }
289
290        // 4. Check static folder (has its own path traversal protection)
291        static_checked = true;
292        if let Some(static_path) = find_in_static_folder(config, request_path) {
293            return ResolvedPath::StaticFile(static_path);
294        }
295
296        // 5. Directory with index.{markdown_ext}
297        if kind == Some(EntryKind::Dir) {
298            if let Ok(index_base) = candidate.join("index")
299                && let Some(md_path) =
300                    find_markdown_file(vault, &index_base, config.markdown_extensions)
301            {
302                return ResolvedPath::MarkdownFile(vault.key(&md_path));
303            }
304
305            // 6. Directory without index → listing
306            return ResolvedPath::DirectoryListing(vault.key(&candidate));
307        }
308    }
309
310    // 4b. Static folder check - ALSO check here for paths not in base_dir
311    // This handles the case where the path doesn't exist in base_dir but exists in static folder
312    // (e.g., /images/blog/photo.png where images/ only exists under static/)
313    if !static_checked && let Some(static_path) = find_in_static_folder(config, request_path) {
314        return ResolvedPath::StaticFile(static_path);
315    }
316
317    // 7-8. Check for tag URLs (only if nothing matched in filesystem)
318    // This is also reached if safe_join returned None (path traversal blocked)
319    if let Some(tag_result) = try_resolve_tag_url(request_path, config.tag_sources) {
320        return tag_result;
321    }
322
323    // 9. Nothing found
324    ResolvedPath::NotFound
325}
326
327/// Checks if a path is a markdown file based on configured extensions.
328fn is_markdown_file(path: &Path, extensions: &[String]) -> bool {
329    path.extension()
330        .and_then(|ext| ext.to_str())
331        .map(|ext| extensions.iter().any(|md_ext| md_ext == ext))
332        .unwrap_or(false)
333}
334
335/// Strips trailing path separator(s) from a path.
336///
337/// Both `/` and the platform separator are trimmed. The input here is derived
338/// from a request URL, where the separator is *always* `/` regardless of
339/// platform — trimming only `std::path::MAIN_SEPARATOR` silently did nothing on
340/// Windows, leaving a trailing slash on the candidate path.
341///
342/// The resolver itself no longer needs this — a [`VaultPath`] never carries a
343/// trailing separator — but it remains the one definition for callers that
344/// still hold native paths.
345pub fn strip_trailing_separator(path: &Path) -> PathBuf {
346    let s = path.to_string_lossy();
347    let trimmed = s.trim_end_matches(['/', std::path::MAIN_SEPARATOR]);
348    PathBuf::from(trimmed)
349}
350
351/// Finds a markdown file by trying each configured extension.
352///
353/// The URL for a markdown file strips only its final extension (see
354/// `build_markdown_url_path`), so a file named `a.b.c.md` is served at `/a.b.c/`.
355/// We therefore reverse that by *appending* the extension to the full stem;
356/// `Path::set_extension` would instead replace the trailing dotted segment
357/// (`a.b.c` -> `a.b.md`) and 404 on any file whose name contains a dot.
358///
359/// The vault root has no name, so it never probes for a sibling of the
360/// repository (`<parent>/<repo>.md`), which the path-based version did.
361fn find_markdown_file(
362    vault: &dyn Vault,
363    base_path: &VaultPath,
364    extensions: &[String],
365) -> Option<VaultPath> {
366    let file_name = base_path.file_name()?;
367    let parent = base_path.parent()?;
368    extensions
369        .iter()
370        .filter_map(|ext| parent.child(&format!("{file_name}.{ext}")).ok())
371        .find(|path| vault.is_file(path))
372}
373
374/// Finds a file in the static folder.
375///
376/// # Security
377///
378/// Containment is enforced against the **static root** — the canonicalized
379/// static directory itself — not against the repository root. The overlay is
380/// allowed to live outside the repository root (`static_folder = "../static"`
381/// for the common `repo/content` + `repo/static` layout), so requiring every
382/// served file to sit under the repository root would 404 the entire overlay.
383/// An overlay outside the root is a vault of its own (`config.static_vault`);
384/// one inside it is a folder of `config.vault`.
385///
386/// *How far* the overlay may reach is decided once, at load time, by
387/// `Config::validate_static_folder`: inside the root, or under a directory at
388/// most two levels above it, never reached through `$HOME` or the filesystem
389/// root, and never a directory containing the root. What this function must
390/// still guarantee — and does — is that a *request path* cannot walk out of
391/// whatever directory that policy settled on, including through a symlink inside
392/// the overlay pointing at, say, `/etc/passwd`: the candidate is canonicalized
393/// before the containment check, so the symlink's target is what gets judged.
394fn find_in_static_folder(config: &PathResolverConfig, request_path: &str) -> Option<PathBuf> {
395    let request = visible(VaultPath::new(request_path).ok()?, &[])?;
396    let (vault, static_dir) = match config.static_vault {
397        Some(overlay) => (overlay, VaultPath::root()),
398        None => (
399            config.vault,
400            crate::vault::configured_folder(config.vault, config.static_folder)?,
401        ),
402    };
403
404    // Canonicalize the static directory (it must exist) and the candidate
405    // (which must exist too — unlike `resolve_under`, there is no "probe a
406    // name that is not there yet" here, so a miss costs no second lookup),
407    // then require the candidate to still be inside the former.
408    let static_dir = vault.canonicalize(&static_dir).ok()?;
409    let canonical = vault.canonicalize(&static_dir.join_path(&request)).ok()?;
410    (canonical.starts_with(&static_dir)
411        && !hidden_below(&canonical, &static_dir)
412        && vault.is_file(&canonical))
413    .then(|| vault.key(&canonical))
414}
415
416/// Whether `path` has a hidden segment below `base` (which it starts with).
417///
418/// The static folder is judged from *inside*: the folder itself is whatever
419/// the configuration named — an in-root `.vuepress/public` is a real layout —
420/// while everything served out of it answers to the hidden-file rule. (An
421/// *external* overlay that is itself hidden is refused when the configuration
422/// is validated, by [`crate::config::external_folder_refusal`].)
423fn hidden_below(path: &VaultPath, base: &VaultPath) -> bool {
424    path.segments()
425        .skip(base.segments().count())
426        .any(crate::vault::is_hidden_segment)
427}
428
429/// Attempts to resolve a URL path as a tag URL.
430///
431/// Matches patterns like:
432/// - `{source}/` → TagSourceIndex (e.g., "tags/" → list all tags)
433/// - `{source}/{value}/` → TagPage (e.g., "tags/rust/" → pages tagged "rust")
434///
435/// The source must match one of the configured tag sources (case-insensitive).
436/// Returns `None` if the path doesn't match a tag URL pattern.
437fn try_resolve_tag_url(request_path: &str, tag_sources: &[String]) -> Option<ResolvedPath> {
438    // Skip if no tag sources configured
439    if tag_sources.is_empty() {
440        return None;
441    }
442
443    // Normalize path: strip leading and trailing slashes
444    let path = request_path.trim_matches('/');
445
446    // Empty path is not a tag URL
447    if path.is_empty() {
448        return None;
449    }
450
451    // Split path into segments
452    let segments: Vec<&str> = path.split('/').collect();
453
454    match segments.len() {
455        // Single segment: might be a tag source index (e.g., "tags")
456        1 => {
457            let source = segments[0].to_lowercase();
458            if tag_sources.iter().any(|s| s.to_lowercase() == source) {
459                Some(ResolvedPath::TagSourceIndex { source })
460            } else {
461                None
462            }
463        }
464        // Two segments: might be a tag page (e.g., "tags/rust")
465        2 => {
466            let source = segments[0].to_lowercase();
467            let value = segments[1].to_lowercase();
468
469            // Don't match empty values
470            if value.is_empty() {
471                return None;
472            }
473
474            if tag_sources.iter().any(|s| s.to_lowercase() == source) {
475                Some(ResolvedPath::TagPage { source, value })
476            } else {
477                None
478            }
479        }
480        // More than 2 segments: not a tag URL
481        _ => None,
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::vault::LocalVault;
489    use std::fs;
490    use tempfile::TempDir;
491
492    /// `safe_join` on a local vault rooted at `base`, answered as the key
493    /// (canonical path) it resolves to — the shape the path-based `safe_join`
494    /// returned, so the security tests below assert exactly what they did.
495    fn safe_join_local(base: &Path, request: &str) -> Option<PathBuf> {
496        let vault = LocalVault::new(base);
497        safe_join(&vault, request, &[]).map(|p| vault.key(&p))
498    }
499
500    /// The overlay vault the repository would build for `static_folder`, using
501    /// the same policy call `Repo::init` makes.
502    fn overlay_vault(root: &Path, static_folder: &str) -> Option<LocalVault> {
503        match crate::config::resolve_static_overlay(root, static_folder) {
504            Ok(crate::config::StaticOverlay::External(dir)) => Some(LocalVault::new(dir)),
505            _ => None,
506        }
507    }
508
509    /// Test fixture that owns the extensions and tag_sources vectors
510    struct TestFixture {
511        dir: TempDir,
512        vault: LocalVault,
513        extensions: Vec<String>,
514        tag_sources: Vec<String>,
515    }
516
517    impl TestFixture {
518        fn new() -> Self {
519            let dir = TempDir::new().unwrap();
520            fs::create_dir(dir.path().join("static")).unwrap();
521            let vault = LocalVault::new(dir.path());
522            Self {
523                dir,
524                vault,
525                extensions: vec![String::from("md")],
526                tag_sources: vec![],
527            }
528        }
529
530        fn with_extensions(extensions: Vec<String>) -> Self {
531            let dir = TempDir::new().unwrap();
532            fs::create_dir(dir.path().join("static")).unwrap();
533            let vault = LocalVault::new(dir.path());
534            Self {
535                dir,
536                vault,
537                extensions,
538                tag_sources: vec![],
539            }
540        }
541
542        fn with_tag_sources(tag_sources: Vec<String>) -> Self {
543            let dir = TempDir::new().unwrap();
544            fs::create_dir(dir.path().join("static")).unwrap();
545            let vault = LocalVault::new(dir.path());
546            Self {
547                dir,
548                vault,
549                extensions: vec![String::from("md")],
550                tag_sources,
551            }
552        }
553
554        fn config(&self) -> PathResolverConfig<'_> {
555            PathResolverConfig {
556                vault: &self.vault,
557                static_vault: None,
558                static_folder: "static",
559                markdown_extensions: &self.extensions,
560                index_file: "index.md",
561                tag_sources: &self.tag_sources,
562                exempt_hidden_dirs: &[],
563            }
564        }
565
566        fn path(&self) -> &Path {
567            self.dir.path()
568        }
569
570        /// Returns the canonicalized base path (resolves symlinks like /var -> /private/var on macOS)
571        fn canonical_path(&self) -> PathBuf {
572            self.dir.path().canonicalize().unwrap()
573        }
574    }
575
576    #[test]
577    fn test_direct_markdown_file() {
578        let fixture = TestFixture::new();
579        fs::write(fixture.path().join("readme.md"), "# Test").unwrap();
580
581        let result = resolve_request_path(&fixture.config(), "readme.md");
582
583        // safe_join returns canonicalized paths
584        assert_eq!(
585            result,
586            ResolvedPath::MarkdownFile(fixture.canonical_path().join("readme.md"))
587        );
588    }
589
590    #[test]
591    fn test_direct_static_file() {
592        let fixture = TestFixture::new();
593        fs::write(fixture.path().join("image.png"), "fake image").unwrap();
594
595        let result = resolve_request_path(&fixture.config(), "image.png");
596
597        // safe_join returns canonicalized paths
598        assert_eq!(
599            result,
600            ResolvedPath::StaticFile(fixture.canonical_path().join("image.png"))
601        );
602    }
603
604    #[test]
605    fn test_directory_with_index() {
606        let fixture = TestFixture::new();
607        let subdir = fixture.path().join("docs");
608        fs::create_dir(&subdir).unwrap();
609        fs::write(subdir.join("index.md"), "# Docs").unwrap();
610
611        let result = resolve_request_path(&fixture.config(), "docs");
612
613        // safe_join returns canonicalized paths
614        let expected = fixture.canonical_path().join("docs/index.md");
615        assert_eq!(result, ResolvedPath::MarkdownFile(expected));
616    }
617
618    #[test]
619    fn test_trailing_slash_to_markdown() {
620        let fixture = TestFixture::new();
621        fs::write(fixture.path().join("about.md"), "# About").unwrap();
622
623        let result = resolve_request_path(&fixture.config(), "about/");
624
625        // safe_join returns canonicalized paths
626        assert_eq!(
627            result,
628            ResolvedPath::MarkdownFile(fixture.canonical_path().join("about.md"))
629        );
630    }
631
632    #[test]
633    fn test_dotted_filename_trailing_slash_to_markdown() {
634        // Regression: a file whose name contains a period is served at a URL that
635        // strips only the final extension (see build_markdown_url_path). The resolver
636        // must reverse that by appending the extension, not replacing the trailing
637        // dotted segment, otherwise `patrick-walsh-b.2010-03-03.md` 404s at
638        // `/patrick-walsh-b.2010-03-03/`.
639        let fixture = TestFixture::new();
640        fs::write(
641            fixture.path().join("patrick-walsh-b.2010-03-03.md"),
642            "# Patrick",
643        )
644        .unwrap();
645
646        let result = resolve_request_path(&fixture.config(), "patrick-walsh-b.2010-03-03/");
647
648        assert_eq!(
649            result,
650            ResolvedPath::MarkdownFile(
651                fixture
652                    .canonical_path()
653                    .join("patrick-walsh-b.2010-03-03.md")
654            )
655        );
656    }
657
658    #[test]
659    fn test_dotted_filename_without_trailing_slash_to_markdown() {
660        // Mirrors test_trailing_slash_to_markdown semantics: a dotted filename must
661        // also resolve when requested WITHOUT the trailing slash.
662        let fixture = TestFixture::new();
663        fs::write(
664            fixture.path().join("patrick-walsh-b.2010-03-03.md"),
665            "# Patrick",
666        )
667        .unwrap();
668
669        let result = resolve_request_path(&fixture.config(), "patrick-walsh-b.2010-03-03");
670
671        assert_eq!(
672            result,
673            ResolvedPath::MarkdownFile(
674                fixture
675                    .canonical_path()
676                    .join("patrick-walsh-b.2010-03-03.md")
677            )
678        );
679    }
680
681    #[test]
682    fn test_multi_dot_filename_trailing_slash_to_markdown() {
683        // A filename with multiple interior dots must resolve at its canonical URL
684        // (`report.2024.final.md` -> `/report.2024.final/`).
685        let fixture = TestFixture::new();
686        fs::write(fixture.path().join("report.2024.final.md"), "# Report").unwrap();
687
688        let result = resolve_request_path(&fixture.config(), "report.2024.final/");
689
690        assert_eq!(
691            result,
692            ResolvedPath::MarkdownFile(fixture.canonical_path().join("report.2024.final.md"))
693        );
694    }
695
696    #[test]
697    fn test_static_folder_file() {
698        let fixture = TestFixture::new();
699        fs::write(fixture.path().join("static/style.css"), "body {}").unwrap();
700
701        let result = resolve_request_path(&fixture.config(), "style.css");
702
703        // The static file path is canonicalized
704        let expected = fixture
705            .path()
706            .join("static/style.css")
707            .canonicalize()
708            .unwrap();
709        assert_eq!(result, ResolvedPath::StaticFile(expected));
710    }
711
712    #[test]
713    fn test_static_folder_nested_path() {
714        let fixture = TestFixture::new();
715        fs::create_dir_all(fixture.path().join("static/images/blog")).unwrap();
716        fs::write(
717            fixture.path().join("static/images/blog/photo.png"),
718            "fake image",
719        )
720        .unwrap();
721
722        // Request for /images/blog/photo.png should find static/images/blog/photo.png
723        let result = resolve_request_path(&fixture.config(), "images/blog/photo.png");
724
725        let expected = fixture
726            .path()
727            .join("static/images/blog/photo.png")
728            .canonicalize()
729            .unwrap();
730        assert_eq!(result, ResolvedPath::StaticFile(expected));
731    }
732
733    #[test]
734    fn test_directory_listing() {
735        let fixture = TestFixture::new();
736        let subdir = fixture.path().join("posts");
737        fs::create_dir(&subdir).unwrap();
738        // No index file
739
740        let result = resolve_request_path(&fixture.config(), "posts/");
741
742        // safe_join returns canonicalized paths
743        let expected = fixture.canonical_path().join("posts");
744        assert_eq!(result, ResolvedPath::DirectoryListing(expected));
745    }
746
747    #[test]
748    fn test_not_found() {
749        let fixture = TestFixture::new();
750
751        let result = resolve_request_path(&fixture.config(), "nonexistent");
752
753        assert_eq!(result, ResolvedPath::NotFound);
754    }
755
756    #[test]
757    fn test_nested_directory_with_index() {
758        let fixture = TestFixture::new();
759        let nested = fixture.path().join("blog/2024");
760        fs::create_dir_all(&nested).unwrap();
761        fs::write(nested.join("index.md"), "# Blog 2024").unwrap();
762
763        let result = resolve_request_path(&fixture.config(), "blog/2024");
764
765        // safe_join returns canonicalized paths
766        let expected = fixture.canonical_path().join("blog/2024/index.md");
767        assert_eq!(result, ResolvedPath::MarkdownFile(expected));
768    }
769
770    #[test]
771    fn test_multiple_markdown_extensions() {
772        let fixture =
773            TestFixture::with_extensions(vec![String::from("md"), String::from("markdown")]);
774        fs::write(fixture.path().join("notes.markdown"), "# Notes").unwrap();
775
776        let result = resolve_request_path(&fixture.config(), "notes/");
777
778        // safe_join returns canonicalized paths
779        assert_eq!(
780            result,
781            ResolvedPath::MarkdownFile(fixture.canonical_path().join("notes.markdown"))
782        );
783    }
784
785    #[test]
786    fn test_prefers_first_extension() {
787        let fixture =
788            TestFixture::with_extensions(vec![String::from("md"), String::from("markdown")]);
789        // Create both .md and .markdown files
790        fs::write(fixture.path().join("test.md"), "# MD").unwrap();
791        fs::write(fixture.path().join("test.markdown"), "# Markdown").unwrap();
792
793        let result = resolve_request_path(&fixture.config(), "test/");
794
795        // Should prefer .md (first in list), safe_join returns canonicalized paths
796        assert_eq!(
797            result,
798            ResolvedPath::MarkdownFile(fixture.canonical_path().join("test.md"))
799        );
800    }
801
802    #[test]
803    fn test_root_path_empty_string() {
804        let fixture = TestFixture::new();
805        fs::write(fixture.path().join("index.md"), "# Home").unwrap();
806
807        let result = resolve_request_path(&fixture.config(), "");
808
809        // Empty path resolves to base_dir, which is a directory with index.md
810        // safe_join returns canonicalized paths
811        assert_eq!(
812            result,
813            ResolvedPath::MarkdownFile(fixture.canonical_path().join("index.md"))
814        );
815    }
816
817    #[test]
818    fn test_is_markdown_file() {
819        let extensions = vec![String::from("md"), String::from("markdown")];
820
821        assert!(is_markdown_file(Path::new("test.md"), &extensions));
822        assert!(is_markdown_file(Path::new("test.markdown"), &extensions));
823        assert!(!is_markdown_file(Path::new("test.txt"), &extensions));
824        assert!(!is_markdown_file(Path::new("test"), &extensions));
825    }
826
827    #[test]
828    fn test_strip_trailing_separator() {
829        // A forward slash must be stripped on every platform: this function's
830        // input comes from a request URL, so `/` is the separator even where
831        // `std::path::MAIN_SEPARATOR` is `\`.
832        assert_eq!(
833            strip_trailing_separator(Path::new("/foo/bar/")),
834            PathBuf::from("/foo/bar")
835        );
836        assert_eq!(
837            strip_trailing_separator(Path::new("/foo/bar")),
838            PathBuf::from("/foo/bar")
839        );
840        assert_eq!(
841            strip_trailing_separator(Path::new("relative/")),
842            PathBuf::from("relative")
843        );
844        // Repeated separators are all removed.
845        assert_eq!(
846            strip_trailing_separator(Path::new("/foo/bar//")),
847            PathBuf::from("/foo/bar")
848        );
849    }
850
851    /// The platform separator is stripped too, so a natively-joined path with a
852    /// trailing separator is handled the same way as a URL-derived one.
853    #[cfg(windows)]
854    #[test]
855    fn test_strip_trailing_separator_windows_backslash() {
856        assert_eq!(
857            strip_trailing_separator(Path::new(r"\foo\bar\")),
858            PathBuf::from(r"\foo\bar")
859        );
860        // Mixed separators, which Windows accepts in real request handling.
861        assert_eq!(
862            strip_trailing_separator(Path::new(r"\foo\bar/")),
863            PathBuf::from(r"\foo\bar")
864        );
865    }
866
867    // ==================== normalize_link_target Tests ====================
868
869    #[test]
870    fn test_normalize_link_target_plain_path() {
871        assert_eq!(normalize_link_target("docs/guide"), "docs/guide");
872    }
873
874    #[test]
875    fn test_normalize_link_target_decodes_encoded_spaces() {
876        assert_eq!(
877            normalize_link_target("/IronCore%20Swag%20T-shirts%20Gifts"),
878            "IronCore Swag T-shirts Gifts"
879        );
880    }
881
882    #[test]
883    fn test_normalize_link_target_strips_fragment() {
884        assert_eq!(normalize_link_target("/docs/guide/#section"), "docs/guide");
885    }
886
887    #[test]
888    fn test_normalize_link_target_strips_query() {
889        assert_eq!(normalize_link_target("/docs/guide/?x=1&y=2"), "docs/guide");
890    }
891
892    #[test]
893    fn test_normalize_link_target_strips_query_and_fragment_with_decoding() {
894        assert_eq!(
895            normalize_link_target("/My%20Page/?x=1#top"),
896            "My Page",
897            "fragment and query must be stripped before decoding/trimming"
898        );
899    }
900
901    #[test]
902    fn test_normalize_link_target_trims_leading_and_trailing_slashes() {
903        assert_eq!(normalize_link_target("/docs/guide/"), "docs/guide");
904        assert_eq!(normalize_link_target("docs/guide"), "docs/guide");
905        assert_eq!(normalize_link_target("/"), "");
906    }
907
908    #[test]
909    fn test_normalize_link_target_decodes_literal_percent_escape() {
910        // A literal `%` must be authored as `%25`; valid escapes always decode.
911        assert_eq!(normalize_link_target("/100%25"), "100%");
912    }
913
914    // ==================== Tag URL Resolution Tests ====================
915
916    #[test]
917    fn test_tag_source_index() {
918        let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
919        let result = resolve_request_path(&fixture.config(), "tags/");
920
921        assert_eq!(
922            result,
923            ResolvedPath::TagSourceIndex {
924                source: "tags".to_string()
925            }
926        );
927    }
928
929    #[test]
930    fn test_tag_source_index_without_trailing_slash() {
931        let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
932        let result = resolve_request_path(&fixture.config(), "tags");
933
934        assert_eq!(
935            result,
936            ResolvedPath::TagSourceIndex {
937                source: "tags".to_string()
938            }
939        );
940    }
941
942    #[test]
943    fn test_tag_page() {
944        let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
945        let result = resolve_request_path(&fixture.config(), "tags/rust/");
946
947        assert_eq!(
948            result,
949            ResolvedPath::TagPage {
950                source: "tags".to_string(),
951                value: "rust".to_string()
952            }
953        );
954    }
955
956    #[test]
957    fn test_tag_page_without_trailing_slash() {
958        let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
959        let result = resolve_request_path(&fixture.config(), "tags/rust");
960
961        assert_eq!(
962            result,
963            ResolvedPath::TagPage {
964                source: "tags".to_string(),
965                value: "rust".to_string()
966            }
967        );
968    }
969
970    #[test]
971    fn test_tag_url_case_insensitive_source() {
972        let fixture = TestFixture::with_tag_sources(vec!["Tags".to_string()]);
973
974        // Uppercase in URL should match lowercase config
975        let result = resolve_request_path(&fixture.config(), "TAGS/rust/");
976
977        assert_eq!(
978            result,
979            ResolvedPath::TagPage {
980                source: "tags".to_string(),
981                value: "rust".to_string()
982            }
983        );
984    }
985
986    #[test]
987    fn test_tag_url_unknown_source_not_matched() {
988        let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
989
990        // "categories" is not a configured tag source
991        let result = resolve_request_path(&fixture.config(), "categories/rust/");
992
993        assert_eq!(result, ResolvedPath::NotFound);
994    }
995
996    #[test]
997    fn test_tag_url_no_sources_configured() {
998        let fixture = TestFixture::new(); // Empty tag_sources
999        let result = resolve_request_path(&fixture.config(), "tags/rust/");
1000
1001        assert_eq!(result, ResolvedPath::NotFound);
1002    }
1003
1004    #[test]
1005    fn test_tag_url_multiple_sources() {
1006        let fixture = TestFixture::with_tag_sources(vec![
1007            "tags".to_string(),
1008            "performers".to_string(),
1009            "taxonomy.categories".to_string(),
1010        ]);
1011
1012        // All sources should be recognized
1013        assert_eq!(
1014            resolve_request_path(&fixture.config(), "tags/rust/"),
1015            ResolvedPath::TagPage {
1016                source: "tags".to_string(),
1017                value: "rust".to_string()
1018            }
1019        );
1020        assert_eq!(
1021            resolve_request_path(&fixture.config(), "performers/joshua_jay/"),
1022            ResolvedPath::TagPage {
1023                source: "performers".to_string(),
1024                value: "joshua_jay".to_string()
1025            }
1026        );
1027        assert_eq!(
1028            resolve_request_path(&fixture.config(), "taxonomy.categories/"),
1029            ResolvedPath::TagSourceIndex {
1030                source: "taxonomy.categories".to_string()
1031            }
1032        );
1033    }
1034
1035    #[test]
1036    fn test_file_takes_precedence_over_tag_url() {
1037        let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
1038        // Create a real markdown file at "tags.md"
1039        fs::write(fixture.path().join("tags.md"), "# Real Tags Page").unwrap();
1040
1041        // File should take precedence over tag source index
1042        let result = resolve_request_path(&fixture.config(), "tags/");
1043
1044        // safe_join returns canonicalized paths
1045        assert_eq!(
1046            result,
1047            ResolvedPath::MarkdownFile(fixture.canonical_path().join("tags.md"))
1048        );
1049    }
1050
1051    #[test]
1052    fn test_directory_takes_precedence_over_tag_url() {
1053        let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
1054        // Create a real directory "tags/"
1055        fs::create_dir(fixture.path().join("tags")).unwrap();
1056
1057        // Directory listing should take precedence
1058        let result = resolve_request_path(&fixture.config(), "tags/");
1059
1060        // safe_join returns canonicalized paths
1061        assert_eq!(
1062            result,
1063            ResolvedPath::DirectoryListing(fixture.canonical_path().join("tags"))
1064        );
1065    }
1066
1067    #[test]
1068    fn test_nested_tag_value_not_matched() {
1069        let fixture = TestFixture::with_tag_sources(vec!["tags".to_string()]);
1070
1071        // More than 2 segments is not a valid tag URL
1072        let result = resolve_request_path(&fixture.config(), "tags/rust/advanced/");
1073
1074        assert_eq!(result, ResolvedPath::NotFound);
1075    }
1076
1077    #[test]
1078    fn test_try_resolve_tag_url_directly() {
1079        let sources = vec!["tags".to_string(), "performers".to_string()];
1080
1081        // Tag source index
1082        assert_eq!(
1083            try_resolve_tag_url("tags/", &sources),
1084            Some(ResolvedPath::TagSourceIndex {
1085                source: "tags".to_string()
1086            })
1087        );
1088
1089        // Tag page
1090        assert_eq!(
1091            try_resolve_tag_url("tags/rust", &sources),
1092            Some(ResolvedPath::TagPage {
1093                source: "tags".to_string(),
1094                value: "rust".to_string()
1095            })
1096        );
1097
1098        // Unknown source
1099        assert_eq!(try_resolve_tag_url("unknown/value", &sources), None);
1100
1101        // Empty path
1102        assert_eq!(try_resolve_tag_url("", &sources), None);
1103
1104        // Empty sources
1105        assert_eq!(try_resolve_tag_url("tags/rust", &[]), None);
1106    }
1107
1108    // ==================== Non-Canonical Index URL Redirect Tests ====================
1109
1110    #[test]
1111    fn test_non_canonical_index_redirects() {
1112        let fixture = TestFixture::new();
1113        let docs = fixture.path().join("docs");
1114        fs::create_dir(&docs).unwrap();
1115        fs::write(docs.join("index.md"), "# Docs Index").unwrap();
1116
1117        // /docs/index/ should redirect to /docs/
1118        let result = resolve_request_path(&fixture.config(), "docs/index/");
1119        assert_eq!(result, ResolvedPath::Redirect("/docs/".to_string()));
1120    }
1121
1122    #[test]
1123    fn test_root_index_redirects() {
1124        let fixture = TestFixture::new();
1125        fs::write(fixture.path().join("index.md"), "# Home").unwrap();
1126
1127        // /index/ should redirect to /
1128        let result = resolve_request_path(&fixture.config(), "index/");
1129        assert_eq!(result, ResolvedPath::Redirect("/".to_string()));
1130    }
1131
1132    #[test]
1133    fn test_nested_index_redirects() {
1134        let fixture = TestFixture::new();
1135        let nested = fixture.path().join("a/b/c");
1136        fs::create_dir_all(&nested).unwrap();
1137        fs::write(nested.join("index.md"), "# Nested").unwrap();
1138
1139        // /a/b/c/index/ should redirect to /a/b/c/
1140        let result = resolve_request_path(&fixture.config(), "a/b/c/index/");
1141        assert_eq!(result, ResolvedPath::Redirect("/a/b/c/".to_string()));
1142
1143        // A redirect target is a URL, so it must never carry a platform
1144        // separator. Asserted explicitly because the equality above only fails
1145        // on platforms where `\` is the separator.
1146        let ResolvedPath::Redirect(target) = result else {
1147            panic!("expected a redirect");
1148        };
1149        assert!(
1150            !target.contains('\\'),
1151            "redirect target must not contain a backslash, got {target}"
1152        );
1153    }
1154
1155    #[test]
1156    fn test_regular_file_named_index_no_redirect() {
1157        let fixture = TestFixture::new();
1158        // Create a regular file that happens to be named index.md (not in a directory with index)
1159        fs::write(fixture.path().join("index.md"), "# Regular Index").unwrap();
1160
1161        // But also create docs/readme.md as a standalone file (no parent index)
1162        let docs = fixture.path().join("docs");
1163        fs::create_dir(&docs).unwrap();
1164        fs::write(docs.join("readme.md"), "# Readme").unwrap();
1165
1166        // /docs/readme/ should NOT redirect (readme is not the index file)
1167        let result = resolve_request_path(&fixture.config(), "docs/readme/");
1168        assert!(matches!(result, ResolvedPath::MarkdownFile(_)));
1169    }
1170
1171    #[test]
1172    fn test_index_without_trailing_slash_redirects() {
1173        let fixture = TestFixture::new();
1174        let docs = fixture.path().join("docs");
1175        fs::create_dir(&docs).unwrap();
1176        fs::write(docs.join("index.md"), "# Docs Index").unwrap();
1177
1178        // /docs/index (without trailing slash) should also redirect to /docs/
1179        let result = resolve_request_path(&fixture.config(), "docs/index");
1180        assert_eq!(result, ResolvedPath::Redirect("/docs/".to_string()));
1181    }
1182
1183    // ==================== Path Traversal Security Tests ====================
1184
1185    #[test]
1186    fn test_path_traversal_blocked_with_dotdot() {
1187        let fixture = TestFixture::new();
1188        // Create a file outside the temp directory (simulating /etc/passwd)
1189        // We can't actually create /etc/passwd, so we test that path traversal returns NotFound
1190
1191        // Various path traversal attempts should all return NotFound
1192        let attacks = vec![
1193            "../../../etc/passwd",
1194            "..%2F..%2F..%2Fetc/passwd",
1195            "foo/../../../etc/passwd",
1196            "foo/bar/../../../etc/passwd",
1197            "....//....//etc/passwd",
1198        ];
1199
1200        for attack in attacks {
1201            let result = resolve_request_path(&fixture.config(), attack);
1202            assert_eq!(
1203                result,
1204                ResolvedPath::NotFound,
1205                "Path traversal should be blocked for: {}",
1206                attack
1207            );
1208        }
1209    }
1210
1211    #[test]
1212    fn test_path_traversal_blocked_in_static_folder() {
1213        let fixture = TestFixture::new();
1214        // Create a file in static folder
1215        fs::write(fixture.path().join("static/safe.txt"), "safe content").unwrap();
1216
1217        // Path traversal within static folder should be blocked
1218        let attacks = vec![
1219            "../readme.md",     // Try to escape static to base_dir
1220            "../../etc/passwd", // Try to escape completely
1221            "foo/../../../etc/passwd",
1222        ];
1223
1224        for attack in &attacks {
1225            let result = find_in_static_folder(&fixture.config(), attack);
1226            assert!(
1227                result.is_none(),
1228                "Static folder path traversal should be blocked for: {}",
1229                attack
1230            );
1231        }
1232
1233        // But valid file should still work
1234        let valid = find_in_static_folder(&fixture.config(), "safe.txt");
1235        assert!(valid.is_some(), "Valid static file should be found");
1236    }
1237
1238    #[test]
1239    fn test_safe_join_blocks_traversal() {
1240        let dir = TempDir::new().unwrap();
1241        let base = dir.path();
1242
1243        // Create a file inside
1244        fs::write(base.join("inside.txt"), "inside").unwrap();
1245
1246        // Valid path should work
1247        let valid = safe_join_local(base, "inside.txt");
1248        assert!(valid.is_some(), "Valid path should work");
1249        assert!(valid.unwrap().ends_with("inside.txt"));
1250
1251        // Path traversal should be blocked
1252        let attack = safe_join_local(base, "../../../etc/passwd");
1253        assert!(attack.is_none(), "Path traversal should be blocked");
1254
1255        // Complex traversal should be blocked
1256        let attack2 = safe_join_local(base, "foo/../../../etc/passwd");
1257        assert!(
1258            attack2.is_none(),
1259            "Complex path traversal should be blocked"
1260        );
1261    }
1262
1263    #[test]
1264    fn test_safe_join_allows_internal_dotdot() {
1265        let dir = TempDir::new().unwrap();
1266        let base = dir.path();
1267
1268        // Create nested structure
1269        fs::create_dir_all(base.join("foo/bar")).unwrap();
1270        fs::write(base.join("foo/sibling.txt"), "sibling").unwrap();
1271
1272        // Going up and back down within base_dir should work
1273        let valid = safe_join_local(base, "foo/bar/../sibling.txt");
1274        assert!(valid.is_some(), "Internal navigation should work");
1275        let resolved = valid.unwrap();
1276        assert!(
1277            resolved.ends_with("sibling.txt"),
1278            "Should resolve to sibling.txt, got: {:?}",
1279            resolved
1280        );
1281    }
1282
1283    #[test]
1284    fn test_path_traversal_returns_not_found_not_error() {
1285        let fixture = TestFixture::new();
1286
1287        // Path traversal should cleanly return NotFound, not panic or error
1288        let result = resolve_request_path(&fixture.config(), "../../../../etc/passwd");
1289
1290        // Should be NotFound, not a panic or file access
1291        assert_eq!(result, ResolvedPath::NotFound);
1292    }
1293
1294    #[test]
1295    fn test_symlink_escape_blocked() {
1296        // This test verifies that symlinks pointing outside base_dir are blocked
1297        let dir = TempDir::new().unwrap();
1298        let base = dir.path();
1299        fs::create_dir(base.join("static")).unwrap();
1300
1301        // Create a symlink in static folder pointing outside
1302        // (This is OS-dependent and may not work on all systems)
1303        #[cfg(unix)]
1304        {
1305            use std::os::unix::fs::symlink;
1306            let link_path = base.join("static/escape");
1307            // Try to create symlink to /tmp (which exists on most Unix systems)
1308            if symlink("/tmp", &link_path).is_ok() {
1309                let extensions = vec![String::from("md")];
1310                let tag_sources: Vec<String> = vec![];
1311                let vault = LocalVault::new(base);
1312                let config = PathResolverConfig {
1313                    vault: &vault,
1314                    static_vault: None,
1315                    static_folder: "static",
1316                    markdown_extensions: &extensions,
1317                    index_file: "index.md",
1318                    tag_sources: &tag_sources,
1319                    exempt_hidden_dirs: &[],
1320                };
1321
1322                // Following the symlink should be blocked
1323                let result = find_in_static_folder(&config, "escape/some_file");
1324                assert!(result.is_none(), "Symlink escape should be blocked");
1325            }
1326        }
1327    }
1328
1329    /// Regression: `safe_join` used to fall through to its "path doesn't exist
1330    /// yet" branch when `canonicalize()` *succeeded* but resolved outside the
1331    /// base. That branch validates only the parent, so it handed back the
1332    /// unresolved symlink and the server served the out-of-repo target
1333    /// (`GET /passwd` -> `/etc/passwd`).
1334    #[cfg(unix)]
1335    #[test]
1336    fn test_safe_join_blocks_symlink_to_absolute_path_outside_base() {
1337        use std::os::unix::fs::symlink;
1338
1339        let outside = TempDir::new().unwrap();
1340        let secret = outside.path().join("secret.txt");
1341        fs::write(&secret, "top secret").unwrap();
1342
1343        let dir = TempDir::new().unwrap();
1344        let base = dir.path();
1345        symlink(&secret, base.join("passwd")).unwrap();
1346
1347        assert_eq!(
1348            safe_join_local(base, "passwd"),
1349            None,
1350            "a symlink resolving outside the base must not be joined"
1351        );
1352    }
1353
1354    /// The relative form of the same escape: an attacker needs no knowledge of
1355    /// the victim's absolute paths.
1356    #[cfg(unix)]
1357    #[test]
1358    fn test_safe_join_blocks_relative_symlink_outside_base() {
1359        use std::os::unix::fs::symlink;
1360
1361        let outer = TempDir::new().unwrap();
1362        fs::write(outer.path().join("outside.txt"), "top secret").unwrap();
1363        let base = outer.path().join("repo/nested");
1364        fs::create_dir_all(&base).unwrap();
1365        symlink("../../outside.txt", base.join("escape.txt")).unwrap();
1366
1367        assert_eq!(
1368            safe_join_local(&base, "escape.txt"),
1369            None,
1370            "a relative symlink resolving outside the base must not be joined"
1371        );
1372    }
1373
1374    /// A symlink that stays inside the base is still followed.
1375    #[cfg(unix)]
1376    #[test]
1377    fn test_safe_join_allows_symlink_inside_base() {
1378        use std::os::unix::fs::symlink;
1379
1380        let dir = TempDir::new().unwrap();
1381        let base = dir.path();
1382        fs::write(base.join("real.txt"), "inside").unwrap();
1383        symlink("real.txt", base.join("alias.txt")).unwrap();
1384
1385        let joined = safe_join_local(base, "alias.txt").expect("in-base symlink should resolve");
1386        assert_eq!(joined, base.canonicalize().unwrap().join("real.txt"));
1387    }
1388
1389    /// The escape fix must not break the two branches that legitimately return
1390    /// `Some`: an existing in-base file, and a not-yet-existing sibling whose
1391    /// parent is in-base (how `/foo/` probes for `foo.md`).
1392    #[test]
1393    fn test_safe_join_existing_file_and_missing_sibling() {
1394        let dir = TempDir::new().unwrap();
1395        let base = dir.path();
1396        let canonical = base.canonicalize().unwrap();
1397        fs::create_dir(base.join("docs")).unwrap();
1398        fs::write(base.join("docs/guide.md"), "# Guide").unwrap();
1399
1400        assert_eq!(
1401            safe_join_local(base, "docs/guide.md"),
1402            Some(canonical.join("docs/guide.md")),
1403            "an existing in-base file must resolve"
1404        );
1405        assert_eq!(
1406            safe_join_local(base, "docs/guide"),
1407            Some(canonical.join("docs/guide")),
1408            "a not-yet-existing name under an in-base parent must resolve"
1409        );
1410    }
1411
1412    /// Retargeted. This layer used to insist that the overlay itself sit under
1413    /// the repository root, which 404'd the whole `repo/content` +
1414    /// `repo/static` layout. Deciding *how far* `static_folder` may reach is
1415    /// `Config::validate_static_folder`'s job now (at most two levels up, never
1416    /// via `$HOME` or `/`); what this layer owes is that an out-of-root overlay actually
1417    /// resolves — the regression — while a request path still cannot walk out
1418    /// of it.
1419    #[test]
1420    fn test_find_in_static_folder_serves_peer_overlay_but_contains_requests() {
1421        let outer = TempDir::new().unwrap();
1422        let peer = outer.path().join("static");
1423        fs::create_dir(&peer).unwrap();
1424        fs::write(peer.join("logo.png"), "PNG").unwrap();
1425        fs::write(outer.path().join("id_rsa"), "PRIVATE KEY").unwrap();
1426
1427        let base = outer.path().join("content");
1428        fs::create_dir(&base).unwrap();
1429        let canonical = base.canonicalize().unwrap();
1430
1431        let extensions = vec![String::from("md")];
1432        let tag_sources: Vec<String> = vec![];
1433
1434        let vault = LocalVault::new(&base);
1435        let peer_vault = overlay_vault(&canonical, "../static").expect("peer overlay accepted");
1436        let peer_overlay = PathResolverConfig {
1437            vault: &vault,
1438            static_vault: Some(&peer_vault),
1439            static_folder: "../static",
1440            markdown_extensions: &extensions,
1441            index_file: "index.md",
1442            tag_sources: &tag_sources,
1443            exempt_hidden_dirs: &[],
1444        };
1445        assert_eq!(
1446            find_in_static_folder(&peer_overlay, "logo.png"),
1447            Some(peer.canonicalize().unwrap().join("logo.png")),
1448            "a peer static folder must serve its own files"
1449        );
1450        assert!(
1451            matches!(
1452                resolve_request_path(&peer_overlay, "logo.png"),
1453                ResolvedPath::StaticFile(_)
1454            ),
1455            "the resolver must route the peer overlay's files as static files"
1456        );
1457
1458        // Containment is measured against the overlay, so climbing out of it
1459        // fails even though the target is readable and nearby.
1460        for attack in ["../id_rsa", "../../etc/passwd", "sub/../../id_rsa"] {
1461            assert_eq!(
1462                find_in_static_folder(&peer_overlay, attack),
1463                None,
1464                "a request path must not climb out of the static overlay: {attack}"
1465            );
1466        }
1467
1468        // An absolute overlay (only reachable via MBR_STATIC_FOLDER) behaves the
1469        // same way: it serves its own contents and contains request paths.
1470        let absolute = peer.to_string_lossy().into_owned();
1471        let absolute_vault = overlay_vault(&canonical, &absolute).expect("absolute overlay");
1472        let absolute_overlay = PathResolverConfig {
1473            static_folder: &absolute,
1474            static_vault: Some(&absolute_vault),
1475            ..peer_overlay
1476        };
1477        assert_eq!(
1478            find_in_static_folder(&absolute_overlay, "logo.png"),
1479            Some(peer.canonicalize().unwrap().join("logo.png")),
1480            "an absolute static folder must serve its own files"
1481        );
1482        assert_eq!(
1483            find_in_static_folder(&absolute_overlay, "../id_rsa"),
1484            None,
1485            "an absolute static folder must still contain request paths"
1486        );
1487    }
1488
1489    /// A symlink *inside* the overlay is the traversal route that survives the
1490    /// policy change: the value of `static_folder` is innocent, and only
1491    /// canonicalizing the request target reveals that it lands on `/etc/passwd`.
1492    #[cfg(unix)]
1493    #[test]
1494    fn test_find_in_static_folder_blocks_symlink_out_of_overlay() {
1495        use std::os::unix::fs::symlink;
1496
1497        let outer = TempDir::new().unwrap();
1498        let secrets = outer.path().join("secrets");
1499        fs::create_dir(&secrets).unwrap();
1500        fs::write(secrets.join("passwd"), "root:x:0:0").unwrap();
1501
1502        let base = outer.path().join("content");
1503        fs::create_dir(&base).unwrap();
1504        let canonical = base.canonicalize().unwrap();
1505        let peer = outer.path().join("static");
1506        fs::create_dir(&peer).unwrap();
1507
1508        // A file and a whole directory, each symlinked out of the overlay.
1509        symlink(secrets.join("passwd"), peer.join("passwd")).unwrap();
1510        symlink(&secrets, peer.join("leak")).unwrap();
1511
1512        let extensions = vec![String::from("md")];
1513        let tag_sources: Vec<String> = vec![];
1514        let vault = LocalVault::new(&base);
1515        let peer_vault = overlay_vault(&canonical, "../static").expect("peer overlay accepted");
1516        let config = PathResolverConfig {
1517            vault: &vault,
1518            static_vault: Some(&peer_vault),
1519            static_folder: "../static",
1520            markdown_extensions: &extensions,
1521            index_file: "index.md",
1522            tag_sources: &tag_sources,
1523            exempt_hidden_dirs: &[],
1524        };
1525
1526        for attack in ["passwd", "leak/passwd"] {
1527            assert_eq!(
1528                find_in_static_folder(&config, attack),
1529                None,
1530                "a symlink out of the static overlay must not be served: {attack}"
1531            );
1532            assert_eq!(
1533                resolve_request_path(&config, attack),
1534                ResolvedPath::NotFound,
1535                "the resolver must 404 a symlink out of the static overlay: {attack}"
1536            );
1537        }
1538    }
1539
1540    // ==================== Static Folder Tests ====================
1541
1542    #[test]
1543    fn test_precedence_base_dir_over_static() {
1544        // Request /image.png with file in BOTH locations
1545        // Should prefer base_dir (step 1 wins over step 4b)
1546        let fixture = TestFixture::new();
1547        fs::write(fixture.path().join("image.png"), "direct").unwrap();
1548        fs::write(fixture.path().join("static/image.png"), "static").unwrap();
1549
1550        let result = resolve_request_path(&fixture.config(), "image.png");
1551
1552        // Should return base_dir file, not static folder
1553        assert_eq!(
1554            result,
1555            ResolvedPath::StaticFile(fixture.canonical_path().join("image.png"))
1556        );
1557
1558        // Verify the correct file would be served by checking content
1559        let resolved_path = match result {
1560            ResolvedPath::StaticFile(p) => p,
1561            _ => panic!("Expected StaticFile"),
1562        };
1563        let content = fs::read_to_string(resolved_path).unwrap();
1564        assert_eq!(
1565            content, "direct",
1566            "Should serve file from base_dir, not static folder"
1567        );
1568    }
1569
1570    #[test]
1571    fn test_safe_join_failure_static_fallback() {
1572        // Request /images/blog/photo.png where:
1573        // - base_dir/images/ does NOT exist (safe_join fails)
1574        // - static/images/blog/photo.png DOES exist
1575        // This is the exact regression case
1576        let fixture = TestFixture::new();
1577        fs::create_dir_all(fixture.path().join("static/images/blog")).unwrap();
1578        fs::write(fixture.path().join("static/images/blog/photo.png"), "image").unwrap();
1579        // Note: base_dir/images/ does NOT exist
1580
1581        let result = resolve_request_path(&fixture.config(), "images/blog/photo.png");
1582
1583        let expected = fixture
1584            .path()
1585            .join("static/images/blog/photo.png")
1586            .canonicalize()
1587            .unwrap();
1588        assert_eq!(result, ResolvedPath::StaticFile(expected));
1589    }
1590
1591    #[test]
1592    fn test_empty_static_folder_config() {
1593        // Config with static_folder = ""
1594        // Static folder lookup should be skipped
1595        let dir = TempDir::new().unwrap();
1596        fs::create_dir(dir.path().join("static")).unwrap();
1597        fs::write(dir.path().join("static/file.txt"), "content").unwrap();
1598
1599        let extensions = vec![String::from("md")];
1600        let tag_sources: Vec<String> = vec![];
1601        let vault = LocalVault::new(dir.path());
1602        let config = PathResolverConfig {
1603            vault: &vault,
1604            static_vault: None,
1605            static_folder: "", // Empty!
1606            markdown_extensions: &extensions,
1607            index_file: "index.md",
1608            tag_sources: &tag_sources,
1609            exempt_hidden_dirs: &[],
1610        };
1611
1612        let result = resolve_request_path(&config, "file.txt");
1613        assert_eq!(result, ResolvedPath::NotFound);
1614    }
1615
1616    #[test]
1617    fn test_deeply_nested_static_path() {
1618        // Test 5+ levels of nesting
1619        let fixture = TestFixture::new();
1620        fs::create_dir_all(fixture.path().join("static/a/b/c/d/e")).unwrap();
1621        fs::write(fixture.path().join("static/a/b/c/d/e/deep.png"), "deep").unwrap();
1622
1623        let result = resolve_request_path(&fixture.config(), "a/b/c/d/e/deep.png");
1624
1625        let expected = fixture
1626            .path()
1627            .join("static/a/b/c/d/e/deep.png")
1628            .canonicalize()
1629            .unwrap();
1630        assert_eq!(result, ResolvedPath::StaticFile(expected));
1631    }
1632
1633    /// A trailing slash on a static file request. This used to depend on the
1634    /// platform's `canonicalize()`: macOS tolerated `photo.png/` and served the
1635    /// file, Linux rejected it with `ENOTDIR` and 404'd. A request path is now
1636    /// normalized into a `VaultPath` before the filesystem sees it, so a
1637    /// trailing separator means nothing anywhere and every platform answers as
1638    /// macOS did — which is also what the root-folder half of the resolver
1639    /// already answered on Linux, through `safe_join`'s missing-file branch.
1640    #[test]
1641    fn test_static_folder_with_trailing_slash_request() {
1642        let fixture = TestFixture::new();
1643        fs::create_dir_all(fixture.path().join("static/images")).unwrap();
1644        fs::write(fixture.path().join("static/images/photo.png"), "img").unwrap();
1645
1646        let result = resolve_request_path(&fixture.config(), "images/photo.png/");
1647
1648        let expected = fixture
1649            .path()
1650            .join("static/images/photo.png")
1651            .canonicalize()
1652            .unwrap();
1653        assert_eq!(result, ResolvedPath::StaticFile(expected));
1654    }
1655
1656    #[test]
1657    fn test_static_folder_url_encoded_spaces() {
1658        // Test that paths with spaces work through static folder
1659        let fixture = TestFixture::new();
1660        fs::create_dir_all(fixture.path().join("static/my images")).unwrap();
1661        fs::write(
1662            fixture.path().join("static/my images/photo file.jpg"),
1663            "img",
1664        )
1665        .unwrap();
1666
1667        // URL-decoded path (as server would provide after decoding)
1668        let result = resolve_request_path(&fixture.config(), "my images/photo file.jpg");
1669
1670        let expected = fixture
1671            .path()
1672            .join("static/my images/photo file.jpg")
1673            .canonicalize()
1674            .unwrap();
1675        assert_eq!(result, ResolvedPath::StaticFile(expected));
1676    }
1677
1678    // ==================== Non-local vaults ====================
1679
1680    fn mem_fixture() -> crate::vault::MemVault {
1681        crate::vault::MemVault::new()
1682            .with_file("readme.md", "# R")
1683            .with_file("docs/index.md", "# D")
1684            .with_file("docs/guide.md", "# G")
1685            .with_file("notes/a.b.md", "# AB")
1686            .with_file("img.png", "png")
1687            .with_file("static/images/photo.png", "img")
1688            .with_file("static/readme.md", "# shadowed")
1689            .with_dir("empty")
1690    }
1691
1692    /// Every resolution step answers the same through a vault that has no
1693    /// filesystem at all.
1694    #[test]
1695    fn test_mem_vault_resolves_every_kind() {
1696        let vault = mem_fixture();
1697        let extensions = vec![String::from("md")];
1698        let tag_sources = vec![String::from("tags")];
1699        let config = PathResolverConfig {
1700            vault: &vault,
1701            static_vault: None,
1702            static_folder: "static",
1703            markdown_extensions: &extensions,
1704            index_file: "index.md",
1705            tag_sources: &tag_sources,
1706            exempt_hidden_dirs: &[],
1707        };
1708        let root = PathBuf::from(crate::vault::MemVault::DEFAULT_ROOT);
1709        let key = |rel: &str| VaultPath::new(rel).unwrap().to_native(&root);
1710        let cases = [
1711            ("readme.md", ResolvedPath::MarkdownFile(key("readme.md"))),
1712            ("readme/", ResolvedPath::MarkdownFile(key("readme.md"))),
1713            ("readme", ResolvedPath::MarkdownFile(key("readme.md"))),
1714            ("docs", ResolvedPath::MarkdownFile(key("docs/index.md"))),
1715            ("docs/", ResolvedPath::MarkdownFile(key("docs/index.md"))),
1716            ("docs/index/", ResolvedPath::Redirect("/docs/".to_string())),
1717            (
1718                "docs/guide/",
1719                ResolvedPath::MarkdownFile(key("docs/guide.md")),
1720            ),
1721            (
1722                "notes/a.b/",
1723                ResolvedPath::MarkdownFile(key("notes/a.b.md")),
1724            ),
1725            ("img.png", ResolvedPath::StaticFile(key("img.png"))),
1726            (
1727                "images/photo.png",
1728                ResolvedPath::StaticFile(key("static/images/photo.png")),
1729            ),
1730            ("empty/", ResolvedPath::DirectoryListing(key("empty"))),
1731            ("notes", ResolvedPath::DirectoryListing(key("notes"))),
1732            ("", ResolvedPath::DirectoryListing(root.clone())),
1733            ("missing/", ResolvedPath::NotFound),
1734            ("../readme.md", ResolvedPath::NotFound),
1735            ("/readme.md", ResolvedPath::NotFound),
1736            (
1737                "tags/rust/",
1738                ResolvedPath::TagPage {
1739                    source: "tags".to_string(),
1740                    value: "rust".to_string(),
1741                },
1742            ),
1743        ];
1744        for (request, expected) in cases {
1745            assert_eq!(
1746                resolve_request_path(&config, request),
1747                expected,
1748                "{request:?}"
1749            );
1750        }
1751    }
1752
1753    /// An external overlay is a vault of its own, whatever it is backed by.
1754    #[test]
1755    fn test_mem_vault_external_overlay() {
1756        let vault = mem_fixture();
1757        let overlay = crate::vault::MemVault::with_root("/mbr-mem-overlay")
1758            .with_file("logo.png", "PNG")
1759            .with_file("css/site.css", "body{}");
1760        let extensions = vec![String::from("md")];
1761        let tag_sources: Vec<String> = vec![];
1762        let config = PathResolverConfig {
1763            vault: &vault,
1764            static_vault: Some(&overlay),
1765            static_folder: "../static",
1766            markdown_extensions: &extensions,
1767            index_file: "index.md",
1768            tag_sources: &tag_sources,
1769            exempt_hidden_dirs: &[],
1770        };
1771        assert_eq!(
1772            resolve_request_path(&config, "css/site.css"),
1773            ResolvedPath::StaticFile(
1774                PathBuf::from("/mbr-mem-overlay")
1775                    .join("css")
1776                    .join("site.css")
1777            )
1778        );
1779        // The in-vault `static/` folder is not consulted once an overlay is set.
1780        assert_eq!(
1781            resolve_request_path(&config, "images/photo.png"),
1782            ResolvedPath::NotFound
1783        );
1784        assert_eq!(find_in_static_folder(&config, "../logo.png"), None);
1785    }
1786
1787    /// The vault root has no name, so resolving `/` in a repository without an
1788    /// index never probes `<parent>/<repo>.md` — a file *outside* the
1789    /// repository the path-based resolver would have served at `/`.
1790    #[test]
1791    fn test_root_request_never_probes_a_sibling_of_the_repository() {
1792        let outer = TempDir::new().unwrap();
1793        let repo = outer.path().join("notes");
1794        fs::create_dir(&repo).unwrap();
1795        fs::write(outer.path().join("notes.md"), "# Outside the repository").unwrap();
1796        let vault = LocalVault::new(&repo);
1797        let extensions = vec![String::from("md")];
1798        let tag_sources: Vec<String> = vec![];
1799        let config = PathResolverConfig {
1800            vault: &vault,
1801            static_vault: None,
1802            static_folder: "static",
1803            markdown_extensions: &extensions,
1804            index_file: "index.md",
1805            tag_sources: &tag_sources,
1806            exempt_hidden_dirs: &[],
1807        };
1808        assert_eq!(
1809            resolve_request_path(&config, ""),
1810            ResolvedPath::DirectoryListing(vault.root().to_path_buf())
1811        );
1812    }
1813}
1814
1815#[cfg(test)]
1816mod proptests {
1817    use super::*;
1818    use crate::vault::LocalVault;
1819    use proptest::prelude::*;
1820    use std::fs;
1821    use tempfile::TempDir;
1822
1823    // Strategy for valid path component names
1824    fn path_component_strategy() -> impl Strategy<Value = String> {
1825        "[a-zA-Z0-9_-]{1,12}"
1826    }
1827
1828    // Strategy for valid extensions
1829    fn extension_strategy() -> impl Strategy<Value = String> {
1830        "[a-z]{1,5}"
1831    }
1832
1833    proptest! {
1834        /// is_markdown_file is deterministic
1835        #[test]
1836        fn prop_is_markdown_file_deterministic(
1837            filename in path_component_strategy(),
1838            ext in extension_strategy(),
1839            extensions in proptest::collection::vec(extension_strategy(), 1..4)
1840        ) {
1841            let path = PathBuf::from(format!("{}.{}", filename, ext));
1842            let result1 = is_markdown_file(&path, &extensions);
1843            let result2 = is_markdown_file(&path, &extensions);
1844            prop_assert_eq!(result1, result2);
1845        }
1846
1847        /// is_markdown_file returns true when extension matches
1848        #[test]
1849        fn prop_is_markdown_file_matches_extension(
1850            filename in path_component_strategy(),
1851            extensions in proptest::collection::vec(extension_strategy(), 1..4)
1852        ) {
1853            // Use the first extension from the list
1854            if let Some(ext) = extensions.first() {
1855                let path = PathBuf::from(format!("{}.{}", filename, ext));
1856                prop_assert!(is_markdown_file(&path, &extensions));
1857            }
1858        }
1859
1860        /// strip_trailing_separator is idempotent
1861        #[test]
1862        fn prop_strip_trailing_separator_idempotent(
1863            components in proptest::collection::vec(path_component_strategy(), 1..5)
1864        ) {
1865            let path_str = format!("/{}/", components.join("/"));
1866            let path = Path::new(&path_str);
1867
1868            let once = strip_trailing_separator(path);
1869            let twice = strip_trailing_separator(&once);
1870
1871            prop_assert_eq!(once, twice);
1872        }
1873
1874        /// strip_trailing_separator never ends with a separator (except for root).
1875        ///
1876        /// Asserts on `/` (the URL contract this function actually operates
1877        /// under) *and* on the platform separator, so neither assumption can
1878        /// regress independently.
1879        #[test]
1880        fn prop_strip_trailing_separator_no_trailing(
1881            components in proptest::collection::vec(path_component_strategy(), 1..5)
1882        ) {
1883            let path_str = format!("/{}/", components.join("/"));
1884            let path = Path::new(&path_str);
1885            let result = strip_trailing_separator(path);
1886            let result_str = result.to_string_lossy();
1887
1888            prop_assert!(
1889                !result_str.ends_with('/'),
1890                "Result {:?} should not end with /",
1891                result_str
1892            );
1893            prop_assert!(
1894                !result_str.ends_with(std::path::MAIN_SEPARATOR),
1895                "Result {:?} should not end with the platform separator",
1896                result_str
1897            );
1898        }
1899
1900        /// Path resolution is deterministic for the same filesystem state
1901        #[test]
1902        fn prop_path_resolution_deterministic(
1903            request_path in proptest::collection::vec(path_component_strategy(), 0..3)
1904        ) {
1905            let dir = TempDir::new().unwrap();
1906            fs::create_dir(dir.path().join("static")).unwrap();
1907
1908            // Create a markdown file
1909            fs::write(dir.path().join("test.md"), "# Test").unwrap();
1910
1911            let extensions = vec![String::from("md")];
1912            let tag_sources: Vec<String> = vec![];
1913            let vault = LocalVault::new(dir.path());
1914            let config = PathResolverConfig {
1915                vault: &vault,
1916                static_vault: None,
1917                static_folder: "static",
1918                markdown_extensions: &extensions,
1919                index_file: "index.md",
1920                tag_sources: &tag_sources,
1921                exempt_hidden_dirs: &[],
1922            };
1923
1924            let path_str = request_path.join("/");
1925
1926            let result1 = resolve_request_path(&config, &path_str);
1927            let result2 = resolve_request_path(&config, &path_str);
1928
1929            prop_assert_eq!(result1, result2);
1930        }
1931
1932        /// Path traversal with ".." in paths doesn't cause panics
1933        /// and returns deterministic results
1934        #[test]
1935        fn prop_path_traversal_no_panic(
1936            prefix in proptest::collection::vec(path_component_strategy(), 0..2),
1937            suffix in proptest::collection::vec(path_component_strategy(), 0..2)
1938        ) {
1939            let dir = TempDir::new().unwrap();
1940            let base_dir = dir.path();
1941            fs::create_dir(base_dir.join("static")).unwrap();
1942
1943            let extensions = vec![String::from("md")];
1944            let tag_sources: Vec<String> = vec![];
1945            let vault = LocalVault::new(base_dir);
1946            let config = PathResolverConfig {
1947                vault: &vault,
1948                static_vault: None,
1949                static_folder: "static",
1950                markdown_extensions: &extensions,
1951                index_file: "index.md",
1952                tag_sources: &tag_sources,
1953                exempt_hidden_dirs: &[],
1954            };
1955
1956            // Try various path traversal patterns
1957            let attack_paths = vec![
1958                format!("{}/../{}", prefix.join("/"), suffix.join("/")),
1959                format!("../{}", suffix.join("/")),
1960                format!("{}/../../{}", prefix.join("/"), suffix.join("/")),
1961            ];
1962
1963            for attack_path in attack_paths {
1964                // Should not panic and should return consistent results
1965                let result1 = resolve_request_path(&config, &attack_path);
1966                let result2 = resolve_request_path(&config, &attack_path);
1967                prop_assert_eq!(result1, result2, "Results should be deterministic for {:?}", attack_path);
1968            }
1969        }
1970
1971        /// Whatever the request — separators, dots, encodings, drive letters —
1972        /// anything resolved is inside the vault.
1973        #[test]
1974        fn prop_resolved_paths_stay_inside_the_vault(
1975            request in "[a-z./\\\\%:C]{0,24}"
1976        ) {
1977            let vault = crate::vault::MemVault::new()
1978                .with_file("a/b.md", "# B")
1979                .with_file("static/c.png", "c")
1980                .with_file("d.md", "# D");
1981            let extensions = vec![String::from("md")];
1982            let tag_sources: Vec<String> = vec![];
1983            let config = PathResolverConfig {
1984                vault: &vault,
1985                static_vault: None,
1986                static_folder: "static",
1987                markdown_extensions: &extensions,
1988                index_file: "index.md",
1989                tag_sources: &tag_sources,
1990                exempt_hidden_dirs: &[],
1991            };
1992            match resolve_request_path(&config, &request) {
1993                ResolvedPath::StaticFile(p)
1994                | ResolvedPath::MarkdownFile(p)
1995                | ResolvedPath::DirectoryListing(p) => {
1996                    prop_assert!(
1997                        p.starts_with(crate::vault::MemVault::DEFAULT_ROOT),
1998                        "{:?} resolved to {:?}", request, p
1999                    );
2000                }
2001                ResolvedPath::Redirect(url) => {
2002                    prop_assert!(url.starts_with('/') && !url.contains(".."), "{:?}", url);
2003                }
2004                _ => {}
2005            }
2006        }
2007    }
2008}
2009
2010/// The hidden-file rule and symlink mounts, end to end through the resolver.
2011///
2012/// Unix only where real symlinks are needed. Temp dirs get a visible prefix:
2013/// `tempfile`'s default `.tmpXXXX` is itself hidden, so a mount target under
2014/// one would be refused by the very rule under test.
2015#[cfg(test)]
2016mod hidden_and_mount_tests {
2017    use super::*;
2018    use crate::vault::LocalVault;
2019    // Only the symlink fixtures below use it, and they are Unix-only.
2020    #[cfg(unix)]
2021    use crate::vault::MountPolicy;
2022    use std::fs;
2023
2024    fn visible_tempdir() -> tempfile::TempDir {
2025        tempfile::Builder::new()
2026            .prefix("mbr-resolver-test-")
2027            .tempdir()
2028            .unwrap()
2029    }
2030
2031    fn resolve(
2032        vault: &dyn Vault,
2033        static_vault: Option<&dyn Vault>,
2034        exempt: &[VaultPath],
2035        request: &str,
2036    ) -> ResolvedPath {
2037        let extensions = vec![String::from("md")];
2038        let tag_sources = vec![String::from("tags")];
2039        let config = PathResolverConfig {
2040            vault,
2041            static_vault,
2042            static_folder: if static_vault.is_some() {
2043                "../static"
2044            } else {
2045                "static"
2046            },
2047            markdown_extensions: &extensions,
2048            index_file: "index.md",
2049            tag_sources: &tag_sources,
2050            exempt_hidden_dirs: exempt,
2051        };
2052        resolve_request_path(&config, request)
2053    }
2054
2055    #[test]
2056    fn hidden_files_and_directories_are_never_resolved() {
2057        let dir = visible_tempdir();
2058        let root = dir.path().canonicalize().unwrap();
2059        for (path, body) in [
2060            (".env", "SECRET=1"),
2061            (".git/config", "[remote]"),
2062            (".git/notes.md", "# in git"),
2063            ("docs/.private/plan.md", "# plan"),
2064            ("docs/.hidden.png", "png"),
2065            ("docs/visible.md", "# ok"),
2066            ("static/.htpasswd", "user:hash"),
2067            ("static/images/.DS_Store", "junk"),
2068            ("static/images/logo.png", "png"),
2069            ("static/.well-known/security.txt", "Contact: x"),
2070            (".mbr/config.toml", "edit_token_hash = 'x'"),
2071        ] {
2072            let file = root.join(path);
2073            fs::create_dir_all(file.parent().unwrap()).unwrap();
2074            fs::write(file, body).unwrap();
2075        }
2076        let vault = LocalVault::new(&root);
2077        for request in [
2078            ".env",
2079            ".git/config",
2080            ".git",
2081            ".git/",
2082            ".git/notes/",
2083            "docs/.private/plan/",
2084            "docs/.private/plan.md",
2085            "docs/.hidden.png",
2086            ".htpasswd",
2087            "static/.htpasswd",
2088            "images/.DS_Store",
2089            ".mbr/config.toml",
2090            "docs/../.env",
2091        ] {
2092            assert_eq!(
2093                resolve(&vault, None, &[], request),
2094                ResolvedPath::NotFound,
2095                "{request} must not resolve"
2096            );
2097        }
2098        assert!(matches!(
2099            resolve(&vault, None, &[], "docs/visible/"),
2100            ResolvedPath::MarkdownFile(_)
2101        ));
2102        assert!(matches!(
2103            resolve(&vault, None, &[], "images/logo.png"),
2104            ResolvedPath::StaticFile(_)
2105        ));
2106        // RFC 8615 site metadata is the one dot path that is published.
2107        assert!(matches!(
2108            resolve(&vault, None, &[], ".well-known/security.txt"),
2109            ResolvedPath::StaticFile(_)
2110        ));
2111        // Tag pages are not files: a tag value may start with a dot.
2112        assert!(matches!(
2113            resolve(&vault, None, &[], "tags/.net/"),
2114            ResolvedPath::TagPage { .. }
2115        ));
2116    }
2117
2118    /// The one exemption: hidden directories the user named on the command
2119    /// line — and only the named chain, not dot directories inside it.
2120    #[test]
2121    fn an_explicitly_named_hidden_directory_is_served() {
2122        let dir = visible_tempdir();
2123        let root = dir.path().canonicalize().unwrap();
2124        fs::create_dir_all(root.join(".scratch/.git")).unwrap();
2125        fs::write(root.join(".scratch/alpha.md"), "# Alpha").unwrap();
2126        fs::write(root.join(".scratch/.git/config"), "x").unwrap();
2127        let vault = LocalVault::new(&root);
2128        let exempt = [VaultPath::new(".scratch").unwrap()];
2129        assert!(matches!(
2130            resolve(&vault, None, &exempt, ".scratch/alpha/"),
2131            ResolvedPath::MarkdownFile(_)
2132        ));
2133        assert_eq!(
2134            resolve(&vault, None, &exempt, ".scratch/.git/config"),
2135            ResolvedPath::NotFound
2136        );
2137        assert_eq!(
2138            resolve(&vault, None, &[], ".scratch/alpha/"),
2139            ResolvedPath::NotFound
2140        );
2141    }
2142
2143    /// A link that is not itself hidden cannot reach a hidden target.
2144    #[cfg(unix)]
2145    #[test]
2146    fn a_visible_link_to_a_hidden_directory_is_not_resolved() {
2147        let dir = visible_tempdir();
2148        let root = dir.path().canonicalize().unwrap();
2149        fs::create_dir_all(root.join(".git")).unwrap();
2150        fs::write(root.join(".git/config"), "x").unwrap();
2151        std::os::unix::fs::symlink(root.join(".git"), root.join("notes")).unwrap();
2152        let vault = LocalVault::new(&root);
2153        assert_eq!(
2154            resolve(&vault, None, &[], "notes/config"),
2155            ResolvedPath::NotFound
2156        );
2157    }
2158
2159    /// `site/{content, static}` with `static/videos -> <base>/movies`, and a
2160    /// set of links that must never serve anything: to `/`, to `$HOME`, to an
2161    /// ancestor of the root, to a hidden directory. `$HOME` is a fake.
2162    #[cfg(unix)]
2163    struct MountSite {
2164        _dir: tempfile::TempDir,
2165        base: PathBuf,
2166        content: PathBuf,
2167        overlay: PathBuf,
2168        movies: PathBuf,
2169        extra: PathBuf,
2170    }
2171
2172    #[cfg(unix)]
2173    impl MountSite {
2174        fn new() -> Self {
2175            use std::os::unix::fs::symlink;
2176            let dir = visible_tempdir();
2177            let base = dir.path().canonicalize().unwrap();
2178            let content = base.join("site/content");
2179            let overlay = base.join("site/static");
2180            let movies = base.join("movies");
2181            let extra = base.join("extra");
2182            for (path, body) in [
2183                ("site/content/index.md", "# Home"),
2184                ("site/content/notes/a.md", "# A"),
2185                ("site/content/.env", "SECRET=1"),
2186                ("site/static/images/logo.png", "png"),
2187                ("movies/clip.mp4", "clip"),
2188                ("movies/notes.md", "# Mounted"),
2189                ("movies/sub/deep.mp4", "deep"),
2190                ("movies/.secret/key", "key"),
2191                ("movies/.dotfile", "dot"),
2192                ("extra/x.mp4", "x"),
2193                ("home/diary.md", "diary"),
2194                (".hidden/key", "key"),
2195                ("secret.txt", "secret"),
2196            ] {
2197                let file = base.join(path);
2198                fs::create_dir_all(file.parent().unwrap()).unwrap();
2199                fs::write(file, body).unwrap();
2200            }
2201            // Accepted: the user's case, plus a nested mount inside it, a link
2202            // back into the repository and a cycle.
2203            symlink(&movies, overlay.join("videos")).unwrap();
2204            symlink(&movies, content.join("media")).unwrap();
2205            symlink(&extra, movies.join("more")).unwrap();
2206            symlink(content.join("notes"), movies.join("back")).unwrap();
2207            symlink(&movies, movies.join("loop")).unwrap();
2208            // Refused, every one of them.
2209            symlink("/", overlay.join("fsroot")).unwrap();
2210            symlink(base.join("home"), overlay.join("home")).unwrap();
2211            symlink(&base, content.join("up")).unwrap();
2212            symlink("../..", movies.join("climb")).unwrap();
2213            symlink(base.join(".hidden"), overlay.join("hidden")).unwrap();
2214            Self {
2215                _dir: dir,
2216                base,
2217                content,
2218                overlay,
2219                movies,
2220                extra,
2221            }
2222        }
2223
2224        fn policy(&self, other: &Path) -> MountPolicy {
2225            MountPolicy::default()
2226                .with_home(Some(self.base.join("home")))
2227                .with_other_root(other)
2228        }
2229
2230        fn vaults(&self) -> (LocalVault, LocalVault) {
2231            (
2232                LocalVault::with_mounts(&self.content, self.policy(&self.overlay)),
2233                LocalVault::with_mounts(
2234                    &self.overlay,
2235                    self.policy(&self.content).with_markdown_root(&self.content),
2236                ),
2237            )
2238        }
2239
2240        /// Where a served key really is, if it is somewhere it may be.
2241        fn allowed(&self, key: &Path) -> bool {
2242            let Ok(real) = key.canonicalize() else {
2243                return false;
2244            };
2245            [&self.content, &self.overlay, &self.movies, &self.extra]
2246                .iter()
2247                .any(|root| {
2248                    real.strip_prefix(root).is_ok_and(|below| {
2249                        !below.components().any(|c| {
2250                            c.as_os_str()
2251                                .to_str()
2252                                .is_none_or(crate::vault::is_hidden_segment)
2253                        })
2254                    })
2255                })
2256        }
2257    }
2258
2259    #[cfg(unix)]
2260    #[test]
2261    fn mounts_are_served_and_refused_targets_are_not() {
2262        let site = MountSite::new();
2263        let (vault, overlay) = site.vaults();
2264        let served = |request: &str| match resolve(&vault, Some(&overlay), &[], request) {
2265            ResolvedPath::StaticFile(key) | ResolvedPath::MarkdownFile(key) => {
2266                Some(fs::read_to_string(key).unwrap())
2267            }
2268            _ => None,
2269        };
2270        assert_eq!(served("videos/clip.mp4").as_deref(), Some("clip"));
2271        assert_eq!(served("videos/sub/deep.mp4").as_deref(), Some("deep"));
2272        assert_eq!(served("videos/more/x.mp4").as_deref(), Some("x"));
2273        assert_eq!(served("media/clip.mp4").as_deref(), Some("clip"));
2274        assert_eq!(served("media/notes/").as_deref(), Some("# Mounted"));
2275        assert_eq!(served("media/back/a/").as_deref(), Some("# A"));
2276        assert_eq!(served("media/loop/loop/clip.mp4").as_deref(), Some("clip"));
2277        for refused in [
2278            "videos/.secret/key",
2279            "videos/.dotfile",
2280            "media/.secret/key",
2281            "fsroot/etc/hosts",
2282            "home/diary.md",
2283            "up/secret.txt",
2284            "up/home/diary.md",
2285            "media/climb/secret.txt",
2286            // A link from an overlay mount back into the markdown root: the
2287            // overlay would serve the note's raw source. (Found by the proptest.)
2288            "videos/back/a.md",
2289            "hidden/key",
2290            "videos/../../secret.txt",
2291            "videos/%2e%2e/%2e%2e/secret.txt",
2292            ".env",
2293        ] {
2294            assert_eq!(served(refused), None, "{refused} must not be served");
2295        }
2296        let mut targets: Vec<PathBuf> = vault
2297            .mounts()
2298            .into_iter()
2299            .chain(overlay.mounts())
2300            .map(|m| m.target)
2301            .collect();
2302        targets.sort();
2303        targets.dedup();
2304        let mut expected = vec![site.movies.clone(), site.extra.clone()];
2305        expected.sort();
2306        assert_eq!(
2307            targets, expected,
2308            "only the two legitimate targets are mounted"
2309        );
2310    }
2311
2312    #[cfg(unix)]
2313    mod prop {
2314        use super::*;
2315        use proptest::prelude::*;
2316
2317        const SEGMENTS: &[&str] = &[
2318            "videos",
2319            "media",
2320            "more",
2321            "back",
2322            "loop",
2323            "climb",
2324            "up",
2325            "fsroot",
2326            "home",
2327            "hidden",
2328            "sub",
2329            "notes",
2330            "images",
2331            "etc",
2332            "clip.mp4",
2333            "deep.mp4",
2334            "x.mp4",
2335            "logo.png",
2336            "a",
2337            "a.md",
2338            "index",
2339            "secret.txt",
2340            "diary.md",
2341            "key",
2342            ".secret",
2343            ".dotfile",
2344            ".env",
2345            ".hidden",
2346            "..",
2347            ".",
2348            "%2e%2e",
2349            "static",
2350            "site",
2351            "content",
2352            "movies",
2353            "extra",
2354        ];
2355
2356        proptest! {
2357            #![proptest_config(ProptestConfig::with_cases(64))]
2358
2359            /// No request path resolves to anything outside the repository,
2360            /// the overlay and the accepted mount targets — or to anything
2361            /// hidden in them — and only the legitimate targets get mounted.
2362            #[test]
2363            fn prop_nothing_outside_the_served_roots_resolves(
2364                requests in proptest::collection::vec(
2365                    (proptest::collection::vec(proptest::sample::select(SEGMENTS), 0..7), any::<bool>()),
2366                    1..12,
2367                )
2368            ) {
2369                let site = MountSite::new();
2370                let (vault, overlay) = site.vaults();
2371                for (segments, trailing) in requests {
2372                    let mut request = segments.join("/");
2373                    if trailing {
2374                        request.push('/');
2375                    }
2376                    match resolve(&vault, Some(&overlay), &[], &request) {
2377                        ResolvedPath::StaticFile(key)
2378                        | ResolvedPath::MarkdownFile(key)
2379                        | ResolvedPath::DirectoryListing(key) => {
2380                            prop_assert!(site.allowed(&key), "{request:?} served {key:?}");
2381                        }
2382                        _ => {}
2383                    }
2384                }
2385                for mount in vault.mounts().into_iter().chain(overlay.mounts()) {
2386                    prop_assert!(
2387                        mount.target == site.movies || mount.target == site.extra,
2388                        "unexpected mount {mount:?}"
2389                    );
2390                }
2391            }
2392        }
2393    }
2394}