Skip to content

Commit ab5187c

Browse files
gh-62222: Fix and extend quoted-printable decoding (GH-152679)
binascii.a2b_qp() and quopri now leave an invalid "=" escape in place and rescan the following character instead of consuming it, like email.quoprimime and other decoders (b'==41' now decodes to b'=A'). Add an opt-in strip_ws parameter to binascii.a2b_qp(), quopri.decode(), quopri.decodestring() and email.quoprimime.decode() to strip trailing whitespace per RFC 2045. It is false by default everywhere except email.quoprimime.decode(), preserving every existing default. Also document that email's get_payload(decode=True) does not strip trailing whitespace, contrary to RFC 2045. Register the new "strip_ws" argument name in the global objects tables (make regen-global-objects), needed by static builds where binascii is compiled into the core, and replace an unresolved :func: reference to the undocumented email.quoprimime.decode with a literal. * gh-62222: Make strip_ws parameters keyword-only --------- Co-authored-by: Gregory P. Smith <greg@krypto.org>
1 parent c72b435 commit ab5187c

16 files changed

Lines changed: 213 additions & 39 deletions

‎Doc/library/binascii.rst‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,16 @@ The :mod:`!binascii` module defines the following functions:
283283

284284
.. versionadded:: 3.15
285285

286-
.. function:: a2b_qp(data, header=False)
286+
.. function:: a2b_qp(data, header=False, *, strip_ws=False)
287287

288288
Convert a block of quoted-printable data back to binary and return the binary
289289
data. More than one line may be passed at a time. If the optional argument
290290
*header* is present and true, underscores will be decoded as spaces.
291+
If the optional argument *strip_ws* is true,
292+
trailing whitespace is stripped from each line, as required by :rfc:`2045`.
293+
294+
.. versionchanged:: next
295+
Added the *strip_ws* parameter.
291296

292297

293298
.. function:: b2a_qp(data, quotetabs=False, istext=True, header=False)

‎Doc/library/email.compat32-message.rst‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,14 @@ Here are the methods of the :class:`Message` class:
214214
defect property (:class:`~email.errors.InvalidBase64PaddingDefect` or
215215
:class:`~email.errors.InvalidBase64CharactersDefect`, respectively).
216216

217+
.. note::
218+
219+
A ``quoted-printable`` payload is decoded without stripping
220+
trailing whitespace, contrary to :rfc:`2045` but matching
221+
common mail clients.
222+
Use :func:`binascii.a2b_qp` with ``strip_ws=True``
223+
(or ``email.quoprimime.decode``) for RFC-compliant decoding.
224+
217225
When *decode* is ``False`` (the default) the body is returned as a string
218226
without decoding the :mailheader:`Content-Transfer-Encoding`. However,
219227
for a :mailheader:`Content-Transfer-Encoding` of 8bit, an attempt is made

‎Doc/library/quopri.rst‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@ few nonprintable characters; the base64 encoding scheme available via the
2020
:mod:`base64` module is more compact if there are many such characters, as when
2121
sending a graphics file.
2222

23-
.. function:: decode(input, output, header=False)
23+
.. function:: decode(input, output, header=False, *, strip_ws=False)
2424

2525
Decode the contents of the *input* file and write the resulting decoded binary
2626
data to the *output* file. *input* and *output* must be :term:`binary file objects
2727
<file object>`. If the optional argument *header* is present and true, underscore
2828
will be decoded as space. This is used to decode "Q"-encoded headers as
2929
described in :rfc:`1522`: "MIME (Multipurpose Internet Mail Extensions)
3030
Part Two: Message Header Extensions for Non-ASCII Text".
31+
If the optional argument *strip_ws* is true,
32+
trailing whitespace is stripped from each line, as required by :rfc:`2045`.
33+
34+
.. versionchanged:: next
35+
Added the *strip_ws* parameter.
3136

3237

3338
.. function:: encode(input, output, quotetabs, header=False)
@@ -43,11 +48,14 @@ sending a graphics file.
4348
as underscores as per :rfc:`1522`.
4449

4550

46-
.. function:: decodestring(s, header=False)
51+
.. function:: decodestring(s, header=False, *, strip_ws=False)
4752

4853
Like :func:`decode`, except that it accepts a source :class:`bytes` and
4954
returns the corresponding decoded :class:`bytes`.
5055

56+
.. versionchanged:: next
57+
Added the *strip_ws* parameter.
58+
5159

5260
.. function:: encodestring(s, quotetabs=False, header=False)
5361

‎Doc/whatsnew/3.16.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,16 @@ pickle
614614
:class:`!WeakMethod` and :class:`!WeakSet` can now also be copied.
615615
(Contributed by Serhiy Storchaka in :gh:`74876`.)
616616

617+
quopri
618+
------
619+
620+
* :func:`quopri.decode`, :func:`quopri.decodestring` and
621+
:func:`binascii.a2b_qp` gained a *strip_ws* parameter. When true, trailing
622+
whitespace is stripped from each line while decoding, as required by
623+
:rfc:`2045` for a quoted-printable body.
624+
(Contributed by Serhiy Storchaka in :gh:`62222`.)
625+
626+
617627
re
618628
--
619629

‎Include/internal/pycore_global_objects_fini_generated.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Include/internal/pycore_global_strings.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,7 @@ struct _Py_global_strings {
853853
STRUCT_FOR_ID(strict)
854854
STRUCT_FOR_ID(strict_mode)
855855
STRUCT_FOR_ID(string)
856+
STRUCT_FOR_ID(strip_ws)
856857
STRUCT_FOR_ID(sub_key)
857858
STRUCT_FOR_ID(subcalls)
858859
STRUCT_FOR_ID(symmetric_difference_update)

‎Include/internal/pycore_runtime_init_generated.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Include/internal/pycore_unicodeobject_generated.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Lib/email/quoprimime.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,14 @@ def body_encode(body, maxlinelen=76, eol=NL):
229229

230230
# BAW: I'm not sure if the intent was for the signature of this function to be
231231
# the same as base64MIME.decode() or not...
232-
def decode(encoded, eol=NL):
232+
def decode(encoded, eol=NL, *, strip_ws=True):
233233
"""Decode a quoted-printable string.
234234
235235
Lines are separated with eol, which defaults to \\n.
236+
237+
If strip_ws is true (the default), whitespace at the end of a line is
238+
stripped, as required by RFC 2045 when decoding a quoted-printable body.
239+
Pass strip_ws=False to keep it.
236240
"""
237241
if not encoded:
238242
return encoded
@@ -242,7 +246,8 @@ def decode(encoded, eol=NL):
242246
decoded = ''
243247

244248
for line in encoded.splitlines():
245-
line = line.rstrip()
249+
if strip_ws:
250+
line = line.rstrip()
246251
if not line:
247252
decoded += eol
248253
continue

‎Lib/quopri.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,16 @@ def encodestring(s, quotetabs=False, header=False):
109109

110110

111111

112-
def decode(input, output, header=False):
112+
def decode(input, output, header=False, *, strip_ws=False):
113113
"""Read 'input', apply quoted-printable decoding, and write to 'output'.
114114
'input' and 'output' are binary file objects.
115-
If 'header' is true, decode underscore as space (per RFC 1522)."""
115+
If 'header' is true, decode underscore as space (per RFC 1522).
116+
If 'strip_ws' is true, strip whitespace at the end of a line (per
117+
RFC 2045)."""
116118

117119
if a2b_qp is not None:
118120
data = input.read()
119-
odata = a2b_qp(data, header=header)
121+
odata = a2b_qp(data, header=header, strip_ws=strip_ws)
120122
output.write(odata)
121123
return
122124

@@ -125,11 +127,19 @@ def decode(input, output, header=False):
125127
i, n = 0, len(line)
126128
if n > 0 and line[n-1:n] == b'\n':
127129
partial = 0; n = n-1
128-
# Strip trailing whitespace
129-
while n > 0 and line[n-1:n] in b" \t\r":
130-
n = n-1
130+
# Separate off the line ending (keeping it to re-add after
131+
# decoding) so that a trailing "=" -- possibly before the "\r" of
132+
# a "\r\n" pair -- is recognized as a soft line break.
133+
if n > 0 and line[n-1:n] == b'\r':
134+
n = n-1; eol = b'\r\n'
135+
else:
136+
eol = b'\n'
131137
else:
132-
partial = 1
138+
partial = 1; eol = b''
139+
if strip_ws:
140+
# Strip trailing whitespace (RFC 2045).
141+
while n > 0 and line[n-1:n] in b" \t":
142+
n = n-1
133143
while i < n:
134144
c = line[i:i+1]
135145
if c == b'_' and header:
@@ -138,25 +148,23 @@ def decode(input, output, header=False):
138148
new = new + c; i = i+1
139149
elif i+1 == n and not partial:
140150
partial = 1; break
141-
elif i+1 < n and line[i+1:i+2] == ESCAPE:
142-
new = new + ESCAPE; i = i+2
143151
elif i+2 < n and ishex(line[i+1:i+2]) and ishex(line[i+2:i+3]):
144152
new = new + bytes((unhex(line[i+1:i+3]),)); i = i+3
145153
else: # Bad escape sequence -- leave it in
146154
new = new + c; i = i+1
147155
if not partial:
148-
output.write(new + b'\n')
156+
output.write(new + eol)
149157
new = b''
150158
if new:
151159
output.write(new)
152160

153-
def decodestring(s, header=False):
161+
def decodestring(s, header=False, *, strip_ws=False):
154162
if a2b_qp is not None:
155-
return a2b_qp(s, header=header)
163+
return a2b_qp(s, header=header, strip_ws=strip_ws)
156164
from io import BytesIO
157165
infp = BytesIO(s)
158166
outfp = BytesIO()
159-
decode(infp, outfp, header=header)
167+
decode(infp, outfp, header=header, strip_ws=strip_ws)
160168
return outfp.getvalue()
161169

162170

0 commit comments

Comments
 (0)