]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
[3.15] gh-88574: Do not swallow the line after a terminating literal in imaplib ...
authorMiss Islington (bot) <31488909+miss-islington@users.noreply.github.com>
Wed, 8 Jul 2026 12:17:10 +0000 (14:17 +0200)
committerGitHub <noreply@github.com>
Wed, 8 Jul 2026 12:17:10 +0000 (12:17 +0000)
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 <storchaka@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Lib/imaplib.py
Lib/test/test_imaplib.py
Misc/NEWS.d/next/Library/2026-07-01-10-00-00.gh-issue-88574.Kz3wQm.rst

index 44acaa16397e2dc6df6db34183ac83d50fc6cecc..773913864153b1f795c2f0392b9fc30788c4086f 100644 (file)
@@ -137,6 +137,12 @@ _non_list_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff\\"]')
 _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.
@@ -1284,6 +1290,11 @@ class IMAP4:
         else:
             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):
@@ -1321,6 +1332,7 @@ class IMAP4:
 
             # Is there a literal to come?
 
+            depth = 0                   # open parenthesis nesting so far
             while self._match(self.Literal, dat):
 
                 # Read literal direct from connection.
@@ -1334,13 +1346,15 @@ class IMAP4:
                 # 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)
index 5786f12f322f74114575293baa2118eebded73bf..8e724795b58e750ae95fbf408f4e02dc987b6819 100644 (file)
@@ -988,6 +988,51 @@ class NewIMAPTestsMixin:
         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')
index 8d95bbbb5a15e469b85d926880d86dacd4594617..371f4d08bccd2e2972d1d7dc7bc2ebca533b50cc 100644 (file)
@@ -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.