Skip to content

QuotedPrintableDecoder: fix an escape split across two writes - #344

Closed
carlok wants to merge 1 commit into
Kludex:mainfrom
carlok:fix-quoted-printable-split-escape
Closed

carlok wants to merge 1 commit into
Kludex:mainfrom
carlok:fix-quoted-printable-split-escape

Conversation

@carlok

@carlok carlok commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What happens

QuotedPrintableDecoder.write() keeps the last two bytes for the next call when they contain =. A chunk that ends with a complete escape followed by a lone = (for example =41=) keeps 1= and decodes =4 as literal text:

from python_multipart.decoders import QuotedPrintableDecoder

class Sink:
    def __init__(self): self.b = b""
    def write(self, d): self.b += d; return len(d)

s = Sink(); d = QuotedPrintableDecoder(s)
d.write(b"=41="); d.write(b"42")
print(s.b)        # b'=41B'; the same bytes in one write give b'AB'

The multipart parser uses this decoder for a part sent with Content-Transfer-Encoding: quoted-printable, and the chunks follow the caller's reads, so the decoded upload depends on where the reads fall.

The fix

Keep the bytes from the last = within the final two, not always the final two: data.rfind(b"=", max(0, len(data) - 2)). A complete escape is then decoded at once and only an unfinished one (=, =4) waits for the next write.

Tests

tests/test_quoted_printable_chunks.py added: every two-way split of four inputs must decode like the whole input. It fails on the current code (4 failures) and passes with the fix. Full suite: 163 passed before, 167 after.

🤖 Generated with Claude Code

View guided diff

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 <noreply@anthropic.com>
@codspeed

codspeed Bot commented Oct 21, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 5 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing carlok:fix-quoted-printable-split-escape (ce796e5) with main (0607c9b)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="python_multipart/decoders.py">

<violation number="1" location="python_multipart/decoders.py:145">
P2: `rfind` can split adjacent `=` bytes and decode them separately, changing accepted malformed input. For `b"=="`, `write()` followed by `finalize()` now emits `b""` instead of `b"="`; preserve the entire trailing run of `=` bytes (or reject it consistently).</violation>
</file>

Reply to a comment to ask cubic a question or push back. It learns from your replies.

View guided diff | Re-trigger cubic

# next decoding step.
if data[-2:].find(b"=") != -1:
enc, rest = data[:-2], data[-2:]
idx = data.rfind(b"=", max(0, len(data) - 2))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: rfind can split adjacent = bytes and decode them separately, changing accepted malformed input. For b"==", write() followed by finalize() now emits b"" instead of b"="; preserve the entire trailing run of = bytes (or reject it consistently).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python_multipart/decoders.py, line 145:

<comment>`rfind` can split adjacent `=` bytes and decode them separately, changing accepted malformed input. For `b"=="`, `write()` followed by `finalize()` now emits `b""` instead of `b"="`; preserve the entire trailing run of `=` bytes (or reject it consistently).</comment>

<file context>
@@ -142,8 +142,9 @@ def write(self, data: bytes) -> int:
         # 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:]
</file context>
Suggested change
idx = data.rfind(b"=", max(0, len(data) - 2))
idx = data.rfind(b"=", max(0, len(data) - 2))
while idx > 0 and data[idx - 1 : idx] == b"=":
idx -= 1

@Kludex

Kludex commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thanks for the report and the fix! Superseded by #355, which uses find instead of rfind so == still decodes to =, and moves the tests into tests/test_multipart.py.

@Kludex Kludex closed this Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants