Found by design-dev's impeccable run and confirmed at source. Python-Markdown passes raw HTML through, and doc.html and booth.html render the result |safe. A <script> in any session's .md ran on the Booth's origin, and a contract that quoted <pre> opened a real one and swallowed the rest of the doc. Operator ruling: escape raw HTML (not an allowlist). - render_doc deregisters Python-Markdown's block and inline HTML processors, so raw HTML reaches the serializer as text and is escaped there. Fenced and inline code are unchanged. - Every link href in a doc goes through links.is_safe_href after browser-style decoding. Python-Markdown keeps character references in attributes, so `javascript:` reached the browser as `javascript:`. - is_safe_href reads a backslash as a slash, as a browser does in an http(s) URL: `/\evil.test` is `//evil.test`. This also closes the hole on the link board. - A render that raises falls back to raw text, which the template escapes. Two of 19 live .md files render differently. One is a contract losing the quoted <pre> that swallowed it. The other is links.md, which renders as a board, not through render_doc. heid bug-hunt panel (4/4): the core claim held. Its two concrete edges (the backslash twin, the unbounded render) are fixed here. Table tests/mutations/doc_html.toml: 9/9 proved. Suite 951 -> 975.
533 lines
19 KiB
Python
533 lines
19 KiB
Python
"""U1 — the item record.
|
|
|
|
The headline here is `zoom_carries_the_caption`. The operator reported that
|
|
zoomed-in images lose their annotations; the cause was not a rendering bug but
|
|
three independent readers of one truth, of which the zoom route was the one that
|
|
never resolved a caption at all. These tests pin the record and pin the bug.
|
|
|
|
See docs/contracts/u1_item_record.contract.md.
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from booth.app import build_gallery, create_app, list_booths
|
|
from booth.items import (
|
|
CAPTION_MAX,
|
|
Item,
|
|
booth_items,
|
|
find_item,
|
|
image_chain,
|
|
render_doc_body,
|
|
)
|
|
|
|
|
|
def _touch(path, data=b"x"):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path):
|
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
|
return TestClient(app), tmp_path
|
|
|
|
|
|
# ---- the record -------------------------------------------------------------
|
|
|
|
|
|
def test_section_is_the_parent_dir(tmp_path):
|
|
_touch(tmp_path / "root.png")
|
|
_touch(tmp_path / "v3" / "x.png")
|
|
_touch(tmp_path / "v3" / "deep" / "y.png")
|
|
|
|
by_rel = {it.rel: it for it in booth_items(tmp_path)}
|
|
assert by_rel["root.png"].section is None
|
|
assert by_rel["v3/x.png"].section == "v3"
|
|
assert by_rel["v3/deep/y.png"].section == "v3/deep"
|
|
|
|
|
|
def test_caption_sidecars_are_not_items(tmp_path):
|
|
_touch(tmp_path / "a.png")
|
|
(tmp_path / "a.txt").write_text("variant A")
|
|
_touch(tmp_path / "b.png")
|
|
(tmp_path / "b.png.txt").write_text("variant B")
|
|
(tmp_path / "loose.txt").write_text("captions nothing")
|
|
|
|
by_rel = {it.rel: it for it in booth_items(tmp_path)}
|
|
assert by_rel["a.png"].caption == "variant A"
|
|
assert by_rel["b.png"].caption == "variant B"
|
|
assert "a.txt" not in by_rel and "b.png.txt" not in by_rel
|
|
assert "loose.txt" in by_rel # a caption with nothing to caption stays visible
|
|
|
|
|
|
def test_a_txt_beside_a_bin_is_its_own_item(tmp_path):
|
|
"""The `classify != "other"` guard: a .txt only captions a MEDIA sibling."""
|
|
_touch(tmp_path / "data.bin")
|
|
(tmp_path / "data.txt").write_text("not a caption for a blob")
|
|
|
|
by_rel = {it.rel: it for it in booth_items(tmp_path)}
|
|
assert "data.txt" in by_rel
|
|
assert by_rel["data.bin"].caption is None
|
|
|
|
|
|
def test_ask_sidecars_are_not_items(tmp_path):
|
|
_touch(tmp_path / "a.png")
|
|
(tmp_path / "pick.ask.json").write_text("{}")
|
|
(tmp_path / "pick.answer.json").write_text("{}")
|
|
|
|
rels = {it.rel for it in booth_items(tmp_path)}
|
|
assert rels == {"a.png"}
|
|
|
|
|
|
def test_dotfiles_are_not_items(tmp_path):
|
|
_touch(tmp_path / "a.png")
|
|
_touch(tmp_path / ".forever")
|
|
_touch(tmp_path / ".blurred")
|
|
|
|
assert {it.rel for it in booth_items(tmp_path)} == {"a.png"}
|
|
|
|
|
|
def test_blur_state_rides_on_the_item(tmp_path):
|
|
_touch(tmp_path / "a.png")
|
|
_touch(tmp_path / "b.png")
|
|
(tmp_path / ".blurred").write_text("a.png\n")
|
|
|
|
by_rel = {it.rel: it for it in booth_items(tmp_path)}
|
|
assert by_rel["a.png"].blurred is True
|
|
assert by_rel["b.png"].blurred is False
|
|
|
|
|
|
def test_order_matches_todays_gallery(tmp_path):
|
|
"""INV-3: this unit reorganises who computes what. It must not move a tile."""
|
|
_touch(tmp_path / "z.png")
|
|
_touch(tmp_path / "a.png")
|
|
(tmp_path / "a.txt").write_text("cap")
|
|
_touch(tmp_path / "v3" / "b.png")
|
|
_touch(tmp_path / "v4" / "b.png")
|
|
(tmp_path / "notes.md").write_text("# hi")
|
|
_touch(tmp_path / "blob.bin")
|
|
|
|
assert [it.rel for it in booth_items(tmp_path)] == [
|
|
it["name"] for it in build_gallery(tmp_path)
|
|
]
|
|
|
|
|
|
# ---- helpers ----------------------------------------------------------------
|
|
|
|
|
|
def test_image_chain_is_the_images_in_order(tmp_path):
|
|
_touch(tmp_path / "b.png")
|
|
_touch(tmp_path / "a.png")
|
|
_touch(tmp_path / "clip.webm")
|
|
(tmp_path / "notes.md").write_text("# hi")
|
|
|
|
assert image_chain(booth_items(tmp_path)) == ["a.png", "b.png"]
|
|
|
|
|
|
def test_find_item(tmp_path):
|
|
_touch(tmp_path / "a.png")
|
|
items = booth_items(tmp_path)
|
|
assert find_item(items, "a.png").rel == "a.png"
|
|
assert find_item(items, "nope.png") is None
|
|
|
|
|
|
def test_render_doc_body_markdown_and_text(tmp_path):
|
|
(tmp_path / "r.md").write_text("# Title\n\n- a\n- b\n")
|
|
(tmp_path / "n.txt").write_text("plain\ntext")
|
|
items = {it.rel: it for it in booth_items(tmp_path)}
|
|
|
|
html, is_html = render_doc_body(tmp_path, items["r.md"])
|
|
assert is_html and "<h1>" in html
|
|
body, is_html = render_doc_body(tmp_path, items["n.txt"])
|
|
assert not is_html and body == "plain\ntext"
|
|
|
|
|
|
def test_render_doc_body_is_none_for_a_huge_doc(tmp_path):
|
|
from booth.items import DOC_MAX_BYTES
|
|
|
|
(tmp_path / "huge.log").write_text("x" * (DOC_MAX_BYTES + 1))
|
|
items = {it.rel: it for it in booth_items(tmp_path)}
|
|
assert render_doc_body(tmp_path, items["huge.log"]) is None
|
|
|
|
|
|
def test_render_doc_body_is_none_for_a_non_doc(tmp_path):
|
|
_touch(tmp_path / "a.png")
|
|
items = {it.rel: it for it in booth_items(tmp_path)}
|
|
assert render_doc_body(tmp_path, items["a.png"]) is None
|
|
|
|
|
|
# ---- the bug this unit exists to close --------------------------------------
|
|
|
|
|
|
def test_zoom_carries_the_caption(client):
|
|
"""THE operator-reported defect, as a regression test.
|
|
|
|
`a.png.txt` captions `a.png` in the gallery. Before U1 the zoom route
|
|
re-derived the item from scratch and never resolved a caption, so the
|
|
annotation vanished at exactly the size where it is most readable.
|
|
"""
|
|
c, data = client
|
|
b = data / "bo"
|
|
_touch(b / "a.png")
|
|
(b / "a.png.txt").write_text("the annotation that used to vanish")
|
|
|
|
r = c.get("/b/bo/view", params={"f": "a.png"})
|
|
assert r.status_code == 200
|
|
assert "the annotation that used to vanish" in r.text
|
|
|
|
|
|
def test_zoom_carries_the_stem_caption(client):
|
|
"""The other caption form -- `a.txt` beside `a.png`."""
|
|
c, data = client
|
|
b = data / "bo"
|
|
_touch(b / "a.png")
|
|
(b / "a.txt").write_text("stem-form annotation")
|
|
|
|
r = c.get("/b/bo/view", params={"f": "a.png"})
|
|
assert "stem-form annotation" in r.text
|
|
|
|
|
|
def test_doc_view_carries_the_caption(client):
|
|
c, data = client
|
|
b = data / "bo"
|
|
b.mkdir()
|
|
(b / "notes.md").write_text("# body")
|
|
(b / "notes.md.txt").write_text("what this doc is")
|
|
|
|
r = c.get("/b/bo/view", params={"f": "notes.md"})
|
|
assert r.status_code == 200
|
|
assert "what this doc is" in r.text
|
|
|
|
|
|
def test_zoom_prev_next_still_works(client):
|
|
"""image_chain replaces booth_image_names; the ring must be unchanged."""
|
|
c, data = client
|
|
b = data / "bo"
|
|
_touch(b / "a.png")
|
|
_touch(b / "b.png")
|
|
|
|
r = c.get("/b/bo/view", params={"f": "a.png"})
|
|
assert "b.png" in r.text # prev and next both wrap to the only other image
|
|
|
|
|
|
# ---- invariants -------------------------------------------------------------
|
|
|
|
|
|
def test_index_renders_no_doc_bodies(client, monkeypatch):
|
|
"""INV-4: the index calls the resolver once per booth. If it also rendered
|
|
every doc, a page load would markdown-render every doc in every booth."""
|
|
c, data = client
|
|
b = data / "bo"
|
|
b.mkdir()
|
|
(b / "big.md").write_text("# hi")
|
|
_touch(b / "a.png")
|
|
|
|
import booth.items as items_mod
|
|
|
|
def boom(*a, **k):
|
|
raise AssertionError("the index must not render doc bodies")
|
|
|
|
monkeypatch.setattr(items_mod, "render_doc_body", boom)
|
|
assert c.get("/").status_code == 200
|
|
|
|
|
|
def test_app_still_exports_the_moved_names():
|
|
"""INV-5: 22 existing test sites import these from booth.app by name."""
|
|
import booth.app as app_mod
|
|
|
|
for name in (
|
|
"classify",
|
|
"doc_kind",
|
|
"render_doc",
|
|
"CAPTION_MAX",
|
|
"DOC_MAX_BYTES",
|
|
"IMAGE_EXTS",
|
|
"VIDEO_EXTS",
|
|
"AUDIO_EXTS",
|
|
"MARKDOWN_EXTS",
|
|
"TEXT_EXTS",
|
|
):
|
|
assert hasattr(app_mod, name), f"booth.app must still export {name}"
|
|
|
|
|
|
def test_list_booths_counts_match_the_resolver(tmp_path):
|
|
b = tmp_path / "bo"
|
|
_touch(b / "a.png")
|
|
_touch(b / "clip.webm")
|
|
(b / "a.txt").write_text("cap") # captions a.png # a sidecar is not an item
|
|
|
|
got = list_booths(tmp_path, ttl_seconds=86400)[0]
|
|
assert got["count"] == len(booth_items(b)) == 2
|
|
|
|
|
|
# --- U7: the group, derived here and nowhere else -------------------------
|
|
#
|
|
# ⚠ THE RULE IS NOT THE ONE THE CONTRACT FIRST STATED, and the change is
|
|
# measured rather than preferred. The contract's `strip ONE trailing run of
|
|
# digits` yields 24 groups for sindra-bakeoff's 40 images and 27 for sindra's
|
|
# 30 — a rail with one row per tile, which is a second copy of the grid rather
|
|
# than a way through it. Measured against all 17 live booths on 2026-09-22;
|
|
# the numbers are in the contract's rewritten table.
|
|
|
|
|
|
def test_group_of_takes_the_first_segment(tmp_path):
|
|
from booth.items import _group_of
|
|
|
|
assert _group_of("00-sheet-c1-market-noon.png") == "00"
|
|
assert _group_of("m-c1-market-noon-9401.png") == "m"
|
|
assert _group_of("flag-rear.png") == "flag"
|
|
assert _group_of("v30-seed8302-HELD.png") == "v30"
|
|
|
|
|
|
def test_group_of_destems_only_a_flat_name(tmp_path):
|
|
"""`ac01.png` has no separator, so the digits ARE the separator and the
|
|
group is `ac`. `v30-seed8302` HAS one, so `v30` survives intact — stripping
|
|
there would merge v30 with v35, which is the axis that booth is about."""
|
|
from booth.items import _group_of
|
|
|
|
assert _group_of("ac01.png") == "ac"
|
|
assert _group_of("DSC0001.jpg") == "DSC"
|
|
assert _group_of("v30-seed8302.png") == "v30"
|
|
assert _group_of("v35-seed8302.png") == "v35"
|
|
|
|
|
|
def test_group_of_is_none_when_there_is_no_prefix(tmp_path):
|
|
"""A stem that is entirely digits has nothing to group on. Inventing one
|
|
would file every numbered render under the empty string."""
|
|
from booth.items import _group_of
|
|
|
|
assert _group_of("01.png") is None
|
|
assert _group_of("0042.jpg") is None
|
|
assert _group_of("-leading.png") is None
|
|
|
|
|
|
def test_group_is_derived_from_the_basename_not_the_path(tmp_path):
|
|
"""A booth WITH subdirectories still groups on the filename. Sections and
|
|
groups are different questions; `Item.section` still carries the path."""
|
|
from booth.items import _group_of
|
|
|
|
assert _group_of("sub/dir/ac01.png") == "ac"
|
|
|
|
|
|
def test_booth_items_carries_the_group(tmp_path):
|
|
b = tmp_path / "g"
|
|
_touch(b / "ac01.png")
|
|
_touch(b / "ac02.png")
|
|
_touch(b / "99.png")
|
|
got = {it.rel: it.group for it in booth_items(b)}
|
|
assert got == {"ac01.png": "ac", "ac02.png": "ac", "99.png": None}
|
|
|
|
|
|
def test_one_unrepresentable_filename_costs_its_own_tile_not_the_booth(tmp_path):
|
|
"""HULDA, and it is worse than the bundle could see: `quote()` raises
|
|
UnicodeEncodeError on a surrogate from a non-UTF-8 filename, and
|
|
`booth_items` feeds `list_booths` — so ONE 0xff byte in ONE booth's
|
|
filename took out the INDEX for every booth, not just its own page.
|
|
|
|
The repo's standing posture is that a damaged file costs its own tile and
|
|
never the page. A file whose name cannot be percent-encoded cannot be
|
|
linked or served either, so it cannot be an item.
|
|
|
|
Defeating change: dropping the guard — this raises before it renders."""
|
|
import os
|
|
|
|
b = tmp_path / "b"
|
|
b.mkdir()
|
|
(b / "ok.png").write_bytes(b"\x89PNG")
|
|
(b / os.fsdecode(b"bad\xff.png")).write_bytes(b"\x89PNG")
|
|
|
|
got = booth_items(b)
|
|
assert [it.rel for it in got] == ["ok.png"]
|
|
|
|
|
|
|
|
def test_a_folder_that_lists_but_cannot_be_searched_costs_its_files_not_the_index(tmp_path):
|
|
"""Found folding R2's bug-hunt: `Path.is_file()` swallows a missing entry
|
|
but PROPAGATES EACCES. A directory with read and no execute permission
|
|
lists its names, and every stat under it raises — so one such folder in
|
|
one booth took out the index for every booth, the same blast radius as the
|
|
unrepresentable filename above. Its files are not items.
|
|
|
|
Defeating change: calling `is_file()` outside the OSError guard."""
|
|
b = tmp_path / "b"
|
|
b.mkdir()
|
|
(b / "ok.png").write_bytes(b"\x89PNG")
|
|
sub = b / "d"
|
|
sub.mkdir()
|
|
(sub / "x.png").write_bytes(b"\x89PNG")
|
|
sub.chmod(0o644) # r--: listable, nothing inside stat-able
|
|
try:
|
|
with pytest.raises(PermissionError):
|
|
(sub / "x.png").stat() # the fixture is live, not assumed
|
|
assert [it.rel for it in booth_items(b)] == ["ok.png"]
|
|
[row] = list_booths(tmp_path, ttl_seconds=86400)
|
|
assert row["name"] == "b" and row["count"] == 1
|
|
finally:
|
|
sub.chmod(0o755)
|
|
|
|
def test_a_huge_caption_sidecar_is_not_read_whole(tmp_path):
|
|
"""HULDA: `read_text()` pulled the entire sidecar into memory before
|
|
`[:CAPTION_MAX]` trimmed it, and the handler catches only OSError — so a
|
|
pathological sidecar is a MemoryError, not a missing caption.
|
|
|
|
Bounded at the READ. Deliberately NOT bounded by st_size: a FIFO reports
|
|
st_size 0 and a bound that trusts it inherits what it does not mean —
|
|
persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md."""
|
|
b = tmp_path / "b"
|
|
b.mkdir()
|
|
(b / "a.png").write_bytes(b"\x89PNG")
|
|
(b / "a.txt").write_text("x" * (CAPTION_MAX * 50))
|
|
|
|
cap = {it.rel: it.caption for it in booth_items(b)}["a.png"]
|
|
assert cap is not None and len(cap) <= CAPTION_MAX
|
|
|
|
|
|
def test_a_dot_directory_hides_its_whole_subtree(tmp_path):
|
|
"""CLAUDE.md invariant 2 claims a dotfile costs nothing in item counts,
|
|
galleries or zips. That was only true at the TOP LEVEL: both `booth_items`
|
|
and `zip_booth` tested the FILE's name, so `.thumbs/a.png` has `p.name ==
|
|
"a.png"` and sailed through as a gallery item and a zip entry.
|
|
|
|
Pre-existing, found while adding a `.thumbs/` cache. Any path component
|
|
starting with a dot is the Booth's own namespace.
|
|
|
|
Defeating change: back to `p.name.startswith(".")`."""
|
|
import io
|
|
import zipfile
|
|
|
|
from booth.app import zip_booth
|
|
|
|
b = tmp_path / "b"
|
|
(b / ".thumbs").mkdir(parents=True)
|
|
(b / "real.png").write_bytes(b"\x89PNG")
|
|
(b / ".thumbs" / "real.png").write_bytes(b"\x89PNGthumb")
|
|
(b / ".marks.json").write_text("{}")
|
|
|
|
assert [i.rel for i in booth_items(b)] == ["real.png"]
|
|
assert zipfile.ZipFile(io.BytesIO(zip_booth(b))).namelist() == ["real.png"]
|
|
|
|
|
|
# ---- a posted doc cannot run code (2026-09-28) --------------------------------
|
|
#
|
|
# design-dev's impeccable run found it and it held at source: Python-Markdown
|
|
# passes raw HTML through, and doc.html / booth.html render the result `|safe`,
|
|
# so a `<script>` in any agent's `.md` ran on the Booth's origin, and a contract
|
|
# that merely QUOTED `<pre>` opened a real one and swallowed the rest of the
|
|
# doc. Operator ruling: ESCAPE raw HTML, not an allowlist — the live docs that
|
|
# carry tags mean the literal tag. The same class, found while fixing it: a
|
|
# markdown link's href is not HTML-escaped either, and an entity-encoded
|
|
# `javascript:` sails past any scheme test that does not decode it first.
|
|
|
|
from html.parser import HTMLParser
|
|
|
|
from booth.items import render_doc
|
|
|
|
|
|
class _Scan(HTMLParser):
|
|
"""What a BROWSER would see: tags as parsed, attribute values decoded."""
|
|
|
|
def __init__(self):
|
|
super().__init__(convert_charrefs=True)
|
|
self.tags, self.hrefs = [], []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
self.tags.append(tag)
|
|
for k, v in attrs:
|
|
if k == "href":
|
|
self.hrefs.append(v)
|
|
|
|
|
|
def _scan(md_text):
|
|
html, is_html = render_doc(md_text, "markdown")
|
|
assert is_html
|
|
s = _Scan()
|
|
s.feed(html)
|
|
return html, s
|
|
|
|
|
|
def _navigates_to_script(href):
|
|
# the browser drops tab/CR/LF anywhere and C0-or-space at the ends
|
|
bare = "".join(ch for ch in href if ch not in "\t\r\n").strip("".join(map(chr, range(0x21))))
|
|
return bare.lower().startswith(("javascript:", "vbscript:", "data:"))
|
|
|
|
|
|
@pytest.mark.parametrize("src", [
|
|
"<script>alert(1)</script>\n\nafter",
|
|
"inline <img src=x onerror=alert(1)> here",
|
|
"<iframe src=//evil.test></iframe>",
|
|
"| a |\n|---|\n| <svg onload=alert(1)> |",
|
|
])
|
|
def test_raw_html_in_a_doc_is_text_never_markup(src):
|
|
html, s = _scan(src)
|
|
assert not {"script", "img", "iframe", "svg"} & set(s.tags), (s.tags, html)
|
|
assert "<" in html
|
|
|
|
|
|
def test_a_quoted_pre_is_shown_not_opened_and_the_doc_goes_on():
|
|
html, s = _scan("a contract that says <pre> opens one\n\nnext paragraph")
|
|
assert "pre" not in s.tags, html
|
|
assert "<p>next paragraph</p>" in html
|
|
|
|
|
|
def test_fenced_code_is_still_a_code_block():
|
|
"""Positive control: escaping raw HTML must not cost the code block."""
|
|
html, s = _scan("```html\n<script>x</script>\n```")
|
|
assert s.tags == ["pre", "code"], html
|
|
assert "<script>" in html
|
|
|
|
|
|
def test_inline_html_a_doc_meant_is_now_literal_text():
|
|
"""The declared cost of the ruling: <sub> and <details> show as tags."""
|
|
html, s = _scan("H<sub>2</sub>O")
|
|
assert "sub" not in s.tags and "<sub>" in html
|
|
|
|
|
|
@pytest.mark.parametrize("dest", [
|
|
"[x](javascript:alert(1))",
|
|
"[x](JaVaScRiPt:alert(1))",
|
|
"[x](javascript:alert(1))",
|
|
"[x](javascript:alert(1))",
|
|
"[x](javascript:alert`1`)",
|
|
"[x](java	script:alert`1`)",
|
|
"[x](javascript:alert`1`)",
|
|
"[x](<java\tscript:alert(1)>)",
|
|
"[x](\x01javascript:alert`1`)",
|
|
"[r]: javascript:alert`1`\n\n[go][r]",
|
|
"[x](data:text/html,<script>alert(1)</script>)",
|
|
])
|
|
def test_a_link_that_would_run_code_keeps_its_text_and_loses_its_href(dest):
|
|
html, s = _scan(dest)
|
|
assert not any(_navigates_to_script(h) for h in s.hrefs), (s.hrefs, html)
|
|
assert "a" in s.tags, html # the words are still there
|
|
|
|
|
|
def test_ordinary_links_survive():
|
|
html, s = _scan("[a](https://example.com) [b](http://x.test/p) [c](other.md) [d](#frag)")
|
|
assert s.hrefs == ["https://example.com", "http://x.test/p", "other.md", "#frag"], html
|
|
|
|
|
|
# Markdown reads `\\` as an escaped backslash, so FOUR in the source put two
|
|
# in the href: `\\evil.test`, which a browser reads as `//evil.test`.
|
|
@pytest.mark.parametrize("dest", ["[x](//evil.test/p)", "[x](/\\evil.test/p)", "[x](\\\\\\\\evil.test/p)"])
|
|
def test_a_link_that_leaves_the_origin_by_backslash_is_refused(dest):
|
|
"""heid bug-hunt, groa: `is_safe_href` refused `//host` but not its
|
|
backslash twin — a browser reads `\\` as `/` in an http(s) URL, so
|
|
`/\\evil.test` is `//evil.test`."""
|
|
html, s = _scan(dest)
|
|
assert s.hrefs == [], (s.hrefs, html)
|
|
|
|
|
|
def test_a_renderer_failure_costs_the_doc_its_formatting_never_the_page(monkeypatch):
|
|
"""heid bug-hunt, 3 of 4 arms: nothing bounded the render. A doc that
|
|
makes Python-Markdown raise — deep nesting, or an upgrade that renames the
|
|
processors this module deregisters — must fall back to escaped raw text,
|
|
never raise out of the page."""
|
|
import booth.items as items_mod
|
|
|
|
def boom():
|
|
raise RecursionError("too deep")
|
|
monkeypatch.setattr(items_mod, "_markdown_renderer", boom)
|
|
assert items_mod.render_doc("# t\n\n<script>x</script>", "markdown") == \
|
|
("# t\n\n<script>x</script>", False)
|