From 477136f13e6996f78ab6a878aa24ff29f93c834f Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 8 Jul 2026 14:45:58 +0300 Subject: [PATCH] gh-88574: Do not swallow the line after a terminating literal in imaplib (GH-153317) 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 6b81784f57c6e7ddef61c8db26bb896b3633dcae) Co-authored-by: Serhiy Storchaka Co-authored-by: Claude Opus 4.8 --- Lib/imaplib.py | 18 +++++++- Lib/test/test_imaplib.py | 45 +++++++++++++++++++ ...6-07-01-10-00-00.gh-issue-88574.Kz3wQm.rst | 4 +- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 0223a4ad9e2b027..eb80b03fb740611 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -140,6 +140,12 @@ _quoted = re.compile(br'"(?:[^"\\]|\\.)*+"') +def _paren_depth(data, depth=0): + # Net parenthesis nesting of data, ignoring parentheses in quoted strings. + data = _quoted.sub(b'', data) + return depth + data.count(b'(') - data.count(b')') + + class IMAP4: r"""IMAP4 client class. @@ -1154,6 +1160,11 @@ def _get_response(self): resp = self._get_line() + # Skip spurious blank lines between responses (some servers send one + # after a literal that ends a response). + while resp == b'': + resp = self._get_line() + # Command completion response? if self._match(self.tagre, resp): @@ -1191,6 +1202,7 @@ def _get_response(self): # Is there a literal to come? + depth = 0 # open parenthesis nesting so far while self._match(self.Literal, dat): # Read literal direct from connection. @@ -1204,13 +1216,15 @@ def _get_response(self): # Store response with literal as tuple self._append_untagged(typ, (dat, data)) + depth = _paren_depth(dat, depth) # Read trailer - possibly containing another literal dat = self._get_line() - # Skip a blank line that some servers send after a literal. - if dat == b'': + # Skip spurious blank lines after a literal, but only inside an + # unclosed parenthesis (at top level they end the response). + while dat == b'' and depth > 0: dat = self._get_line() self._append_untagged(typ, dat) diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 49a8c1a1b817c82..93747fe729a135e 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -879,6 +879,51 @@ def cmd_FETCH(self, tag, args): self.assertEqual(data, [(b'1 (BODY[HEADER] {13}', b'Subject: test'), b')']) + def test_literal_terminating_response(self): + # A literal ending a response (a LIST mailbox name sent as a literal) + # has an empty trailer that must not be swallowed. Conforming case: + # no spurious blank lines. + names = [b'My (box)"', b'Another', b'Third'] + class Handler(SimpleIMAPHandler): + def cmd_LIST(self, tag, args): + for name in names: + self._send(b'* LIST (\\HasNoChildren) "/" {%d}\r\n' + % len(name)) + self._send(name) + self._send(b'\r\n') # ends the response, no blank + self._send_tagged(tag, 'OK', 'LIST completed') + client, _ = self._setup(Handler) + client.login('user', 'pass') + typ, data = client.list() + self.assertEqual(typ, 'OK') + self.assertEqual(data, [ + (b'(\\HasNoChildren) "/" {9}', b'My (box)"'), b'', + (b'(\\HasNoChildren) "/" {7}', b'Another'), b'', + (b'(\\HasNoChildren) "/" {5}', b'Third'), b'', + ]) + + def test_spurious_blank_lines_between_responses(self): + # A spurious blank line after each terminating literal falls between the + # untagged responses and must be skipped, even several in a row. + names = [b'My (box)"', b'Another', b'Third'] + class Handler(SimpleIMAPHandler): + def cmd_LIST(self, tag, args): + for name in names: + self._send(b'* LIST (\\HasNoChildren) "/" {%d}\r\n' + % len(name)) + self._send(name) + self._send(b'\r\n\r\n') # ends the response, then a blank + self._send_tagged(tag, 'OK', 'LIST completed') + client, _ = self._setup(Handler) + client.login('user', 'pass') + typ, data = client.list() + self.assertEqual(typ, 'OK') + self.assertEqual(data, [ + (b'(\\HasNoChildren) "/" {9}', b'My (box)"'), b'', + (b'(\\HasNoChildren) "/" {7}', b'Another'), b'', + (b'(\\HasNoChildren) "/" {5}', b'Third'), b'', + ]) + def test_unselect(self): client, server = self._setup(SimpleIMAPHandler) client.login('user', 'pass') diff --git a/Misc/NEWS.d/next/Library/2026-07-01-10-00-00.gh-issue-88574.Kz3wQm.rst b/Misc/NEWS.d/next/Library/2026-07-01-10-00-00.gh-issue-88574.Kz3wQm.rst index 8d95bbbb5a15e46..371f4d08bccd2e2 100644 --- a/Misc/NEWS.d/next/Library/2026-07-01-10-00-00.gh-issue-88574.Kz3wQm.rst +++ b/Misc/NEWS.d/next/Library/2026-07-01-10-00-00.gh-issue-88574.Kz3wQm.rst @@ -1,2 +1,4 @@ :mod:`imaplib` no longer fails when a server sends a spurious blank line -after the counted data of a literal. Such a blank line is now skipped. +after the counted data of a literal, including after a literal that +terminates a response (such as a mailbox name returned by ``LIST``). +Such blank lines are now skipped without swallowing the following line.