From ce796e58a95981bdd44b7c675d0ea5e53f94ebd7 Mon Sep 17 00:00:00 2001 From: Carlo Perassi Date: Wed, 21 Oct 2026 18:17:49 +0200 Subject: [PATCH] QuotedPrintableDecoder: fix an escape split across two writes write() keeps the last two bytes for the next call when they contain '='. A chunk ending with a complete escape followed by a lone '=' (e.g. b"=41=") keeps b"1=" and decodes b"=4" as literal text, so the decoded part depends on where the caller's reads fall: b"=41=" then b"42" gives b"=41B" instead of b"AB". The multipart parser uses this decoder for a quoted-printable part. Keep the bytes from the last '=' within the final two, not always the final two, so a complete escape is decoded at once and only an unfinished one waits for the next write. Co-Authored-By: Claude Opus 4.8 --- python_multipart/decoders.py | 5 +++-- tests/test_quoted_printable_chunks.py | 30 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/test_quoted_printable_chunks.py diff --git a/python_multipart/decoders.py b/python_multipart/decoders.py index c4cdfb66..2e13858d 100644 --- a/python_multipart/decoders.py +++ b/python_multipart/decoders.py @@ -142,8 +142,9 @@ def write(self, data: bytes) -> int: # If the last 2 characters have an '=' sign in it, then we won't be # able to decode the encoded value and we'll need to save it for the # next decoding step. - if data[-2:].find(b"=") != -1: - enc, rest = data[:-2], data[-2:] + idx = data.rfind(b"=", max(0, len(data) - 2)) + if idx != -1: + enc, rest = data[:idx], data[idx:] else: enc = data rest = b"" diff --git a/tests/test_quoted_printable_chunks.py b/tests/test_quoted_printable_chunks.py new file mode 100644 index 00000000..f520506d --- /dev/null +++ b/tests/test_quoted_printable_chunks.py @@ -0,0 +1,30 @@ +import binascii + +import pytest + +from python_multipart.decoders import QuotedPrintableDecoder + + +class Sink: + def __init__(self) -> None: + self.b = b"" + + def write(self, data: bytes) -> int: + self.b += data + return len(data) + + +@pytest.mark.parametrize( + "data", + [b"=41=42", b"a=41=42=43z", b"=41=\n=42", b"x=3D=3Dy"], +) +def test_chunked_matches_whole(data: bytes) -> None: + whole = binascii.a2b_qp(data) + for i in range(len(data) + 1): + for j in range(i, len(data) + 1): + sink = Sink() + decoder = QuotedPrintableDecoder(sink) + for part in (data[:i], data[i:j], data[j:]): + decoder.write(part) + decoder.finalize() + assert sink.b == whole, (i, j)