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()

Reply via email to