Skip to content

gh-107862: Fix infinite loop in email header folding found by a Hypothesis round-trip test - #159771

Open
pganssle wants to merge 2 commits into
python:mainfrom
pganssle:email-fold-hypothesis
Open

pganssle wants to merge 2 commits into
python:mainfrom
pganssle:email-fold-hypothesis

Conversation

@pganssle

@pganssle pganssle commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Stacked on #159770; only the top two commits are new here.

When an encoded word does not fit in the remaining space of a line, _fold_as_ew shrinks the chunk being encoded one character at a time until it fits. Nothing stops it from shrinking the chunk to nothing, and an empty chunk makes no progress: the loop appends an empty encoded word and a new continuation line forever.

>>> import email.policy
>>> email.policy.default.clone(max_line_length=20).fold('Subject', '😀')
# never returns

This is reachable whenever a single character's encoded form is wider than a fresh continuation line, e.g. any character outside the BMP at max_line_length=20 (the q-encoded word is 24 characters). The two earlier fixes in this area (bpo-33529, bpo-36564) handled lines too short for the RFC 2047 chrome, but not lines too short for the chrome plus one character.

The fix keeps at least one character in the chunk, so the loop always advances and the line runs over max_line_length instead. That matches what _refold_parse_tree already does when it cannot fold a part at all, and avoids raising for widths that work for every other input.

Tests, next to the two earlier infinite-loop regressions in test_policy.py:

  • An exact-output regression case, as the earlier two have.
  • A Hypothesis property: folding any generated unstructured value at any width in 1..120 (plus 0 and None) either raises HeaderParseError (only possible at widths of 13 or fewer, where no encoded word fits) or reparses to the same value modulo leading whitespace, which the parser drops. 100 examples take about 0.2 s; the explicit examples replay under the stubs.
  • The round-trip strategy cannot produce text that is itself a well-formed encoded word, because such text is written out unencoded and so decodes to something else on reparse. That is a separate question, filed as email: a header value that is itself a well-formed RFC 2047 encoded word is decoded or written verbatim, never encoded #160148.

@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 19, 2026
Comment thread Lib/test/support/_hypothesis_stubs/stateful.py
Comment thread Lib/test/support/_hypothesis_stubs/stateful.py Outdated
Comment thread Lib/test/support/hypothesis_helper.py Outdated
Comment thread Lib/test/test_hypothesis_stubs.py Outdated
@pganssle
pganssle force-pushed the email-fold-hypothesis branch from ad9482f to aeb677f Compare October 19, 2026 21:30
…he line

When an encoded word does not fit in the remaining space of a line,
_fold_as_ew shrinks the chunk being encoded one character at a time
until it fits. Nothing stops it from shrinking the chunk to nothing,
and an empty chunk makes no progress: the loop appends an empty
encoded word and a new continuation line forever.

This is reachable whenever a single character's encoded form is wider
than a fresh continuation line, e.g. any character outside the BMP
with max_line_length=20 (the q-encoded word is 24 characters). The two
earlier fixes in this area (bpo-33529, bpo-36564) handled lines too
short for the RFC 2047 chrome, but not lines too short for the chrome
plus one character.

This keeps at least one character in the chunk, so the loop always
advances and the line runs over max_line_length instead. That matches
what _refold_parse_tree already does when it cannot fold a part at
all, and avoids raising for widths that work for every other input.

The regression test pins the exact output, as the earlier two did. The
Hypothesis property checks that folding any unstructured value at any
width either raises HeaderParseError (only possible at widths of 13 or
fewer, where no encoded word fits) or reparses to the same value modulo
leading whitespace, which the parser drops.
@pganssle
pganssle force-pushed the email-fold-hypothesis branch from 7f30bad to 61e55a2 Compare October 20, 2026 18:01
@pganssle pganssle added type-bug An unexpected behavior, bug, or error and removed type-feature A feature request or enhancement labels Oct 20, 2026
@pganssle
pganssle marked this pull request as ready for review October 20, 2026 18:02
@pganssle
pganssle requested a review from a team as a code owner October 20, 2026 18:02
@bitdancer

Copy link
Copy Markdown
Member

Thanks for finding this. I actually thought I had observed that case during those previous changes, but I must be mis-remembering. Either that, or there's an open issue I can't find ;)

I'll have to look in to the hypothesis stuff...looking at it without understanding hypothesis it is completely opaque to me what exactly it is testing.

As for the encoded word bug, please separate that out into a new issue. If/how to fix it should be a separate discussion. I prefer not to add "expected failure" tests.

…ok-alikes

The round trip property cannot cover text that is itself a well-formed
encoded word, because such text is written out unencoded and decodes to
something else on reparse. That is now tracked separately as pythongh-160148,
so point at it from the strategy rather than carrying an expected
failure here.
@pganssle
pganssle force-pushed the email-fold-hypothesis branch from 61e55a2 to 1cb3b3b Compare October 21, 2026 23:27
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 21, 2026
@pganssle

Copy link
Copy Markdown
Member Author

Done: the expected failure is removed from this PR and filed separately as #160148, with the test included in the issue text for reference. The branch now has only the infinite loop fix, its regression test, and the round-trip property (plus a comment pointing at #160148 for why the strategy avoids encoded-word look-alikes).

On the Hypothesis test: it generates subjects from a small set of pieces (words, whitespace runs, RFC 5322 specials, 2/3/4-byte characters, =? and ?=) at every width from 1 to 120, folds each one, reparses, and checks it comes back the same (modulo leading whitespace). The @example lines are the fixed cases that always run, including the ones that looped before this fix and the two earlier bpo regressions; without Hypothesis installed only those run.

@pganssle pganssle removed the type-feature A feature request or enhancement label Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting core review type-bug An unexpected behavior, bug, or error

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants