Skip to content

Commit 7868451

Browse files
gh-153044: Bound the follower-scan recursion
The follower scan recurses once per following group, so thousands of sequential groups overflowed the recursion limit (the parser handles them iteratively). Add an explicit depth cap -- relying on RecursionError is fragile near the limit -- and keep catching it as a backstop: every rewrite already applied is sound on its own, so the rest of the pattern just stays unoptimized. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ba9c881 commit 7868451

2 files changed

Lines changed: 25 additions & 12 deletions

File tree

Lib/re/_optimizer.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -457,9 +457,9 @@ def _compile_info(code, pattern, flags):
457457
_ASCII_WORD = frozenset(b'_') | frozenset(
458458
range(0x30, 0x3a)) | frozenset(range(0x41, 0x5b)) | frozenset(range(0x61, 0x7b))
459459
_PROBE_LIMIT = 64 # cap on the size of a finite atom used as a witness set
460-
_FOLLOW_LIMIT = 64 # cap on the size of a follower set (each empty branch
461-
# alternative appends the continuation again, so unchecked
462-
# growth is exponential, e.g. for (|)(|)(|)...)
460+
_FOLLOW_LIMIT = 64 # cap on a follower set: an empty branch alternative
461+
# re-appends the continuation, exponential for (|)(|)...
462+
_DEPTH_LIMIT = 100 # cap on the follower-scan recursion: one level per group
463463

464464
def _tolower(c, flags):
465465
if flags & SRE_FLAG_UNICODE:
@@ -689,9 +689,12 @@ def _leading_atom(data):
689689
lead = a
690690
return lead
691691

692-
def _first_consumers(seq, i, flags, cont):
692+
def _first_consumers(seq, i, flags, cont, depth=0):
693693
# Atoms for every character that could be consumed at position i of seq;
694694
# cont is the same for what follows seq. None if it can't be analyzed.
695+
if depth >= _DEPTH_LIMIT:
696+
return None
697+
depth += 1
695698
acc = []
696699
n = len(seq)
697700
while i < n:
@@ -702,30 +705,30 @@ def _first_consumers(seq, i, flags, cont):
702705
if op is SUBPATTERN:
703706
if av[1] or av[2]:
704707
return None # flag-scoping group: atoms can't carry their flags
705-
after = _first_consumers(seq, i + 1, flags, cont)
708+
after = _first_consumers(seq, i + 1, flags, cont, depth)
706709
if after is None:
707710
return None
708-
inner = _first_consumers(av[3].data, 0, flags, after)
711+
inner = _first_consumers(av[3].data, 0, flags, after, depth)
709712
return None if inner is None else acc + inner
710713
if op is ATOMIC_GROUP:
711-
after = _first_consumers(seq, i + 1, flags, cont)
714+
after = _first_consumers(seq, i + 1, flags, cont, depth)
712715
if after is None:
713716
return None
714-
inner = _first_consumers(av.data, 0, flags, after)
717+
inner = _first_consumers(av.data, 0, flags, after, depth)
715718
return None if inner is None else acc + inner
716719
if op is BRANCH:
717-
after = _first_consumers(seq, i + 1, flags, cont)
720+
after = _first_consumers(seq, i + 1, flags, cont, depth)
718721
if after is None:
719722
return None
720723
for alt in av[1]:
721-
a = _first_consumers(alt.data, 0, flags, after)
724+
a = _first_consumers(alt.data, 0, flags, after, depth)
722725
if a is None or len(acc) + len(a) > _FOLLOW_LIMIT:
723726
return None
724727
acc += a
725728
return acc
726729
if op in _REPEAT_CODES:
727730
mn, mx, p = av
728-
sub = _first_consumers(p.data, 0, flags, None)
731+
sub = _first_consumers(p.data, 0, flags, None, depth)
729732
if sub is None or len(acc) + len(sub) > _FOLLOW_LIMIT:
730733
return None
731734
acc += sub
@@ -851,5 +854,9 @@ def _walk(seq, flags, cont):
851854

852855
def optimize(pattern, flags):
853856
"""Rewrite a parsed pattern in place and return it."""
854-
_walk(pattern.data, flags, [])
857+
try:
858+
_walk(pattern.data, flags, [])
859+
except RecursionError:
860+
# A backstop for _DEPTH_LIMIT; the rewrites already applied are sound.
861+
pass
855862
return pattern

Lib/test/test_re.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3107,6 +3107,12 @@ def is_possessive(self, pattern, flags=0):
31073107
def test_possessified(self, pattern, flags):
31083108
self.assertTrue(self.is_possessive(pattern, flags))
31093109

3110+
def test_many_sequential_groups(self):
3111+
# The follower scan recurses once per following group; a very long
3112+
# sequence of groups must not turn that into a crash, and repeats
3113+
# outside the chain are still optimized.
3114+
self.assertTrue(self.is_possessive('(a)' * 2000 + 'b+c'))
3115+
31103116
def test_follower_set_capped(self):
31113117
# Each empty branch alternative appends the continuation again, so
31123118
# an uncapped follower set grows exponentially (found by OSS-Fuzz).

0 commit comments

Comments
 (0)