1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use crate::vault::{EntryKind, Vault, VaultPath, has_hidden_segment, resolve_under};
12
13fn 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
33fn visible(path: VaultPath, exempt: &[VaultPath]) -> Option<VaultPath> {
43 (!has_hidden_segment(&path, exempt)).then_some(path)
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ResolvedPath {
52 StaticFile(PathBuf),
54 MarkdownFile(PathBuf),
56 DirectoryListing(PathBuf),
58 TagPage {
60 source: String,
62 value: String,
64 },
65 TagSourceIndex {
67 source: String,
69 },
70 NotFound,
72 Redirect(String),
74}
75
76#[derive(Debug, Clone, Copy)]
78pub struct PathResolverConfig<'a> {
79 pub vault: &'a dyn Vault,
81 pub static_vault: Option<&'a dyn Vault>,
85 pub static_folder: &'a str,
89 pub markdown_extensions: &'a [String],
90 pub index_file: &'a str,
91 pub tag_sources: &'a [String],
94 pub exempt_hidden_dirs: &'a [VaultPath],
98}
99
100#[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 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 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
154pub 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
177pub 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
204pub fn resolve_request_path(config: &PathResolverConfig, request_path: &str) -> ResolvedPath {
230 let vault = config.vault;
231 let mut static_checked = false;
233
234 if let Some(candidate) = safe_join(vault, request_path, config.exempt_hidden_dirs) {
237 let kind = vault
239 .stat(&candidate)
240 .ok()
241 .flatten()
242 .map(|entry| entry.kind);
243
244 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 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 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 let canonical = if parent.is_root() {
278 "/".to_string()
279 } else {
280 format!("/{parent}/")
281 };
282 return ResolvedPath::Redirect(canonical);
283 }
284
285 if let Some(md_path) = find_markdown_file(vault, &candidate, config.markdown_extensions) {
287 return ResolvedPath::MarkdownFile(vault.key(&md_path));
288 }
289
290 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 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 return ResolvedPath::DirectoryListing(vault.key(&candidate));
307 }
308 }
309
310 if !static_checked && let Some(static_path) = find_in_static_folder(config, request_path) {
314 return ResolvedPath::StaticFile(static_path);
315 }
316
317 if let Some(tag_result) = try_resolve_tag_url(request_path, config.tag_sources) {
320 return tag_result;
321 }
322
323 ResolvedPath::NotFound
325}
326
327fn 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
335pub 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
351fn 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
374fn 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 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
416fn hidden_below(path: &VaultPath, base: &VaultPath) -> bool {
424 path.segments()
425 .skip(base.segments().count())
426 .any(crate::vault::is_hidden_segment)
427}
428
429fn try_resolve_tag_url(request_path: &str, tag_sources: &[String]) -> Option<ResolvedPath> {
438 if tag_sources.is_empty() {
440 return None;
441 }
442
443 let path = request_path.trim_matches('/');
445
446 if path.is_empty() {
448 return None;
449 }
450
451 let segments: Vec<&str> = path.split('/').collect();
453
454 match segments.len() {
455 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 2 => {
466 let source = segments[0].to_lowercase();
467 let value = segments[1].to_lowercase();
468
469 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 _ => 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let result = resolve_request_path(&fixture.config(), "posts/");
741
742 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 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 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 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 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 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 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 assert_eq!(
846 strip_trailing_separator(Path::new("/foo/bar//")),
847 PathBuf::from("/foo/bar")
848 );
849 }
850
851 #[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 assert_eq!(
862 strip_trailing_separator(Path::new(r"\foo\bar/")),
863 PathBuf::from(r"\foo\bar")
864 );
865 }
866
867 #[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 assert_eq!(normalize_link_target("/100%25"), "100%");
912 }
913
914 #[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 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 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(); 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 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 fs::write(fixture.path().join("tags.md"), "# Real Tags Page").unwrap();
1040
1041 let result = resolve_request_path(&fixture.config(), "tags/");
1043
1044 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 fs::create_dir(fixture.path().join("tags")).unwrap();
1056
1057 let result = resolve_request_path(&fixture.config(), "tags/");
1059
1060 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 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 assert_eq!(
1083 try_resolve_tag_url("tags/", &sources),
1084 Some(ResolvedPath::TagSourceIndex {
1085 source: "tags".to_string()
1086 })
1087 );
1088
1089 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 assert_eq!(try_resolve_tag_url("unknown/value", &sources), None);
1100
1101 assert_eq!(try_resolve_tag_url("", &sources), None);
1103
1104 assert_eq!(try_resolve_tag_url("tags/rust", &[]), None);
1106 }
1107
1108 #[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 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 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 let result = resolve_request_path(&fixture.config(), "a/b/c/index/");
1141 assert_eq!(result, ResolvedPath::Redirect("/a/b/c/".to_string()));
1142
1143 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 fs::write(fixture.path().join("index.md"), "# Regular Index").unwrap();
1160
1161 let docs = fixture.path().join("docs");
1163 fs::create_dir(&docs).unwrap();
1164 fs::write(docs.join("readme.md"), "# Readme").unwrap();
1165
1166 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 let result = resolve_request_path(&fixture.config(), "docs/index");
1180 assert_eq!(result, ResolvedPath::Redirect("/docs/".to_string()));
1181 }
1182
1183 #[test]
1186 fn test_path_traversal_blocked_with_dotdot() {
1187 let fixture = TestFixture::new();
1188 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 fs::write(fixture.path().join("static/safe.txt"), "safe content").unwrap();
1216
1217 let attacks = vec![
1219 "../readme.md", "../../etc/passwd", "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 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 fs::write(base.join("inside.txt"), "inside").unwrap();
1245
1246 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 let attack = safe_join_local(base, "../../../etc/passwd");
1253 assert!(attack.is_none(), "Path traversal should be blocked");
1254
1255 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 fs::create_dir_all(base.join("foo/bar")).unwrap();
1270 fs::write(base.join("foo/sibling.txt"), "sibling").unwrap();
1271
1272 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 let result = resolve_request_path(&fixture.config(), "../../../../etc/passwd");
1289
1290 assert_eq!(result, ResolvedPath::NotFound);
1292 }
1293
1294 #[test]
1295 fn test_symlink_escape_blocked() {
1296 let dir = TempDir::new().unwrap();
1298 let base = dir.path();
1299 fs::create_dir(base.join("static")).unwrap();
1300
1301 #[cfg(unix)]
1304 {
1305 use std::os::unix::fs::symlink;
1306 let link_path = base.join("static/escape");
1307 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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[test]
1543 fn test_precedence_base_dir_over_static() {
1544 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 assert_eq!(
1554 result,
1555 ResolvedPath::StaticFile(fixture.canonical_path().join("image.png"))
1556 );
1557
1558 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 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 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 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: "", 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 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 #[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 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 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 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 #[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 #[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 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 #[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 fn path_component_strategy() -> impl Strategy<Value = String> {
1825 "[a-zA-Z0-9_-]{1,12}"
1826 }
1827
1828 fn extension_strategy() -> impl Strategy<Value = String> {
1830 "[a-z]{1,5}"
1831 }
1832
1833 proptest! {
1834 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 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 #[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#[cfg(test)]
2016mod hidden_and_mount_tests {
2017 use super::*;
2018 use crate::vault::LocalVault;
2019 #[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 assert!(matches!(
2108 resolve(&vault, None, &[], ".well-known/security.txt"),
2109 ResolvedPath::StaticFile(_)
2110 ));
2111 assert!(matches!(
2113 resolve(&vault, None, &[], "tags/.net/"),
2114 ResolvedPath::TagPage { .. }
2115 ));
2116 }
2117
2118 #[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 #[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 #[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 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 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 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 "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 #[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}