"""Path resolution: traversal safety, extension rules, and live reload.""" from __future__ import annotations import os from pathlib import Path import pytest from smolweb.site import Document, NotFound, RawFile, Redirect, Site, WmlCard class TestPathTraversal: @pytest.mark.parametrize( "path", [ "/../../etc/passwd", "/../../../../../../etc/passwd", "/foo/../../etc/passwd", "/%2e%2e/etc/passwd", # percent-decoded to ".." before normalizing, then clamped ], ) def test_traversal_attempts_are_refused(self, site: Site, path: str): with pytest.raises(NotFound): site.resolve(path) def test_symlink_escaping_root_is_refused(self, tmp_path: Path): outside = tmp_path / "outside" outside.mkdir() (outside / "secret.md").write_text("# Secret\n", encoding="utf-8") root = tmp_path / "root" root.mkdir() (root / "escape.md").symlink_to(outside / "secret.md") site = Site(root) with pytest.raises(NotFound): site.resolve("/escape") def test_dotfile_paths_are_refused(self, site: Site): with pytest.raises(NotFound): site.resolve("/.secret") class TestResolutionRules: def test_root_serves_index(self, site: Site): doc = site.resolve("/") assert isinstance(doc, Document) assert b"# Home" in doc.gemtext def test_extensionless_path_serves_md_file(self, site: Site): doc = site.resolve("/about") assert isinstance(doc, Document) assert b"# About" in doc.gemtext def test_directory_serves_its_index(self, site: Site): doc = site.resolve("/dir") assert isinstance(doc, Document) assert b"# Nested" in doc.gemtext def test_md_extension_redirects_to_canonical_url(self, site: Site): with pytest.raises(Redirect) as excinfo: site.resolve("/about.md") assert excinfo.value.location == "/about" def test_missing_md_extension_is_not_found(self, site: Site): with pytest.raises(NotFound): site.resolve("/nonexistent.md") def test_raw_file_is_served_with_mime_type(self, site: Site): raw = site.resolve("/img.png") assert isinstance(raw, RawFile) assert raw.mime == "image/png" assert raw.data.startswith(b"\x89PNG") def test_missing_path_is_not_found(self, site: Site): with pytest.raises(NotFound): site.resolve("/nope") def test_bare_unresolvable_segment_is_not_a_root_card(self, site: Site): # Regression: rpartition("/") on a bare top-level segment like # "nope" returns sep="" and parent="" -- which must not be # misread as "card 'nope' of the root index". with pytest.raises(NotFound): site.resolve("/totally-unresolvable-segment") class TestWmlCardUrls: def test_card_subpath_returns_wml_card(self, site: Site): result = site.resolve("/trail/weather") assert isinstance(result, WmlCard) assert b"Cold and clear" in result.wml assert b"$$5" in result.wml # WML's own literal-$ escaping, correctly applied def test_unknown_card_redirects_to_parent(self, site: Site): with pytest.raises(Redirect) as excinfo: site.resolve("/trail/nonexistent-card") assert excinfo.value.location == "/trail" def test_card_url_of_non_deck_per_card_document_is_not_found(self, site: Site): # about.md exists but never opted into deck_per_card, so it has no # wml_cards at all -- /about/whatever must not resolve as a card. with pytest.raises(NotFound): site.resolve("/about/whatever") def test_directive_line_does_not_leak_into_gemtext(self, site: Site): # md2txt's parser has no concept of wapdown's {.card} directive; it # must be stripped before gemtext rendering, not passed through as # literal paragraph text. doc = site.resolve("/trail") assert isinstance(doc, Document) assert b"{.card" not in doc.gemtext assert b"Cold and clear" in doc.gemtext assert b"North: open" in doc.gemtext def test_directive_line_does_not_leak_into_xhtml(self, site: Site): doc = site.resolve("/trail") assert b"{.card" not in doc.xhtml class TestHtmlFallbackHasNoXmlProlog: def test_html_field_has_no_prolog(self, site: Site): # A browser parsing this as text/html (rather than XML) tolerates # a leading as a bogus comment and still renders the # page, but logs a console warning -- so the text/html fallback # must not carry it. doc = site.resolve("/") assert not doc.html.startswith(b"