diff --git a/Lib/email/_header_value_parser.py b/Lib/email/_header_value_parser.py index f396f4f9b32a9a8..fed35c101c9b5e0 100644 --- a/Lib/email/_header_value_parser.py +++ b/Lib/email/_header_value_parser.py @@ -3125,12 +3125,15 @@ def _fold_as_ew(to_encode, lines, maxlen, last_ew, ew_combine_allowed, charset, to_encode_word = to_encode[:text_space] encoded_word = _ew.encode(to_encode_word, charset=encode_as) excess = len(encoded_word) - remaining_space - while excess > 0: + while excess > 0 and len(to_encode_word) > 1: # Since the chunk to encode is guaranteed to fit into less than 100 characters, # shrinking it by one at a time shouldn't take long. to_encode_word = to_encode_word[:-1] encoded_word = _ew.encode(to_encode_word, charset=encode_as) excess = len(encoded_word) - remaining_space + # A single character may not fit even on a fresh line (a four byte + # UTF-8 sequence needs eight base64 characters). Emit it anyway, + # going over maxlen, rather than looping forever on an empty word. lines[-1] += encoded_word to_encode = to_encode[len(to_encode_word):] diff --git a/Lib/test/test_email/test_policy.py b/Lib/test/test_email/test_policy.py index 90e8e5580295f9b..ad467d4337e00f6 100644 --- a/Lib/test/test_email/test_policy.py +++ b/Lib/test/test_email/test_policy.py @@ -8,6 +8,7 @@ import email.generator import email.message from email import headerregistry +from test.support.hypothesis_helper import hypothesis def make_defaults(base_defaults, differences): defaults = base_defaults.copy() @@ -295,6 +296,66 @@ def test_short_maxlen_error(self): with self.assertRaises(email.errors.HeaderParseError): policy.fold("Subject", subject) + def test_wide_char_too_long_for_line_does_not_cause_inf_loop(self): + # A four byte UTF-8 character needs 12 characters in a q-encoded + # word, so with the RFC 2047 chrome and the leading space it cannot + # fit on a 20 character line at all. Rather than looping forever + # trying to shrink the word, the line is allowed to run over. + policy = email.policy.default.clone(max_line_length=20) + actual = policy.fold('Subject', '\U0001f600') + self.assertEqual(actual, 'Subject:\n =?utf-8?q?=F0=9F=98=80?=\n') + + # Pieces chosen to exercise folding: words, runs of whitespace, RFC 5322 + # specials, characters needing 2, 3 and 4 byte UTF-8 encodings, and the + # delimiters of RFC 2047 encoded words. The pieces cannot combine into a + # well-formed encoded word (there is no lone '?'), since such text is + # not encoded and so does not round trip; that is gh-160148. + _fold_pieces = hypothesis.strategies.sampled_from([ + 'a', 'word', 'longwordlongwordlongword', ' ', ' ', '\t', ',', ';', + ':', '(', ')', '<', '>', '"', '@', '\\', '.', '-', '_', '\xa0', + '\xe9', '\u0444', '\u3042', '\U0001f600', '=?', '?=', + ]) + + @hypothesis.given( + subject=hypothesis.strategies.lists( + _fold_pieces, max_size=12).map(''.join), + maxlen=(hypothesis.strategies.integers(1, 120) + | hypothesis.strategies.sampled_from([0, None])), + ) + # Looped forever before the single character case was handled. + @hypothesis.example(subject='\U0001f600', maxlen=20) + @hypothesis.example(subject='\U0001f600a', maxlen=16) + @hypothesis.example(subject='\U0001f600\U0001f600', maxlen=20) + # bpo-33529 and bpo-36564, the earlier infinite loops. + @hypothesis.example(subject='\u0105' * 12, maxlen=20) + @hypothesis.example(subject='Melt away the pounds! 1234567890', maxlen=9) + # Encoded words that must be recombined on reparse (test_generator). + @hypothesis.example(subject='aaaaaaaaa \u0444\u0444\u0444\u0444\u0444\u0444\u0444\u0444 ', maxlen=30) + @hypothesis.example(subject='aaa aaaaaaa aaa \u0444\u0444\u0444 \u0444\u0444\u0444\u0444 ', maxlen=30) + # No wrapping at all, and nothing but whitespace. + @hypothesis.example(subject='word \U0001f600 word', maxlen=None) + @hypothesis.example(subject='word \U0001f600 word', maxlen=0) + @hypothesis.example(subject=' ', maxlen=78) + @hypothesis.example(subject='', maxlen=78) + def test_fold_unstructured_round_trip(self, subject, maxlen): + policy = email.policy.default.clone(max_line_length=maxlen) + try: + folded = policy.fold('Subject', subject) + except email.errors.HeaderParseError: + # The utf-8 RFC 2047 chrome is 12 characters, so no encoded + # word fits on a line of 13 characters or fewer; see + # test_short_maxlen_error. Any other width must fold. + self.assertIsNotNone(maxlen) + self.assertLessEqual(maxlen, 13) + return + reparsed = email.message_from_string( + folded, policy=email.policy.default)['Subject'] + # Leading whitespace is dropped by the parser; everything else, + # including trailing whitespace, survives. + self.assertEqual(reparsed.strip(' \t'), subject.strip(' \t')) + if subject == subject.lstrip(' \t'): + self.assertEqual(reparsed, subject) + def test_verify_generated_headers(self): # Turning protection off allows header injection policy = email.policy.default.clone(verify_generated_headers=False) diff --git a/Misc/NEWS.d/next/Library/2026-10-19-17-06-00.gh-issue-107862.mB7kWe.rst b/Misc/NEWS.d/next/Library/2026-10-19-17-06-00.gh-issue-107862.mB7kWe.rst new file mode 100644 index 000000000000000..033a8ceaaf418e4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-19-17-06-00.gh-issue-107862.mB7kWe.rst @@ -0,0 +1,3 @@ +Fix an infinite loop in :mod:`email` header folding when a single character's +:rfc:`2047` encoded word is wider than the policy's ``max_line_length``; the +line now exceeds the limit instead.