Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-python-multipart for
openSUSE:Factory checked in at 2026-06-22 18:05:16
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-python-multipart (Old)
and /work/SRC/openSUSE:Factory/.python-python-multipart.new.1956 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-python-multipart"
Mon Jun 22 18:05:16 2026 rev:17 rq:1360615 version:0.0.32
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-python-multipart/python-python-multipart.changes
2026-05-28 23:07:41.816931063 +0200
+++
/work/SRC/openSUSE:Factory/.python-python-multipart.new.1956/python-python-multipart.changes
2026-06-22 18:05:33.777499468 +0200
@@ -1,0 +2,14 @@
+Fri Jun 19 08:50:04 UTC 2026 - Nico Krapp <[email protected]>
+
+- Update to 0.0.32
+ * Replace per-byte partial-boundary scan with rfind lookbehind
+- Update to 0.0.31 (fixes CVE-2026-53540 (bsc#1268488))
+ * Speed up multipart header parsing and callback dispatch
+ * Bound header field name size before validating
+ * Validate Content-Length is non-negative in parse_form
+- Update to 0.0.30 (fixes CVE-2026-53537 (bsc#1268506),
+ CVE-2026-53538 (bsc#1268496), CVE-2026-53539 (bsc#1268500))
+ * Treat only & as the urlencoded field separator
+ * Ignore RFC 2231 extended parameters in parse_options_header
+
+-------------------------------------------------------------------
Old:
----
python_multipart-0.0.29.tar.gz
New:
----
python_multipart-0.0.32.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-python-multipart.spec ++++++
--- /var/tmp/diff_new_pack.dyg07j/_old 2026-06-22 18:05:35.105545803 +0200
+++ /var/tmp/diff_new_pack.dyg07j/_new 2026-06-22 18:05:35.105545803 +0200
@@ -18,7 +18,7 @@
%{?sle15_python_module_pythons}
Name: python-python-multipart
-Version: 0.0.29
+Version: 0.0.32
Release: 0
License: Apache-2.0
Summary: Python streaming multipart parser
++++++ python_multipart-0.0.29.tar.gz -> python_multipart-0.0.32.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/python_multipart-0.0.29/CHANGELOG.md
new/python_multipart-0.0.32/CHANGELOG.md
--- old/python_multipart-0.0.29/CHANGELOG.md 2020-02-02 01:00:00.000000000
+0100
+++ new/python_multipart-0.0.32/CHANGELOG.md 2020-02-02 01:00:00.000000000
+0100
@@ -1,5 +1,22 @@
# Changelog
+## Unreleased
+
+## 0.0.32 (2026-06-04)
+
+* Speed up partial-boundary scanning for CR/LF-dense part data
[#300](https://github.com/Kludex/python-multipart/pull/300).
+
+## 0.0.31 (2026-06-04)
+
+* Speed up multipart header parsing and callback dispatch
[#295](https://github.com/Kludex/python-multipart/pull/295).
+* Bound header field name size before validating
[#296](https://github.com/Kludex/python-multipart/pull/296).
+* Validate `Content-Length` is non-negative in `parse_form`
[#297](https://github.com/Kludex/python-multipart/pull/297).
+
+## 0.0.30 (2026-05-31)
+
+* Parse `application/x-www-form-urlencoded` bodies per the WHATWG URL
standard, treating only `&` as a field separator
[#290](https://github.com/Kludex/python-multipart/pull/290).
+* Ignore RFC 2231/5987 extended parameters (`name*`, `filename*`) in
`parse_options_header`, keeping the plain parameter authoritative per [RFC 7578
§4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2)
[#291](https://github.com/Kludex/python-multipart/pull/291).
+
## 0.0.29 (2026-05-17)
* Handle malformed RFC 2231 continuations in `parse_options_header`
[#270](https://github.com/Kludex/python-multipart/pull/270).
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/python_multipart-0.0.29/PKG-INFO
new/python_multipart-0.0.32/PKG-INFO
--- old/python_multipart-0.0.29/PKG-INFO 2020-02-02 01:00:00.000000000
+0100
+++ new/python_multipart-0.0.32/PKG-INFO 2020-02-02 01:00:00.000000000
+0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: python-multipart
-Version: 0.0.29
+Version: 0.0.32
Summary: A streaming multipart parser for Python
Project-URL: Homepage, https://github.com/Kludex/python-multipart
Project-URL: Documentation, https://kludex.github.io/python-multipart/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/python_multipart-0.0.29/pyproject.toml
new/python_multipart-0.0.32/pyproject.toml
--- old/python_multipart-0.0.29/pyproject.toml 2020-02-02 01:00:00.000000000
+0100
+++ new/python_multipart-0.0.32/pyproject.toml 2020-02-02 01:00:00.000000000
+0100
@@ -36,8 +36,8 @@
dev = [
"atomicwrites==1.4.1",
"attrs==26.1.0",
- "coverage==7.13.5",
- "more-itertools==11.0.2",
+ "coverage>=7.14.0",
+ "more-itertools>=11.1.0",
"pbr==7.0.3",
"pluggy==1.6.0",
"py==1.11.0",
@@ -47,7 +47,7 @@
"PyYAML==6.0.3",
"invoke==3.0.3",
"pytest-timeout==2.4.0",
- "ruff==0.15.11",
+ "ruff>=0.15.14",
"mypy",
"types-PyYAML",
"atheris==2.3.0; python_version <= '3.11'",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/python_multipart-0.0.29/python_multipart/__init__.py
new/python_multipart-0.0.32/python_multipart/__init__.py
--- old/python_multipart-0.0.29/python_multipart/__init__.py 2020-02-02
01:00:00.000000000 +0100
+++ new/python_multipart-0.0.32/python_multipart/__init__.py 2020-02-02
01:00:00.000000000 +0100
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-__version__ = "0.0.29"
+__version__ = "0.0.32"
from .multipart import (
BaseParser,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/python_multipart-0.0.29/python_multipart/multipart.py
new/python_multipart-0.0.32/python_multipart/multipart.py
--- old/python_multipart-0.0.29/python_multipart/multipart.py 2020-02-02
01:00:00.000000000 +0100
+++ new/python_multipart-0.0.32/python_multipart/multipart.py 2020-02-02
01:00:00.000000000 +0100
@@ -5,7 +5,6 @@
import shutil
import sys
import tempfile
-from email.message import Message
from enum import IntEnum
from io import BufferedRandom, BytesIO
from numbers import Number
@@ -126,7 +125,6 @@
SPACE = b" "[0]
HYPHEN = b"-"[0]
AMPERSAND = b"&"[0]
-SEMICOLON = b";"[0]
LOWER_A = b"a"[0]
LOWER_Z = b"z"[0]
NULL = b"\x00"[0]
@@ -135,11 +133,12 @@
# Mask for ASCII characters that can be http tokens.
# Per RFC7230 - 3.2.6, this is all alpha-numeric characters
# and these: !#$%&'*+-.^_`|~
-TOKEN_CHARS_SET = frozenset(
+TOKEN_CHARS = (
b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
b"abcdefghijklmnopqrstuvwxyz"
b"0123456789"
b"!#$%&'*+-.^_`|~")
+TOKEN_CHARS_SET = frozenset(TOKEN_CHARS)
# fmt: on
DEFAULT_MAX_HEADER_COUNT = 8
@@ -157,10 +156,37 @@
"""
+def _parseparam(s: str) -> list[str]:
+ # Vendored from the standard library's
+ #
[`email.message._parseparam`](https://github.com/python/cpython/blob/v3.14.2/Lib/email/message.py#L73-L96)
+ # to split a header into its `;`-separated parts without treating a `;`
inside a double-quoted string as a
+ # separator - and without the RFC 2231 decoding that
`email.message.Message.get_params` would apply on top.
+ s = ";" + s
+ plist: list[str] = []
+ start = 0
+ while s.find(";", start) == start:
+ start += 1
+ end = s.find(";", start)
+ ind, diff = start, 0
+ while end > 0:
+ diff += s.count('"', ind, end) - s.count('\\"', ind, end)
+ if diff % 2 == 0:
+ break
+ end, ind = ind, s.find(";", end + 1)
+ if end < 0:
+ end = len(s)
+ i = s.find("=", start, end)
+ if i == -1:
+ f = s[start:end]
+ else:
+ f = s[start:i].rstrip().lower() + "=" + s[i + 1 : end].lstrip()
+ plist.append(f.strip())
+ start = end
+ return plist
+
+
def parse_options_header(value: str | bytes | None) -> tuple[bytes,
dict[bytes, bytes]]:
"""Parses a Content-Type header into a value in the following format:
(content_type, {parameters})."""
- # Uses email.message.Message to parse the header as described in PEP 594.
- # Ref: https://peps.python.org/pep-0594/#cgi
if not value:
return (b"", {})
@@ -175,37 +201,24 @@
if ";" not in value:
return (value.lower().strip().encode("latin-1"), {})
- # Split at the first semicolon, to get our value and then options.
- # ctype, rest = value.split(b';', 1)
- message = Message()
- message["content-type"] = value
- # `get_params()` can raise on malformed RFC 2231 headers found via fuzzing:
- # - ValueError on oversized continuation indices (all supported versions).
- # - TypeError on mixed `filename*` + `filename*0*` continuations (Python
3.12 only;
- # 3.13+ silently picks a value).
- # TODO: drop `TypeError` once Python 3.12 reaches EOL (October 2028).
- try:
- params = message.get_params()
- except (TypeError, ValueError): # pragma: no cover
- return (value.split(";", 1)[0].lower().strip().encode("latin-1"), {})
- # If there were no parameters, this would have already returned above
- assert params, "At least the content type value should be present"
- ctype = params.pop(0)[0].encode("latin-1")
+ ctype, *segments = _parseparam(value)
options: dict[bytes, bytes] = {}
- for param in params:
- key, value = param
- # If the value returned from get_params() is a 3-tuple, the last
- # element corresponds to the value.
- # See: https://docs.python.org/3/library/email.compat32-message.html
- if isinstance(value, tuple):
- value = value[-1]
- # If the value is a filename, we need to fix a bug on IE6 that sends
- # the full file path instead of the filename.
- if key == "filename":
- if value[1:3] == ":\\" or value[:2] == "\\\\":
- value = value.split("\\")[-1]
- options[key.encode("latin-1")] = value.encode("latin-1")
- return ctype, options
+ for segment in segments:
+ key, _, val = segment.partition("=")
+ # [RFC 7578
§4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2)
+ # forbids the RFC 5987/2231 extended syntax (`key*=`, `key*0`, ...) in
+ # multipart/form-data, so we ignore those parameters and keep the plain
+ # `key` authoritative.
+ if "*" in key:
+ continue
+ if len(val) >= 2 and val[0] == '"' and val[-1] == '"':
+ val = val[1:-1].replace("\\\\", "\\").replace('\\"', '"')
+ # Work around an IE6 bug where the full file path is sent instead of
+ # just the filename.
+ if key == "filename" and (val[1:3] == ":\\" or val[:2] == "\\\\"):
+ val = val.split("\\")[-1]
+ options[key.encode("latin-1")] = val.encode("latin-1")
+ return ctype.encode("latin-1"), options
class Field:
@@ -635,8 +648,7 @@
end: An integer that is passed to the data callback.
start: An integer that is passed to the data callback.
"""
- on_name = "on_" + name
- func = self.callbacks.get(on_name)
+ func = self.callbacks.get("on_" + name)
if func is None:
return
func = cast("Callable[..., Any]", func)
@@ -645,11 +657,8 @@
# Don't do anything if we have start == end.
if start is not None and start == end:
return
-
- self.logger.debug("Calling %s with data[%d:%d]", on_name, start,
end)
func(data, start, end)
else:
- self.logger.debug("Calling %s with no data", on_name)
func()
def set_callback(self, name: CallbackName, new_func: Callable[..., Any] |
None) -> None:
@@ -840,13 +849,13 @@
# yet reached a separator, and thus, if we do, we need to skip
# it as it will be the boundary between fields that's supposed
# to be there.
- if ch == AMPERSAND or ch == SEMICOLON:
+ if ch == AMPERSAND:
if found_sep:
# If we're parsing strictly, we disallow blank chunks.
if strict_parsing:
- raise QuerystringParseError("Skipping duplicate
ampersand/semicolon at %d" % i, offset=i)
+ raise QuerystringParseError("Skipping duplicate
ampersand at %d" % i, offset=i)
else:
- self.logger.debug("Skipping duplicate
ampersand/semicolon at %d", i)
+ self.logger.debug("Skipping duplicate ampersand at
%d", i)
else:
# This case is when we're skipping the (first)
# separator between fields, so we just set our flag
@@ -864,9 +873,7 @@
elif state == QuerystringState.FIELD_NAME:
# Try and find a separator - we ensure that, if we do, we only
# look for the equal sign before it.
- sep_pos = data.find(b"&", i)
- if sep_pos == -1:
- sep_pos = data.find(b";", i)
+ sep_pos = data.find(b"&", i, length)
# See if we can find an equals sign in the remaining data. If
# so, we can immediately emit the field name and jump to the
@@ -874,7 +881,7 @@
if sep_pos != -1:
equals_pos = data.find(b"=", i, sep_pos)
else:
- equals_pos = data.find(b"=", i)
+ equals_pos = data.find(b"=", i, length)
if equals_pos != -1:
# Emit this name.
@@ -921,11 +928,8 @@
i = length
elif state == QuerystringState.FIELD_DATA:
- # Try finding either an ampersand or a semicolon after this
- # position.
- sep_pos = data.find(b"&", i)
- if sep_pos == -1:
- sep_pos = data.find(b";", i)
+ # Try finding an ampersand after this position.
+ sep_pos = data.find(b"&", i, length)
# If we found it, callback this bit as data and then go back
# to expecting to find a field.
@@ -1071,6 +1075,7 @@
def _internal_write(self, data: bytes, length: int) -> int:
# Get values from locals.
boundary = self.boundary
+ boundary_length = len(boundary)
# Get our state, flags and index. These are persisted between calls to
# this function.
@@ -1121,7 +1126,7 @@
# We need to use self.flags (and not flags) because we care
about
# the state when we entered the loop.
lookbehind_len = -marked_index
- if lookbehind_len <= len(boundary):
+ if lookbehind_len <= boundary_length:
self.callback(name, boundary, 0, lookbehind_len)
elif self.flags & FLAG_PART_BOUNDARY:
lookback = boundary + b"\r\n"
@@ -1166,7 +1171,7 @@
elif state == MultipartState.START_BOUNDARY:
# Check to ensure that the last 2 characters in our boundary
# are CRLF.
- if index == len(boundary) - 2:
+ if index == boundary_length - 2:
if c == HYPHEN:
# Potential empty message.
state = MultipartState.END_BOUNDARY
@@ -1178,7 +1183,7 @@
index += 1
- elif index == len(boundary) - 2 + 1:
+ elif index == boundary_length - 1:
if c != LF:
msg = "Did not find LF at end of boundary (%d)" % (i,)
self.logger.warning(msg)
@@ -1240,31 +1245,41 @@
i += 1
continue
- # Increment our index in the header.
- index += 1
+ # The field name runs until the colon; jump straight to it and
+ # validate the whole span at once instead of byte by byte.
+ colon = data.find(b":", i, length)
+ end = colon if colon != -1 else length
+
+ # Enforce the size limit before slicing and validating, so an
oversized header
+ # name fails fast instead of copying and scanning a
potentially huge span.
+ advance_header_size(end - i if colon == -1 else end - i + 1)
+
+ field = data[i:end]
+ if field.translate(None, TOKEN_CHARS):
+ bad = next(b for b in field if b not in TOKEN_CHARS_SET)
+ bad_i = i + field.index(bad)
+ msg = "Found invalid character %r in header at %d" % (bad,
bad_i)
+ self.logger.warning(msg)
+ raise MultipartParseError(msg, offset=bad_i)
- # If we've reached a colon, we're done with this header.
- if c == COLON:
- advance_header_size()
+ index += end - i
+ if colon == -1:
+ # Field name continues into the next chunk.
+ i = length
+ else:
# A 0-length header is an error.
- if index == 1:
+ if index == 0:
msg = "Found 0-length header at %d" % (i,)
self.logger.warning(msg)
raise MultipartParseError(msg, offset=i)
# Call our callback with the header field.
+ i = colon
data_callback("header_field", i)
# Move to parsing the header value.
state = MultipartState.HEADER_VALUE_START
- elif c not in TOKEN_CHARS_SET:
- msg = "Found invalid character %r in header at %d" % (c, i)
- self.logger.warning(msg)
- raise MultipartParseError(msg, offset=i)
- else:
- advance_header_size()
-
elif state == MultipartState.HEADER_VALUE_START:
# Skip leading spaces.
if c == SPACE:
@@ -1280,15 +1295,19 @@
i -= 1
elif state == MultipartState.HEADER_VALUE:
- # If we've got a CR, we're nearly done our headers. Otherwise,
- # we do nothing and just move past this character.
- if c == CR:
+ # The value runs until the terminating CR; jump straight to it
+ # instead of inspecting every byte.
+ cr = data.find(b"\r", i, length)
+ end = cr if cr != -1 else length
+ advance_header_size(end - i)
+ if cr != -1:
+ i = cr
data_callback("header_value", i)
self.callback("header_end")
current_header_size = 0
state = MultipartState.HEADER_VALUE_ALMOST_DONE
else:
- advance_header_size()
+ i = length
elif state == MultipartState.HEADER_VALUE_ALMOST_DONE:
# The last character should be a LF. If not, it's an error.
@@ -1331,33 +1350,30 @@
# find part of a boundary, but it doesn't match fully.
prev_index = index
- # Set up variables.
- boundary_length = len(boundary)
- data_length = length
-
# If our index is 0, we're starting a new part, so start our
# search.
if index == 0:
# The most common case is likely to be that the whole
# boundary is present in the buffer.
# Calling `find` is much faster than iterating here.
- i0 = data.find(boundary, i, data_length)
+ i0 = data.find(boundary, i, length)
if i0 >= 0:
# We matched the whole boundary string.
index = boundary_length - 1
i = i0 + boundary_length - 1
+ c = data[i]
else:
- # No match found for whole string.
- # There may be a partial boundary at the end of the
- # data, which the find will not match.
- # Since the length to be searched is limited to the
- # boundary length, scan the tail for boundary[0] via
- # bytes.find (C-level) to keep cost off the Python
loop.
- i = max(i, data_length - boundary_length)
- j = data.find(boundary[:1], i, data_length - 1)
- i = j if j >= 0 else data_length - 1
-
- c = data[i]
+ # No whole boundary, but the tail may hold a partial
one
+ # that completes in the next chunk. Boundary starts
with
+ # CR, which an RFC boundary contains nowhere else, so
the
+ # last CR in the tail is the only candidate prefix
start.
+ k = data.rfind(boundary[:1], max(i, length -
boundary_length + 1), length)
+ if k != -1 and boundary.startswith(data[k:length]):
+ index = length - k
+ # Carry the partial via index; the end-of-chunk flush
+ # emits the data before it and re-marks the lookbehind.
+ i = length
+ continue
# Now, we have a couple of cases here. If our index is before
# the end of the boundary...
@@ -1449,7 +1465,7 @@
i -= 1
elif state == MultipartState.END_BOUNDARY:
- if index == len(boundary) - 2 + 1:
+ if index == boundary_length - 1:
if c != HYPHEN:
msg = "Did not find - at end of boundary (%d)" % (i,)
self.logger.warning(msg)
@@ -1885,6 +1901,8 @@
content_length: int | float | bytes | None = headers.get("Content-Length")
if content_length is not None:
content_length = int(content_length)
+ if content_length < 0:
+ raise ValueError("Content-Length must be non-negative")
else:
content_length = float("inf")
bytes_read = 0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/python_multipart-0.0.29/tests/test_data/http/crlf_dense_part_data.http
new/python_multipart-0.0.32/tests/test_data/http/crlf_dense_part_data.http
--- old/python_multipart-0.0.29/tests/test_data/http/crlf_dense_part_data.http
1970-01-01 01:00:00.000000000 +0100
+++ new/python_multipart-0.0.32/tests/test_data/http/crlf_dense_part_data.http
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,34 @@
+--boundary
+Content-Disposition: form-data; name="file"; filename="crlf.bin"
+Content-Type: application/octet-stream
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+--boundar
+
+
+
+
+
+--bound
+
+--boundary--
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/python_multipart-0.0.29/tests/test_data/http/crlf_dense_part_data.yaml
new/python_multipart-0.0.32/tests/test_data/http/crlf_dense_part_data.yaml
--- old/python_multipart-0.0.29/tests/test_data/http/crlf_dense_part_data.yaml
1970-01-01 01:00:00.000000000 +0100
+++ new/python_multipart-0.0.32/tests/test_data/http/crlf_dense_part_data.yaml
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,8 @@
+boundary: boundary
+expected:
+ - name: file
+ type: file
+ file_name: crlf.bin
+ content_type: 'application/octet-stream'
+ data: !!binary |
+
DQoNCg0KDQoNCg0KDQoNCg0KDQoNCg0KDQoNCg0KDQoNCg0KDQoNCg0KLS1ib3VuZGFyDQoNCg0KDQoNCg0KLS1ib3VuZA0K
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/python_multipart-0.0.29/tests/test_multipart.py
new/python_multipart-0.0.32/tests/test_multipart.py
--- old/python_multipart-0.0.29/tests/test_multipart.py 2020-02-02
01:00:00.000000000 +0100
+++ new/python_multipart-0.0.32/tests/test_multipart.py 2020-02-02
01:00:00.000000000 +0100
@@ -299,24 +299,59 @@
# If vulnerable, this test wouldn't finish, the line above would hang
self.assertIn(b'"\\', p[b"!"])
- def test_handles_rfc_2231(self) -> None:
+ def test_ignores_rfc_2231_extended_param(self) -> None:
+ # RFC 7578 §4.2 forbids the RFC 5987/2231 extended syntax, so the
+ # decoded `param*` value is not exposed under `param`.
t, p = parse_options_header(b"text/plain;
param*=us-ascii'en-us'encoded%20message")
- self.assertEqual(p[b"param"], b"encoded message")
+ self.assertEqual(t, b"text/plain")
+ self.assertEqual(p, {})
+
+ def test_plain_param_authoritative_over_extended(self) -> None:
+ # When both plain and extended forms are present, the plain one wins
+ # and the extended one is ignored.
+ _, p = parse_options_header(b"form-data; name=\"comment\";
name*=utf-8''other")
+
+ self.assertEqual(p, {b"name": b"comment"})
+
+ def test_ignores_rfc_2231_continuation_filename(self) -> None:
+ _, p = parse_options_header(b'form-data; name="f"; filename*0="a";
filename*1="b.txt"')
+
+ self.assertEqual(p, {b"name": b"f"})
- def test_rejects_oversized_rfc_2231_index(self) -> None:
+ def test_ignores_oversized_rfc_2231_index(self) -> None:
t, p = parse_options_header("text/plain; filename*" + ("1" * 4301) +
"*=utf-8''x")
self.assertEqual(t, b"text/plain")
self.assertEqual(p, {})
- @pytest.mark.skipif(sys.version_info >= (3, 13), reason="email parser only
raises TypeError on Python 3.12")
- def test_rejects_mixed_rfc_2231_continuations(self) -> None:
+ def test_ignores_mixed_rfc_2231_continuations(self) -> None:
t, p = parse_options_header("text/plain; filename*=utf-8''a;
filename*0*=utf-8''b")
self.assertEqual(t, b"text/plain")
self.assertEqual(p, {})
+ def test_ignores_extended_param_case_insensitively(self) -> None:
+ _, p = parse_options_header(b"text/plain; UPPER*=utf-8''X")
+
+ self.assertEqual(p, {})
+
+ def test_preserves_quoted_semicolons_and_escapes(self) -> None:
+ _, p = parse_options_header(b'text/plain; a="x;y"; b="esc \\" quote"')
+
+ self.assertEqual(p, {b"a": b"x;y", b"b": b'esc " quote'})
+
+ def test_preserves_content_type_case(self) -> None:
+ t, p = parse_options_header(b"Text/Plain; a=b")
+
+ self.assertEqual(t, b"Text/Plain")
+ self.assertEqual(p, {b"a": b"b"})
+
+ def test_preserves_backslash_unquoting_order(self) -> None:
+ _, p = parse_options_header(b'text/plain; q="a\\\\b"')
+
+ self.assertEqual(p, {b"q": b"a\\b"})
+
class TestBaseParser(unittest.TestCase):
def setUp(self) -> None:
@@ -428,10 +463,10 @@
self.p.write(b"f=baz")
self.assert_fields((b"asdf", b"baz"))
- def test_semicolon_separator(self) -> None:
- self.p.write(b"foo=bar;asdf=baz")
+ def test_semicolon_is_data_not_a_field_separator(self) -> None:
+ self.p.write(b"role=user&x=;role=admin")
- self.assert_fields((b"foo", b"bar"), (b"asdf", b"baz"))
+ self.assert_fields((b"role", b"user"), (b"x", b";role=admin"))
def test_too_large_field(self) -> None:
self.p.max_size = 15
@@ -744,6 +779,7 @@
"almost_match_boundary_without_LF",
"almost_match_boundary_without_final_hyphen",
"single_field_single_file",
+ "crlf_dense_part_data",
]
EPILOGUE_TEST_HEAD = (
@@ -1615,6 +1651,15 @@
chunk_size=0,
)
+ def test_parse_form_negative_content_length(self) -> None:
+ with self.assertRaisesRegex(ValueError, "Content-Length must be
non-negative"):
+ parse_form(
+ {"Content-Type": b"application/octet-stream",
"Content-Length": b"-1"},
+ BytesIO(b"123456789012345"),
+ lambda _: None,
+ lambda _: None,
+ )
+
def suite() -> unittest.TestSuite:
suite = unittest.TestSuite()