Skip to content

Commit 2004463

Browse files
[3.10] pythongh-136065: Fix quadratic complexity in os.path.expandvars() (pythonGH-134952)
(cherry picked from commit f029e8d) Co-authored-by: Serhiy Storchaka <[email protected]> Co-authored-by: Łukasz Langa <[email protected]>
1 parent 3eea546 commit 2004463

File tree

5 files changed

+93
-111
lines changed

5 files changed

+93
-111
lines changed

Lib/ntpath.py

Lines changed: 41 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -374,17 +374,23 @@ def expanduser(path):
374374
# XXX With COMMAND.COM you can use any characters in a variable name,
375375
# XXX except '^|<>='.
376376

377+
_varpattern = r"'[^']*'?|%(%|[^%]*%?)|\$(\$|[-\w]+|\{[^}]*\}?)"
378+
_varsub = None
379+
_varsubb = None
380+
377381
def expandvars(path):
378382
"""Expand shell variables of the forms $var, ${var} and %var%.
379383
380384
Unknown variables are left unchanged."""
381385
path = os.fspath(path)
386+
global _varsub, _varsubb
382387
if isinstance(path, bytes):
383388
if b'$' not in path and b'%' not in path:
384389
return path
385-
import string
386-
varchars = bytes(string.ascii_letters + string.digits + '_-', 'ascii')
387-
quote = b'\''
390+
if not _varsubb:
391+
import re
392+
_varsubb = re.compile(_varpattern.encode(), re.ASCII).sub
393+
sub = _varsubb
388394
percent = b'%'
389395
brace = b'{'
390396
rbrace = b'}'
@@ -393,94 +399,44 @@ def expandvars(path):
393399
else:
394400
if '$' not in path and '%' not in path:
395401
return path
396-
import string
397-
varchars = string.ascii_letters + string.digits + '_-'
398-
quote = '\''
402+
if not _varsub:
403+
import re
404+
_varsub = re.compile(_varpattern, re.ASCII).sub
405+
sub = _varsub
399406
percent = '%'
400407
brace = '{'
401408
rbrace = '}'
402409
dollar = '$'
403410
environ = os.environ
404-
res = path[:0]
405-
index = 0
406-
pathlen = len(path)
407-
while index < pathlen:
408-
c = path[index:index+1]
409-
if c == quote: # no expansion within single quotes
410-
path = path[index + 1:]
411-
pathlen = len(path)
412-
try:
413-
index = path.index(c)
414-
res += c + path[:index + 1]
415-
except ValueError:
416-
res += c + path
417-
index = pathlen - 1
418-
elif c == percent: # variable or '%'
419-
if path[index + 1:index + 2] == percent:
420-
res += c
421-
index += 1
422-
else:
423-
path = path[index+1:]
424-
pathlen = len(path)
425-
try:
426-
index = path.index(percent)
427-
except ValueError:
428-
res += percent + path
429-
index = pathlen - 1
430-
else:
431-
var = path[:index]
432-
try:
433-
if environ is None:
434-
value = os.fsencode(os.environ[os.fsdecode(var)])
435-
else:
436-
value = environ[var]
437-
except KeyError:
438-
value = percent + var + percent
439-
res += value
440-
elif c == dollar: # variable or '$$'
441-
if path[index + 1:index + 2] == dollar:
442-
res += c
443-
index += 1
444-
elif path[index + 1:index + 2] == brace:
445-
path = path[index+2:]
446-
pathlen = len(path)
447-
try:
448-
index = path.index(rbrace)
449-
except ValueError:
450-
res += dollar + brace + path
451-
index = pathlen - 1
452-
else:
453-
var = path[:index]
454-
try:
455-
if environ is None:
456-
value = os.fsencode(os.environ[os.fsdecode(var)])
457-
else:
458-
value = environ[var]
459-
except KeyError:
460-
value = dollar + brace + var + rbrace
461-
res += value
462-
else:
463-
var = path[:0]
464-
index += 1
465-
c = path[index:index + 1]
466-
while c and c in varchars:
467-
var += c
468-
index += 1
469-
c = path[index:index + 1]
470-
try:
471-
if environ is None:
472-
value = os.fsencode(os.environ[os.fsdecode(var)])
473-
else:
474-
value = environ[var]
475-
except KeyError:
476-
value = dollar + var
477-
res += value
478-
if c:
479-
index -= 1
411+
412+
def repl(m):
413+
lastindex = m.lastindex
414+
if lastindex is None:
415+
return m[0]
416+
name = m[lastindex]
417+
if lastindex == 1:
418+
if name == percent:
419+
return name
420+
if not name.endswith(percent):
421+
return m[0]
422+
name = name[:-1]
480423
else:
481-
res += c
482-
index += 1
483-
return res
424+
if name == dollar:
425+
return name
426+
if name.startswith(brace):
427+
if not name.endswith(rbrace):
428+
return m[0]
429+
name = name[1:-1]
430+
431+
try:
432+
if environ is None:
433+
return os.fsencode(os.environ[os.fsdecode(name)])
434+
else:
435+
return environ[name]
436+
except KeyError:
437+
return m[0]
438+
439+
return sub(repl, path)
484440

485441

486442
# Normalize a path, e.g. A//B, A/./B and A/foo/../B all become A\B.

Lib/posixpath.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -279,56 +279,53 @@ def expanduser(path):
279279
# This expands the forms $variable and ${variable} only.
280280
# Non-existent variables are left unchanged.
281281

282-
_varprog = None
283-
_varprogb = None
282+
_varpattern = r'\$(\w+|\{[^}]*\}?)'
283+
_varsub = None
284+
_varsubb = None
284285

285286
def expandvars(path):
286287
"""Expand shell variables of form $var and ${var}. Unknown variables
287288
are left unchanged."""
288289
path = os.fspath(path)
289-
global _varprog, _varprogb
290+
global _varsub, _varsubb
290291
if isinstance(path, bytes):
291292
if b'$' not in path:
292293
return path
293-
if not _varprogb:
294+
if not _varsubb:
294295
import re
295-
_varprogb = re.compile(br'\$(\w+|\{[^}]*\})', re.ASCII)
296-
search = _varprogb.search
296+
_varsubb = re.compile(_varpattern.encode(), re.ASCII).sub
297+
sub = _varsubb
297298
start = b'{'
298299
end = b'}'
299300
environ = getattr(os, 'environb', None)
300301
else:
301302
if '$' not in path:
302303
return path
303-
if not _varprog:
304+
if not _varsub:
304305
import re
305-
_varprog = re.compile(r'\$(\w+|\{[^}]*\})', re.ASCII)
306-
search = _varprog.search
306+
_varsub = re.compile(_varpattern, re.ASCII).sub
307+
sub = _varsub
307308
start = '{'
308309
end = '}'
309310
environ = os.environ
310-
i = 0
311-
while True:
312-
m = search(path, i)
313-
if not m:
314-
break
315-
i, j = m.span(0)
316-
name = m.group(1)
317-
if name.startswith(start) and name.endswith(end):
311+
312+
def repl(m):
313+
name = m[1]
314+
if name.startswith(start):
315+
if not name.endswith(end):
316+
return m[0]
318317
name = name[1:-1]
319318
try:
320319
if environ is None:
321320
value = os.fsencode(os.environ[os.fsdecode(name)])
322321
else:
323322
value = environ[name]
324323
except KeyError:
325-
i = j
324+
return m[0]
326325
else:
327-
tail = path[j:]
328-
path = path[:i] + value
329-
i = len(path)
330-
path += tail
331-
return path
326+
return value
327+
328+
return sub(repl, path)
332329

333330

334331
# Normalize a path, e.g. A//B, A/./B and A/foo/../B all become A/B.

Lib/test/test_genericpath.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import sys
88
import unittest
99
import warnings
10+
from test import support
1011
from test.support import os_helper
1112
from test.support import warnings_helper
1213
from test.support.script_helper import assert_python_ok
@@ -430,6 +431,19 @@ def check(value, expected):
430431
os.fsencode('$bar%s bar' % nonascii))
431432
check(b'$spam}bar', os.fsencode('%s}bar' % nonascii))
432433

434+
@support.requires_resource('cpu')
435+
def test_expandvars_large(self):
436+
expandvars = self.pathmodule.expandvars
437+
with os_helper.EnvironmentVarGuard() as env:
438+
env.clear()
439+
env["A"] = "B"
440+
n = 100_000
441+
self.assertEqual(expandvars('$A'*n), 'B'*n)
442+
self.assertEqual(expandvars('${A}'*n), 'B'*n)
443+
self.assertEqual(expandvars('$A!'*n), 'B!'*n)
444+
self.assertEqual(expandvars('${A}A'*n), 'BA'*n)
445+
self.assertEqual(expandvars('${'*10*n), '${'*10*n)
446+
433447
def test_abspath(self):
434448
self.assertIn("foo", self.pathmodule.abspath("foo"))
435449
with warnings.catch_warnings():

Lib/test/test_ntpath.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import unittest
66
import warnings
77
from ntpath import ALLOW_MISSING
8+
from test import support
89
from test.support import os_helper
9-
from test.support import TestFailed
1010
from test.support.os_helper import FakePath
1111
from test import test_genericpath
1212
from tempfile import TemporaryFile
@@ -56,7 +56,7 @@ def tester(fn, wantResult):
5656
fn = fn.replace("\\", "\\\\")
5757
gotResult = eval(fn)
5858
if wantResult != gotResult and _norm(wantResult) != _norm(gotResult):
59-
raise TestFailed("%s should return: %s but returned: %s" \
59+
raise support.TestFailed("%s should return: %s but returned: %s" \
6060
%(str(fn), str(wantResult), str(gotResult)))
6161

6262
# then with bytes
@@ -72,7 +72,7 @@ def tester(fn, wantResult):
7272
warnings.simplefilter("ignore", DeprecationWarning)
7373
gotResult = eval(fn)
7474
if _norm(wantResult) != _norm(gotResult):
75-
raise TestFailed("%s should return: %s but returned: %s" \
75+
raise support.TestFailed("%s should return: %s but returned: %s" \
7676
%(str(fn), str(wantResult), repr(gotResult)))
7777

7878

@@ -689,6 +689,19 @@ def check(value, expected):
689689
check('%spam%bar', '%sbar' % nonascii)
690690
check('%{}%bar'.format(nonascii), 'ham%sbar' % nonascii)
691691

692+
@support.requires_resource('cpu')
693+
def test_expandvars_large(self):
694+
expandvars = ntpath.expandvars
695+
with os_helper.EnvironmentVarGuard() as env:
696+
env.clear()
697+
env["A"] = "B"
698+
n = 100_000
699+
self.assertEqual(expandvars('%A%'*n), 'B'*n)
700+
self.assertEqual(expandvars('%A%A'*n), 'BA'*n)
701+
self.assertEqual(expandvars("''"*n + '%%'), "''"*n + '%')
702+
self.assertEqual(expandvars("%%"*n), "%"*n)
703+
self.assertEqual(expandvars("$$"*n), "$"*n)
704+
692705
def test_expanduser(self):
693706
tester('ntpath.expanduser("test")', 'test')
694707

@@ -923,6 +936,7 @@ def test_nt_helpers(self):
923936
self.assertIsInstance(b_final_path, bytes)
924937
self.assertGreater(len(b_final_path), 0)
925938

939+
926940
class NtCommonTest(test_genericpath.CommonTest, unittest.TestCase):
927941
pathmodule = ntpath
928942
attributes = ['relpath']
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix quadratic complexity in :func:`os.path.expandvars`.

0 commit comments

Comments
 (0)