Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ See the [Contributing Guide](contributing.md) for details.

### Fixed

* Fix quadratic rendering time for many unclosed square brackets in links
and images.
* Sanitize attribute names in the `fenced_code` and `legacy_attrs` extensions so an
attribute name cannot break out of its tag and inject markup (#1663).
* For backwards compatibility in some languages, revert the enforcement of CommonMark
Expand Down
45 changes: 32 additions & 13 deletions markdown/inlinepatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -1157,19 +1157,38 @@ def getText(self, data: str, index: int) -> tuple[str, int, bool]:
resolving nested square brackets.

"""
bracket_count = 1
text = []
for pos in range(index, len(data)):
c = data[pos]
if c == ']':
bracket_count -= 1
elif c == '[':
bracket_count += 1
index += 1
if bracket_count == 0:
break
text.append(c)
return ''.join(text), index, bracket_count == 0
# Scanning forward from every `[` is quadratic when the brackets do not
# close, so find the closing bracket for every index of `data` at once.
if getattr(self, '_bracket_data', None) is not data:
self._bracket_data = data
self._bracket_ends = self._findBracketEnds(data)
end = self._bracket_ends[index]
if end is None:
return data[index:], len(data), False
return data[index:end - 1], end, True

@staticmethod
def _findBracketEnds(data: str) -> list[int | None]:
"""
For each index of `data`, return the index just past the `]` which closes an
opening bracket before that index, or `None` if the bracket never closes.

"""
ends: list[int | None] = [None] * (len(data) + 1)
depth = 0
# `stack` holds the indexes still waiting for a closing bracket. The bracket
# depth before each one is strictly increasing from bottom to top.
stack: list[tuple[int, int]] = []
for pos in range(len(data) + 1):
while stack and depth < stack[-1][1]:
ends[stack.pop()[0]] = pos
stack.append((pos, depth))
if pos < len(data):
if data[pos] == '[':
depth += 1
elif data[pos] == ']':
depth -= 1
return ends


class ImageInlineProcessor(LinkInlineProcessor):
Expand Down
13 changes: 13 additions & 0 deletions tests/test_syntax/inline/test_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,19 @@ def test_angles_and_nonsense_url(self):
'<p><a href="?}]*+|&amp;)">test nonsense</a>.</p>'
)

def test_unclosed_square_brackets(self):
self.assertMarkdownRenders(
'[[[Text](http://link.com) [more [text',
'<p>[[<a href="http://link.com">Text</a> [more [text</p>'
)

def test_many_unclosed_square_brackets(self):
# Each unclosed bracket used to rescan the rest of the text.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spell checker seems to not like the word "rescan." It should probably be added to .spell-dict.

self.assertMarkdownRenders(
'[[' * 10000 + '[Text](http://link.com)',
'<p>' + '[[' * 10000 + '<a href="http://link.com">Text</a></p>'
)


class TestReferenceLinks(TestCase):

Expand Down
Loading