Skip to content

Commit 0f0a860

Browse files
naaz234waylan
authored andcommitted
Fix quadratic-time backtracking in hash header regex
`HashHeaderProcessor.RE` matches the header text lazily one character at a time and, after each step, tries `#*(?:\n|$)` to see whether only closing hashes remain. When the lazy match is inside a run of `#` that is followed by more text, that check scans to the end of the run and then fails, so a run of n hashes costs O(n^2). A line such as `# ` followed by 8000 `#` and an `x` takes about 13 seconds to convert. A run of hashes ending in a backslash (`'#' * n + '\\'`) hits the same path. Header text now consumes a run of hashes in one step when the run is followed by something other than a newline or the end of the block, and a lone `#` is no longer matched by the single-character branch. The lazy match therefore only stops at the start of a hash run, never inside one, and every match and capture group is unchanged. This was checked against the old pattern on every string of up to 8 characters over `#`, `\`, `a` and newline, plus random longer strings. This differs from #1545, where the time was spent in inline escape handling; here the time is spent inside the block regex itself.
1 parent 5e335ef commit 0f0a860

3 files changed

Lines changed: 15 additions & 1 deletion

File tree

‎docs/changelog.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ See the [Contributing Guide](contributing.md) for details.
2121
* Fix quadratic-time regex backtracking in `attr_list` when a block ends with an
2222
unclosed attribute list followed by a long run of spaces, e.g. a line
2323
consisting only of `{` and some text with many trailing spaces (#1656).
24+
* Fix quadratic-time regex backtracking in hash headers when a long run of `#`
25+
characters is followed by more text on the same line.
2426
* Fix regression in `BacktickInlineProcessor` where text could be lost when opening
2527
and closing backticks did not match (#1668).
2628
* Escape the language name in the `codehilite` extension's non-Pygments output to

‎markdown/blockprocessors.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ class HashHeaderProcessor(BlockProcessor):
458458
""" Process Hash Headers. """
459459

460460
# Detect a header at start of any line in block
461-
RE = re.compile(r'(?:^|\n)(?P<level>#{1,6})(?P<header>(?:\\.|[^\\])*?)#*(?:\n|$)')
461+
RE = re.compile(r'(?:^|\n)(?P<level>#{1,6})(?P<header>(?:\\.|[^\\#]|#+(?=[^#\n]))*?)#*(?:\n|$)')
462462

463463
def test(self, parent: etree.Element, block: str) -> bool:
464464
return bool(self.RE.search(block))

‎tests/test_syntax/blocks/test_headers.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,3 +779,15 @@ def test_unescaped_hash(self):
779779
"""
780780
)
781781
)
782+
783+
def test_long_run_of_hashes_not_at_end(self):
784+
"""
785+
A long run of hashes followed by more text on the same line is part of
786+
the header text and should not be scanned once per hash when looking
787+
for the closing hashes.
788+
"""
789+
hashes = '#' * 20000
790+
self.assertMarkdownRenders(
791+
'# ' + hashes + 'x',
792+
'<h1>' + hashes + 'x</h1>'
793+
)

0 commit comments

Comments
 (0)