Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Lib/email/_header_value_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):]

Expand Down
3 changes: 2 additions & 1 deletion Lib/test/support/_hypothesis_stubs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
"assume",
"reject",
"register_random",
"stateful",
"strategies",
"HealthCheck",
"settings",
"Verbosity",
]

from . import strategies
from . import stateful, strategies


def given(*_args, **_kwargs):
Expand Down
97 changes: 97 additions & 0 deletions Lib/test/support/_hypothesis_stubs/stateful.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Stubs for the subset of ``hypothesis.stateful`` used by the test suite.

The stateful API has no equivalent of ``@example``, so without Hypothesis
there is nothing to replay: the ``TestCase`` generated for a state machine
is skipped, and ``run_state_machine_as_test`` raises ``unittest.SkipTest``.
The decorators and helpers are accepted so that state machine classes can
be defined at import time.
"""
import unittest

from ._helpers import stub_factory
from .strategies import StubStrategy

__all__ = [
"Bundle",
"MultipleResults",
"RuleBasedStateMachine",
"consumes",
"initialize",
"invariant",
"multiple",
"precondition",
"rule",
"run_state_machine_as_test",
]

_SKIP_REASON = "Hypothesis required for stateful test"

Bundle = stub_factory(StubStrategy, "hypothesis.stateful.Bundle")


def consumes(bundle):
return bundle


class MultipleResults:
def __init__(self, *values):
self.values = values


def multiple(*args):
return MultipleResults(*args)


def _identity_operator(f):
return f


def _identity_decorator(*_args, **_kwargs):
Comment thread
pganssle marked this conversation as resolved.
return _identity_operator


rule = _identity_decorator
initialize = _identity_decorator
invariant = _identity_decorator
precondition = _identity_decorator


class _TestCaseProperty:
"""Build (and cache) a skipped ``unittest.TestCase`` per state machine."""

def __init__(self):
self._cases = {}

def __get__(self, obj, owner):
if owner not in self._cases:

class StateMachineTestCase(unittest.TestCase):
settings = None

def runTest(self):
raise unittest.SkipTest(_SKIP_REASON)

StateMachineTestCase.__name__ = f"{owner.__name__}.TestCase"
StateMachineTestCase.__qualname__ = f"{owner.__qualname__}.TestCase"
self._cases[owner] = StateMachineTestCase
return self._cases[owner]


class RuleBasedStateMachine:
TestCase = _TestCaseProperty()

def bundle(self, name):
return Bundle(name)

def check_invariants(self, settings, output, runtimes):
pass

def setup_state(self):
pass

def teardown(self):
pass


def run_state_machine_as_test(state_machine_factory, *, settings=None):
raise unittest.SkipTest(_SKIP_REASON)
3 changes: 3 additions & 0 deletions Lib/test/support/_hypothesis_stubs/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def __or__(self, other):
"frozensets",
"functions",
"integers",
"ip_addresses",
"iterables",
"just",
"lists",
Expand All @@ -62,6 +63,8 @@ def __or__(self, other):
"slices",
"timedeltas",
"times",
"timezone_keys",
"timezones",
"text",
"tuples",
"uuids",
Expand Down
4 changes: 4 additions & 0 deletions Lib/test/support/hypothesis_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
except ImportError:
from . import _hypothesis_stubs as hypothesis
else:
# Real Hypothesis does not import this submodule on its own; do it here
# so that ``hypothesis.stateful`` is available in both modes.
import hypothesis.stateful

# Regrtest changes to use a tempdir as the working directory, so we have
# to tell Hypothesis to use the original in order to persist the database.
from test.support import has_socket_support
Expand Down
80 changes: 80 additions & 0 deletions Lib/test/test_email/test_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -295,6 +296,85 @@ 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 '?'); that case is a known
# failure covered by test_fold_encoded_word_lookalike_round_trip.
_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)

@unittest.expectedFailure
@hypothesis.given(
word=hypothesis.strategies.text(alphabet='abc', min_size=1, max_size=5),
trailer=hypothesis.strategies.sampled_from(['', ')', ' word']),
)
@hypothesis.example(word='x', trailer='')
@hypothesis.example(word='x', trailer=')')
def test_fold_encoded_word_lookalike_round_trip(self, word, trailer):
# Text that happens to be a well-formed RFC 2047 encoded word is
# written out verbatim, so it is decoded when the message is
# reparsed. As far as I can tell RFC 2047 section 5 requires an
# encoder to encode such text; until that is done this is a known
# failure.
subject = f'=?utf-8?q?{word}?={trailer}'
folded = email.policy.default.fold('Subject', subject)
reparsed = email.message_from_string(
folded, policy=email.policy.default)['Subject']
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add ``hypothesis.stateful`` and the ``timezones()`` and ``ip_addresses()``
strategies to the Hypothesis stubs used when Hypothesis is not installed.
Loading