Skip to content

Commit a45aca2

Browse files
committed
gh-109638: Avoid pathological backtracking in csv.Sniffer
1 parent dd2714d commit a45aca2

3 files changed

Lines changed: 131 additions & 10 deletions

File tree

Lib/csv.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -328,20 +328,57 @@ def _guess_quote_and_delimiter(self, data, delimiters):
328328
delim = ''
329329
skipinitialspace = 0
330330

331-
# if we see an extra quote between delimiters, we've got a
332-
# double quoted format
333-
dq_regexp = re.compile(
334-
r"((%(delim)s)|^)\W*%(quote)s[^%(delim)s\n]*%(quote)s[^%(delim)s\n]*%(quote)s\W*((%(delim)s)|$)" % \
335-
{'delim':re.escape(delim), 'quote':quotechar}, re.MULTILINE)
331+
doublequote = self._detect_doublequote(data, delim, quotechar)
336332

333+
return (quotechar, doublequote, delim, skipinitialspace)
337334

338335

339-
if dq_regexp.search(data):
340-
doublequote = True
341-
else:
342-
doublequote = False
336+
def _detect_doublequote(self, data, delimiter, quotechar):
337+
"""
338+
Return whether a doubled quote occurs inside a quoted field.
343339
344-
return (quotechar, doublequote, delim, skipinitialspace)
340+
The first regexp is a fast, linear pre-filter. Since it is not
341+
anchored, a match can start at a delimiter inside another quoted
342+
field. The second regexp rules out well-formed input without doubled
343+
quotes. Both regexps use possessive repetition to avoid backtracking.
344+
"""
345+
import re
346+
347+
escaped_delimiter = re.escape(delimiter)
348+
escaped_quote = re.escape(quotechar)
349+
values = {'delim': escaped_delimiter, 'quote': escaped_quote}
350+
if delimiter:
351+
candidate = re.compile(
352+
r"(?:%(delim)s|\r|^) *+%(quote)s"
353+
r"[^%(quote)s]*+%(quote)s%(quote)s"
354+
r"(?:%(quote)s%(quote)s|[^%(quote)s]++)*+"
355+
r"%(quote)s(?:%(delim)s|(?=\r)|$)"
356+
% values, re.MULTILINE)
357+
separator = rf"(?:{escaped_delimiter}|\r\n|\r|\n)"
358+
plain = (
359+
rf"(?! *+{escaped_quote})"
360+
rf"[^{escaped_delimiter}\r\n]*+")
361+
else:
362+
# An empty delimiter must not create a zero-width alternative
363+
# which makes re.search() retry the pattern at every position.
364+
candidate = re.compile(
365+
r"(?:[\r\n]|\A) *+%(quote)s"
366+
r"[^%(quote)s]*+%(quote)s%(quote)s"
367+
r"(?:%(quote)s%(quote)s|[^%(quote)s]++)*+"
368+
r"%(quote)s(?=[\r\n]|\Z)"
369+
% values, re.MULTILINE)
370+
separator = r"(?:\r\n|\r|\n)"
371+
plain = rf"(?! *+{escaped_quote})[^\r\n]*+"
372+
373+
if candidate.search(data) is None:
374+
return False
375+
376+
quoted = (
377+
rf" *+{escaped_quote}[^{escaped_quote}]*+{escaped_quote}")
378+
field = rf"(?:{quoted}|{plain})"
379+
without_doublequote = re.compile(
380+
rf"\A{field}(?:{separator}{field})*+\Z")
381+
return without_doublequote.match(data) is None
345382

346383

347384
def _guess_delimiter(self, data, delimiters):

Lib/test/test_csv.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,12 @@ def test_delimiters(self):
15011501
self.assertEqual(dialect.delimiter, ',')
15021502
self.assertEqual(dialect.quotechar, '"')
15031503

1504+
def test_sniff_regex_backtracking(self):
1505+
# gh-109638: this artificial sample used to take minutes.
1506+
sniffer = csv.Sniffer()
1507+
sample = '"",' * 100 + '"' * 100 + '0' + '"' * 100 + '0'
1508+
self.assertEqual(sniffer.sniff(sample).delimiter, ',')
1509+
15041510
def test_doublequote(self):
15051511
sniffer = csv.Sniffer()
15061512
dialect = sniffer.sniff(self.header1)
@@ -1514,6 +1520,81 @@ def test_doublequote(self):
15141520
dialect = sniffer.sniff(self.sample9)
15151521
self.assertTrue(dialect.doublequote)
15161522

1523+
def test_doublequote_without_delimiter(self):
1524+
sniffer = csv.Sniffer()
1525+
for quotechar in ('"', "'"):
1526+
with self.subTest(quotechar=quotechar):
1527+
self.assertEqual(
1528+
sniffer._guess_quote_and_delimiter(
1529+
f'{quotechar}a{quotechar}{quotechar}b{quotechar}',
1530+
None,
1531+
),
1532+
(quotechar, True, '', 0),
1533+
)
1534+
self.assertEqual(
1535+
sniffer._guess_quote_and_delimiter(
1536+
f'{quotechar}ab{quotechar}',
1537+
None,
1538+
),
1539+
(quotechar, False, '', 0),
1540+
)
1541+
self.assertEqual(
1542+
sniffer._guess_quote_and_delimiter(
1543+
f'{quotechar}a{quotechar}\n'
1544+
f'x{quotechar}{quotechar}{quotechar}{quotechar}x',
1545+
None,
1546+
),
1547+
(quotechar, False, '', 0),
1548+
)
1549+
1550+
def test_doublequote_with_carriage_return(self):
1551+
sniffer = csv.Sniffer()
1552+
for record_delimiter in ('\r\n', '\r'):
1553+
for quotechar in ('"', "'"):
1554+
with self.subTest(
1555+
record_delimiter=record_delimiter,
1556+
quotechar=quotechar,
1557+
):
1558+
data = (
1559+
f'x,{quotechar}plain{quotechar}{record_delimiter}'
1560+
f'{quotechar}a{quotechar}{quotechar}b{quotechar},y'
1561+
f'{record_delimiter}'
1562+
)
1563+
self.assertEqual(
1564+
sniffer._guess_quote_and_delimiter(data, None),
1565+
(quotechar, True, ',', 0),
1566+
)
1567+
self.assertIs(sniffer.sniff(data).doublequote, True)
1568+
1569+
def test_doublequote_across_quoted_fields(self):
1570+
sniffer = csv.Sniffer()
1571+
for delimiter in (',', ' ', ''):
1572+
for quotechar in ('"', "'"):
1573+
with self.subTest(
1574+
delimiter=delimiter,
1575+
quotechar=quotechar,
1576+
):
1577+
separator = delimiter or '\n'
1578+
data = separator.join((
1579+
f'{quotechar}{separator}{quotechar}',
1580+
quotechar * 2,
1581+
f'{quotechar}{separator}{quotechar}',
1582+
))
1583+
result = sniffer._guess_quote_and_delimiter(data, None)
1584+
self.assertEqual(result[0], quotechar)
1585+
self.assertIs(result[1], False)
1586+
self.assertEqual(result[2], delimiter)
1587+
if delimiter:
1588+
self.assertIs(
1589+
sniffer.sniff(data).doublequote,
1590+
False,
1591+
)
1592+
self.assertFalse(sniffer._detect_doublequote(
1593+
f'a{quotechar}b{separator}{data}',
1594+
delimiter,
1595+
quotechar,
1596+
))
1597+
15171598
def test_guess_delimiter_crlf_not_chosen(self):
15181599
# Ensure that we pick the real delimiter ("|") over "\r" in a tie.
15191600
sniffer = csv.Sniffer()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Prevent pathological regular expression backtracking in :class:`csv.Sniffer`
2+
when detecting doubled quote characters. Improve detection for quoted fields,
3+
avoid matching across quoted fields, and handle CRLF or CR record delimiters.

0 commit comments

Comments
 (0)