From 2f108c57ce936bd527060c31cff90a7db3971dad Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 9 Sep 2026 14:23:04 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Reject=20untermin?= =?UTF-8?q?ated=20inline=20HTML=20in=20constant=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `html=True` (the `commonmark` and `gfm-like` presets), a run of unterminated inline HTML openers in inline context was O(n^2): every alternative of `HTML_TAG_RE` ends in a specific terminator, and the lazy sub-patterns for comments (`-->`), processing instructions (`?>`), CDATA (`]]>`) and declarations (`>`) rescan to the end of the input on each failed attempt. `"x" + " bool: return False # Check start + src = state.src maximum = state.posMax - if state.src[pos] != "<" or pos + 2 >= maximum: + if src[pos] != "<" or pos + 2 >= maximum: return False # Quick fail on second char - ch = state.src[pos + 1] + ch = src[pos + 1] if ch not in ("!", "?", "/") and not isLetter(ord(ch)): # /* / */ return False - match = HTML_TAG_RE.match(state.src, pos) + # Every alternative of the tag regex ends with a specific terminator, at a + # known minimum offset from `pos`. If that terminator does not occur + # anywhere at or after this offset, no match is possible; bail out before + # running the regex, whose lazy sub-patterns would scan to the end of the + # input. Without this, a run of unterminated openers is quadratic. + if src.startswith("` / ``, else `` (`` is the shortest) + if state.html_terminator_last(">") < pos + 4: + return False + if ( + not src.startswith("", pos) + and not src.startswith("", pos) + and state.html_terminator_last("-->") < pos + 4 + ): + return False + elif src.startswith("") < pos + 9: # `` + return False + elif ch == "?": + if state.html_terminator_last("?>") < pos + 2: # `` + return False + elif ch == "!": + if state.html_terminator_last(">") < pos + 3: # `` + return False + elif state.html_terminator_last(">") < pos + 2: # ``, `` + return False + + match = HTML_TAG_RE.match(src, pos) if not match: return False if not silent: token = state.push("html_inline", "", 0) - token.content = state.src[pos : pos + len(match.group(0))] + token.content = src[pos : pos + len(match.group(0))] if isLinkOpen(token.content): state.linkLevel += 1 diff --git a/markdown_it/rules_inline/state_inline.py b/markdown_it/rules_inline/state_inline.py index 8f0ca1e8..28e2f3a3 100644 --- a/markdown_it/rules_inline/state_inline.py +++ b/markdown_it/rules_inline/state_inline.py @@ -84,6 +84,27 @@ def __init__( # inside and markdown links self.linkLevel = 0 + # Lazy cache of `terminator -> last index in src`, see + # `html_terminator_last`. + self._html_terminators: dict[str, int] | None = None + + def html_terminator_last(self, term: str) -> int: + """Index of the last occurrence of `term` in `self.src`, or -1. + + The result is cached per terminator, for the life of the state. + It is used by the `html_inline` rule to reject, in constant time, a + position at which the terminator required to close an HTML construct + cannot possibly occur -- without running the tag regex, whose lazy + sub-patterns would otherwise rescan to the end of the input on every + such (failing) attempt. + """ + if self._html_terminators is None: + self._html_terminators = {} + elif (last := self._html_terminators.get(term)) is not None: + return last + last = self._html_terminators[term] = self.src.rfind(term) + return last + def __repr__(self) -> str: return ( f"{self.__class__.__name__}" diff --git a/tests/test_html_inline.py b/tests/test_html_inline.py new file mode 100644 index 00000000..afc1df8a --- /dev/null +++ b/tests/test_html_inline.py @@ -0,0 +1,35 @@ +"""Tests for the `html_inline` rule.""" + +import pytest + +from markdown_it import MarkdownIt + + +@pytest.mark.parametrize( + "src", + [ + "", # open tag + "", # self-closing open tag + "", # close tag + "", # short comment + "", # short comment + "", # shortest full comment + "", # empty processing instruction + "", # declaration + "", # empty CDATA section + ], +) +def test_shortest_constructs(src: str) -> None: + """The shortest legal form of each construct is passed through as raw html.""" + assert MarkdownIt("commonmark").renderInline(src) == src + + +@pytest.mark.parametrize("opener", [" None: + """A long run of openers that are never terminated must not be quadratic. + + Guarded by the global pytest timeout: before the terminator quick-reject + these took minutes rather than milliseconds. + """ + src = "x" + opener * 10_000 # leading "x" keeps it out of `html_block` + MarkdownIt("commonmark").render(src) From 4104228ea93e521db1bbb9d5b09ce9d1cffdbdab Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 9 Sep 2026 14:24:47 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Drop=20annotations?= =?UTF-8?q?=20on=20parametrized=20tests=20(mypy=20untyped-decorator)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_html_inline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_html_inline.py b/tests/test_html_inline.py index afc1df8a..cee965a4 100644 --- a/tests/test_html_inline.py +++ b/tests/test_html_inline.py @@ -19,13 +19,13 @@ "", # empty CDATA section ], ) -def test_shortest_constructs(src: str) -> None: +def test_shortest_constructs(src): """The shortest legal form of each construct is passed through as raw html.""" assert MarkdownIt("commonmark").renderInline(src) == src @pytest.mark.parametrize("opener", [" None: +def test_unterminated_openers_are_linear(opener): """A long run of openers that are never terminated must not be quadratic. Guarded by the global pytest timeout: before the terminator quick-reject From 0e1f933e350f017b24dc2dcf0195814af6623460 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 9 Sep 2026 14:26:51 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Assert=20the=20tag?= =?UTF-8?q?=20regex=20is=20skipped,=20instead=20of=20timing=20the=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wall-clock guard timed out on PyPy under coverage (no C tracer). The quick-reject means HTML_TAG_RE is never invoked for an unterminated run, which can be asserted directly and deterministically; a companion test checks terminated constructs still reach the regex. --- tests/test_html_inline.py | 51 ++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/tests/test_html_inline.py b/tests/test_html_inline.py index cee965a4..4f118797 100644 --- a/tests/test_html_inline.py +++ b/tests/test_html_inline.py @@ -1,5 +1,7 @@ """Tests for the `html_inline` rule.""" +import importlib + import pytest from markdown_it import MarkdownIt @@ -25,11 +27,48 @@ def test_shortest_constructs(src): @pytest.mark.parametrize("opener", [" 1 else None) + return real.match(*args, **kwargs) + + monkeypatch.setattr(module, "HTML_TAG_RE", Counting()) + src = "x" + opener * 1_000 # leading "x" keeps it out of `html_block` + html = MarkdownIt("commonmark").render(src) + assert calls == [] + # the openers all render as escaped text + assert html == "

x" + (opener.replace("<", "<")) * 1_000 + "

\n" + + +def test_terminated_constructs_still_reach_the_regex(monkeypatch): + """The quick-reject must be exactly that: a terminated construct is still + handed to the regex and parsed as before.""" + module = importlib.import_module("markdown_it.rules_inline.html_inline") + + real = module.HTML_TAG_RE + calls = [] + + class Counting: + def match(self, *args, **kwargs): + calls.append(1) + return real.match(*args, **kwargs) + + monkeypatch.setattr(module, "HTML_TAG_RE", Counting()) + src = "x b" + html = MarkdownIt("commonmark").render(src) + assert calls, "expected the regex to run for terminated constructs" + assert html == "

" + src + "

\n"