From 17c8efd1e7e9ab61d9af71a486c679d3e4ffbbde Mon Sep 17 00:00:00 2001 From: digit50 <93092234+digit50@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:23:50 +0300 Subject: [PATCH] gh-159717: Use atomic groups for stars in glob.translate glob.translate() dropped the star indices from fnmatch._translate(), so every star in a segment became a plain backtracking group and matching took exponential time in pathlib's match, full_match and glob. Each interior star and the fixed part after it are now wrapped in an atomic group, as fnmatch already does. Pairs whose fixed part can match a separator are left unchanged, so the set of matching names stays the same. --- Lib/glob.py | 24 ++++++++++++++++++- Lib/test/test_glob.py | 21 +++++++++++++--- Lib/test/test_pathlib/test_pathlib.py | 18 ++++++++++++++ ...24-07-23-35.gh-issue-159717.oiDoCWId8I.rst | 5 ++++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-24-07-23-35.gh-issue-159717.oiDoCWId8I.rst diff --git a/Lib/glob.py b/Lib/glob.py index 07b9d3eb952ed6..2e728a7d4b95cf 100644 --- a/Lib/glob.py +++ b/Lib/glob.py @@ -324,13 +324,35 @@ def translate(pat, *, recursive=False, include_hidden=False, seps=None): if part: if not include_hidden and part[0] in '*?': results.append(r'(?!\.)') - results.extend(fnmatch._translate(part, f'{not_sep}*', not_sep)[0]) + results.extend(_translate_segment(part, not_sep, seps)) if idx < last_part_idx: results.append(any_sep) res = ''.join(results) return fr'(?s:{res})\z' +def _translate_segment(part, not_sep, seps): + # Wrap each interior `STAR fixed` pair in an atomic group to prevent + # catastrophic backtracking, as fnmatch._join_translated_parts() does. + # A pair whose fixed part can match a separator (a character class) is + # left as it is, since the atomic group could change what it matches. + star = f'{not_sep}*' + parts, star_indices = fnmatch._translate(part, star, not_sep) + if not star_indices: + return parts + res = parts[:star_indices[0]] + for i, j in zip(star_indices, star_indices[1:]): + fixed = parts[i + 1:j] + if any(re.match(p, sep) for p in fixed for sep in seps): + res.extend(parts[i:j]) + else: + res.append(f'(?>{star}?') + res.extend(fixed) + res.append(')') + res.extend(parts[star_indices[-1]:]) + return res + + @functools.lru_cache(maxsize=512) def _compile_pattern(pat, seps, case_sensitive, recursive=True): """Compile given glob pattern to a re.Pattern object (observing case diff --git a/Lib/test/test_glob.py b/Lib/test/test_glob.py index 9e4e82b2542c15..84465b663ea84e 100644 --- a/Lib/test/test_glob.py +++ b/Lib/test/test_glob.py @@ -425,6 +425,21 @@ def test_translate_matching(self): self.assertIsNotNone(match(os.path.join('foo', 'bar.txt'))) self.assertIsNone(match(os.path.join('foo', '.bar.txt'))) + def test_translate_slow(self): + match = re.compile(glob.translate('*a' * 8 + '*b', seps='/')).match + self.assertIsNotNone(match('a' * 100 + 'b')) + # The next "takes forever" if the regexp translation is + # straightforward. See bpo-40480. + self.assertIsNone(match('a' * 100)) + + def test_translate_negated_class_matches_sep(self): + # A character class can match a separator, so each star is not + # limited to a single segment there. + match = re.compile(glob.translate('*[!x]*x', seps='/')).match + self.assertIsNotNone(match('a/x')) + self.assertIsNotNone(match('ab/x')) + self.assertIsNone(match('a/b/x')) + def test_translate(self): def fn(pat): return glob.translate(pat, seps='/') @@ -443,7 +458,7 @@ def fn(pat): self.assertEqual(fn('a**'), r'(?s:a[^/]*)\z') self.assertEqual(fn('**b'), r'(?s:(?!\.)[^/]*b)\z') self.assertEqual(fn('/**/*/*.*/**'), - r'(?s:/(?!\.)[^/]*/[^/.][^/]*/(?!\.)[^/]*\.[^/]*/(?!\.)[^/]*)\z') + r'(?s:/(?!\.)[^/]*/[^/.][^/]*/(?!\.)(?>[^/]*?\.)[^/]*/(?!\.)[^/]*)\z') def test_translate_include_hidden(self): def fn(pat): @@ -462,7 +477,7 @@ def fn(pat): self.assertEqual(fn('***'), r'(?s:[^/]*)\z') self.assertEqual(fn('a**'), r'(?s:a[^/]*)\z') self.assertEqual(fn('**b'), r'(?s:[^/]*b)\z') - self.assertEqual(fn('/**/*/*.*/**'), r'(?s:/[^/]*/[^/]+/[^/]*\.[^/]*/[^/]*)\z') + self.assertEqual(fn('/**/*/*.*/**'), r'(?s:/[^/]*/[^/]+/(?>[^/]*?\.)[^/]*/[^/]*)\z') def test_translate_recursive(self): def fn(pat): @@ -474,7 +489,7 @@ def fn(pat): self.assertEqual(fn('***'), r'(?s:[^/]*)\z') self.assertEqual(fn('a**'), r'(?s:a[^/]*)\z') self.assertEqual(fn('**b'), r'(?s:[^/]*b)\z') - self.assertEqual(fn('/**/*/*.*/**'), r'(?s:/(?:.+/)?[^/]+/[^/]*\.[^/]*/.*)\z') + self.assertEqual(fn('/**/*/*.*/**'), r'(?s:/(?:.+/)?[^/]+/(?>[^/]*?\.)[^/]*/.*)\z') def test_translate_seps(self): def fn(pat): diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index aff66c8efedbbc..ab479186a9f488 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -549,6 +549,16 @@ def test_full_match_case_sensitive(self): self.assertFalse(P('c:/a/B.Py').full_match('C:/A/*.pY', case_sensitive=True)) self.assertTrue(P('/a/b/c.py').full_match('/A/*/*.Py', case_sensitive=False)) + def test_full_match_slow(self): + P = self.cls + pat = '*a' * 8 + '*b' + self.assertTrue(P('a' * 100 + 'b').full_match(pat)) + self.assertTrue(P('a' * 100 + 'b').match(pat)) + # The next "takes forever" if the regexp translation is + # straightforward. See bpo-40480. + self.assertFalse(P('a' * 100).full_match(pat)) + self.assertFalse(P('a' * 100).match(pat)) + def test_match_empty(self): P = self.cls self.assertRaises(ValueError, P('a').match, '') @@ -2973,6 +2983,14 @@ def test_glob_windows(self): self.assertEqual(set(p.glob("*a\\")), { P(self.base, "dirA/") }) self.assertEqual(set(p.glob("F*a")), { P(self.base, "fileA") }) + def test_glob_slow(self): + P = self.cls + p = P(self.base) + q = p / ('a' * 100) + q.touch() + self.assertEqual(list(p.glob('*a' * 8 + '*b')), []) + self.assertEqual(list(p.glob('*a' * 8)), [q]) + def test_glob_empty_pattern(self): p = self.cls('') with self.assertRaisesRegex(ValueError, 'Unacceptable pattern'): diff --git a/Misc/NEWS.d/next/Library/2026-10-24-07-23-35.gh-issue-159717.oiDoCWId8I.rst b/Misc/NEWS.d/next/Library/2026-10-24-07-23-35.gh-issue-159717.oiDoCWId8I.rst new file mode 100644 index 00000000000000..e730bf0ae2cc73 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-24-07-23-35.gh-issue-159717.oiDoCWId8I.rst @@ -0,0 +1,5 @@ +Fix exponential matching time in :func:`glob.translate`, +:meth:`pathlib.PurePath.match`, :meth:`~pathlib.PurePath.full_match` and +:meth:`pathlib.Path.glob` for patterns with several ``*`` wildcards in one +segment. Interior ``*`` wildcards are now translated to atomic groups where +this cannot change which names match, as :func:`fnmatch.translate` does.