Skip to content

Fix catastrophic backtracking in CommonLispLexer/EmacsLispLexer symbols - #3324

Open
kratos0718 wants to merge 1 commit into
pygments:masterfrom
kratos0718:fix-lisp-symbol-redos
Open

kratos0718 wants to merge 1 commit into
pygments:masterfrom
kratos0718:fix-lisp-symbol-redos

Conversation

@kratos0718

Copy link
Copy Markdown

Summary

Reported in a comment on #3226 (the same super-linear-lexing class covered there for SPARQL/Turtle/ShExC, but a separate root cause in lisp.py, not yet its own PR): #3226 (comment)

nonmacro's +-/ is an unescaped character-class range — it matches +,-./ (five characters), not the three literal characters + - / it looks like it's listing. So . already matches through nonmacro. constituent (nonmacro + '|[#.:]') then adds . a second time via its own explicit suffix. That gives two ways to attribute each . in a run of dots to the same repeated group ((?:{constituent})*), and when the enclosing rule fails to close — the *symbol* special-variable rule (line ~463) is the easiest to trigger, since it just needs a * with no matching closing * — the engine retries every such split before giving up. Quadratic in the run's length; a few thousand dots is already unusable, and the reported repro ("*" + "." * 30) took 55s.

Same bug, same fix, in EmacsLispLexer (nonmacro/constituent a bit further down the file).

Fix

Dropped the redundant . from constituent's own [#.:] suffix, rather than touching nonmacro. I initially tried removing . from nonmacro instead (seemed like the more obviously "unintended" side of the duplication) — that broke tests/examplefiles/common-lisp/type.lisp, which has (symbol-name :#.junk): a symbol starting with a literal . needs nonmacro (which matches only the first character of a symbol) to accept .. constituent covers the repeated remainder and already includes nonmacro as one of its alternatives, so dropping the duplicate from its explicit suffix removes the ambiguity without changing what any symbol can start with or contain.

Test Plan

  • Full test suite: pytest tests/ --ignore=tests/contrast (that one dir needs an unrelated optional dependency neither installed nor touched by this PR) — 5332 passed, 16 skipped, no failures, including the type.lisp example-file test that caught my first attempt.
  • ruff check --ignore UP031 . and regexlint --verbose pygments.lexers.lisp (CommonLispLexer OK, EmacsLispLexer OK) both clean.
  • Added tests/test_lisp.py:
    • test_symbol_redos_resistance (parametrized over both lexers): times lexing "*" + "." * 2000 + "\n", asserts under 5s. Fails on the pre-fix code well before the timeout (30 dots alone was already 55s per the issue's own measurement).
    • test_symbol_starting_with_dot_still_lexes: regression guard for the type.lisp case that caught my first (wrong) attempt at this fix.

nonmacro's "+-/" is an unescaped character-class range (it matches
+,-./, not the three literal characters + - / it looks like), so "."
already matched through nonmacro. constituent then added "." again via
its own explicit "[#.:]", giving two ways to attribute each "." in a
run to the same repeated group. A symbol rule that fails to close (the
"*symbol*" special-variable rule is the easiest to trigger) forces the
engine to retry every such split before giving up, which is quadratic
in the run's length - a few thousand dots is already unusable.

Dropped the redundant "." from constituent's own suffix instead of
touching nonmacro, since some real symbols (e.g. one starting with a
literal ".", already covered by tests/examplefiles/common-lisp/type.lisp)
rely on nonmacro accepting it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant