Skip to content

Commit 0dbb00e

Browse files
miss-islingtonserhiy-storchakaclaude
authored
[3.14] gh-88574: Do not swallow the line after a terminating literal in imaplib (GH-153317) (GH-153325)
GH-152751 skipped a spurious blank line after a literal unconditionally, corrupting a response that ends with a literal (such as a mailbox name returned by LIST): its empty trailer was mistaken for the blank and the following line was swallowed. The blank is now skipped only inside an unclosed parenthesis. After a literal that ends the response it instead arrives before the next response and is skipped there. (cherry picked from commit 6b81784) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9967465 commit 0dbb00e

3 files changed

Lines changed: 64 additions & 3 deletions

File tree

Lib/imaplib.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@
139139
_quoted = re.compile(br'"(?:[^"\\]|\\.)*+"')
140140

141141

142+
def _paren_depth(data, depth=0):
143+
# Net parenthesis nesting of data, ignoring parentheses in quoted strings.
144+
data = _quoted.sub(b'', data)
145+
return depth + data.count(b'(') - data.count(b')')
146+
147+
142148
class IMAP4:
143149

144150
r"""IMAP4 client class.
@@ -1277,6 +1283,11 @@ def _get_response(self, start_timeout=False):
12771283
else:
12781284
resp = self._get_line()
12791285

1286+
# Skip spurious blank lines between responses (some servers send one
1287+
# after a literal that ends a response).
1288+
while resp == b'':
1289+
resp = self._get_line()
1290+
12801291
# Command completion response?
12811292

12821293
if self._match(self.tagre, resp):
@@ -1314,6 +1325,7 @@ def _get_response(self, start_timeout=False):
13141325

13151326
# Is there a literal to come?
13161327

1328+
depth = 0 # open parenthesis nesting so far
13171329
while self._match(self.Literal, dat):
13181330

13191331
# Read literal direct from connection.
@@ -1327,13 +1339,15 @@ def _get_response(self, start_timeout=False):
13271339
# Store response with literal as tuple
13281340

13291341
self._append_untagged(typ, (dat, data))
1342+
depth = _paren_depth(dat, depth)
13301343

13311344
# Read trailer - possibly containing another literal
13321345

13331346
dat = self._get_line()
13341347

1335-
# Skip a blank line that some servers send after a literal.
1336-
if dat == b'':
1348+
# Skip spurious blank lines after a literal, but only inside an
1349+
# unclosed parenthesis (at top level they end the response).
1350+
while dat == b'' and depth > 0:
13371351
dat = self._get_line()
13381352

13391353
self._append_untagged(typ, dat)

Lib/test/test_imaplib.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,51 @@ def cmd_FETCH(self, tag, args):
994994
self.assertEqual(data, [(b'1 (BODY[HEADER] {13}', b'Subject: test'),
995995
b')'])
996996

997+
def test_literal_terminating_response(self):
998+
# A literal ending a response (a LIST mailbox name sent as a literal)
999+
# has an empty trailer that must not be swallowed. Conforming case:
1000+
# no spurious blank lines.
1001+
names = [b'My (box)"', b'Another', b'Third']
1002+
class Handler(SimpleIMAPHandler):
1003+
def cmd_LIST(self, tag, args):
1004+
for name in names:
1005+
self._send(b'* LIST (\\HasNoChildren) "/" {%d}\r\n'
1006+
% len(name))
1007+
self._send(name)
1008+
self._send(b'\r\n') # ends the response, no blank
1009+
self._send_tagged(tag, 'OK', 'LIST completed')
1010+
client, _ = self._setup(Handler)
1011+
client.login('user', 'pass')
1012+
typ, data = client.list()
1013+
self.assertEqual(typ, 'OK')
1014+
self.assertEqual(data, [
1015+
(b'(\\HasNoChildren) "/" {9}', b'My (box)"'), b'',
1016+
(b'(\\HasNoChildren) "/" {7}', b'Another'), b'',
1017+
(b'(\\HasNoChildren) "/" {5}', b'Third'), b'',
1018+
])
1019+
1020+
def test_spurious_blank_lines_between_responses(self):
1021+
# A spurious blank line after each terminating literal falls between the
1022+
# untagged responses and must be skipped, even several in a row.
1023+
names = [b'My (box)"', b'Another', b'Third']
1024+
class Handler(SimpleIMAPHandler):
1025+
def cmd_LIST(self, tag, args):
1026+
for name in names:
1027+
self._send(b'* LIST (\\HasNoChildren) "/" {%d}\r\n'
1028+
% len(name))
1029+
self._send(name)
1030+
self._send(b'\r\n\r\n') # ends the response, then a blank
1031+
self._send_tagged(tag, 'OK', 'LIST completed')
1032+
client, _ = self._setup(Handler)
1033+
client.login('user', 'pass')
1034+
typ, data = client.list()
1035+
self.assertEqual(typ, 'OK')
1036+
self.assertEqual(data, [
1037+
(b'(\\HasNoChildren) "/" {9}', b'My (box)"'), b'',
1038+
(b'(\\HasNoChildren) "/" {7}', b'Another'), b'',
1039+
(b'(\\HasNoChildren) "/" {5}', b'Third'), b'',
1040+
])
1041+
9971042
def test_unselect(self):
9981043
client, server = self._setup(SimpleIMAPHandler)
9991044
client.login('user', 'pass')
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
:mod:`imaplib` no longer fails when a server sends a spurious blank line
2-
after the counted data of a literal. Such a blank line is now skipped.
2+
after the counted data of a literal, including after a literal that
3+
terminates a response (such as a mailbox name returned by ``LIST``).
4+
Such blank lines are now skipped without swallowing the following line.

0 commit comments

Comments
 (0)