Reuses md2txt's existing nex and text renderers (both already ship unmodified) -- rendered unwrapped gemtext still works for Gemini/Spartan clients that wrap themselves, but Nex and Gopher clients don't, so those two get the classic 80-column pre-wrap instead. Neither protocol has a redirect status of its own, so Site.resolve_flat() follows the /foo.md -> /foo canonical bounce and the WML-card-only -> parent redirect server-side and hands back real content directly, rather than leaving the two new listeners to invent a redirect convention that doesn't exist on the wire. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
213 lines
8.3 KiB
Python
213 lines
8.3 KiB
Python
"""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
|
|
|
|
def test_directive_line_does_not_leak_into_nex_or_gopher(self, site: Site):
|
|
doc = site.resolve("/trail")
|
|
assert b"{.card" not in doc.nex
|
|
assert b"{.card" not in doc.gopher
|
|
assert b"Cold and clear" in doc.nex
|
|
assert b"Cold and clear" in doc.gopher
|
|
|
|
|
|
class TestNexAndGopherRendering:
|
|
def test_nex_and_gopher_fields_are_rendered(self, site: Site):
|
|
doc = site.resolve("/about")
|
|
assert isinstance(doc, Document)
|
|
assert b"About" in doc.nex
|
|
assert b"Body." in doc.nex
|
|
# md2txt's `text` renderer (unlike `nex`) still FIGlet-banners
|
|
# h1-h3 by default, so only plain paragraph text is checked here.
|
|
assert b"Body." in doc.gopher
|
|
|
|
def test_nex_heading_has_no_figlet_banner(self, site: Site):
|
|
# The nex renderer uses a plain setext-style underline, never a
|
|
# FIGlet banner -- unlike md2txt's own `text` renderer, which does
|
|
# use one for h1-h3 by default.
|
|
doc = site.resolve("/")
|
|
assert b"# Home" not in doc.nex
|
|
assert b"Home" in doc.nex
|
|
assert b"====" in doc.nex
|
|
|
|
|
|
class TestResolveFlat:
|
|
def test_canonical_md_redirect_is_resolved_not_bounced(self, site: Site):
|
|
# Nex and Gopher have no redirect status: a request for the .md
|
|
# form must come back with the real document, not a Redirect.
|
|
resource = site.resolve_flat("/about.md")
|
|
assert isinstance(resource, Document)
|
|
assert b"About" in resource.nex
|
|
|
|
def test_unmatched_card_is_resolved_to_parent_document(self, site: Site):
|
|
resource = site.resolve_flat("/trail/nonexistent-card")
|
|
assert isinstance(resource, Document)
|
|
assert b"North: open" in resource.gopher
|
|
|
|
def test_card_subpath_is_resolved_to_parent_document_not_the_wml_card(self, site: Site):
|
|
# /trail/weather is WML-only URL space; resolve_flat must land on
|
|
# trail.md's own Document (which has .nex/.gopher), never leak a
|
|
# WmlCard (which has neither) out to the Nex/Gopher servers.
|
|
resource = site.resolve_flat("/trail/weather")
|
|
assert isinstance(resource, Document)
|
|
|
|
def test_still_raises_not_found_for_a_missing_path(self, site: Site):
|
|
with pytest.raises(NotFound):
|
|
site.resolve_flat("/nope")
|
|
|
|
|
|
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 <?xml ...?> 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"<?xml")
|
|
assert doc.html.startswith(b"<!DOCTYPE html")
|
|
|
|
def test_xhtml_field_keeps_the_prolog(self, site: Site):
|
|
# Real XHTML-MP/WAP 2.0 clients still need well-formed XML.
|
|
doc = site.resolve("/")
|
|
assert doc.xhtml.startswith(b'<?xml version="1.0"')
|
|
|
|
def test_html_and_xhtml_are_otherwise_identical(self, site: Site):
|
|
doc = site.resolve("/")
|
|
assert doc.html == doc.xhtml[doc.xhtml.index(b"<!DOCTYPE"):]
|
|
|
|
|
|
class TestLiveReload:
|
|
def test_edited_file_is_rerendered(self, tmp_path: Path):
|
|
target = tmp_path / "index.md"
|
|
target.write_text("# First\n", encoding="utf-8")
|
|
site = Site(tmp_path)
|
|
first = site.resolve("/")
|
|
assert b"# First" in first.gemtext
|
|
|
|
# mtime granularity on some filesystems is coarse; force a change.
|
|
new_time = target.stat().st_mtime + 5
|
|
target.write_text("# Second\n", encoding="utf-8")
|
|
os.utime(target, (new_time, new_time))
|
|
|
|
second = site.resolve("/")
|
|
assert b"# Second" in second.gemtext
|
|
assert b"# First" not in second.gemtext
|
|
|
|
def test_unchanged_file_reuses_cache(self, tmp_path: Path):
|
|
target = tmp_path / "index.md"
|
|
target.write_text("# Same\n", encoding="utf-8")
|
|
site = Site(tmp_path)
|
|
first = site.resolve("/")
|
|
second = site.resolve("/")
|
|
assert first is second
|