From d69df35cd2cad9c72794c2c340db646afae957d8 Mon Sep 17 00:00:00 2001
From: Marcelo Trylesinski <marcelotryle@gmail.com>
Date: Sun, 31 May 2026 14:22:44 +0200
Subject: [PATCH] Treat only `&` as the urlencoded field separator (#290)

---
 CHANGELOG.md                  |  4 ++++
 python_multipart/multipart.py | 20 +++++++-------------
 tests/test_multipart.py       |  6 +++---
 3 files changed, 14 insertions(+), 16 deletions(-)

Index: python_multipart-0.0.28/python_multipart/multipart.py
===================================================================
--- python_multipart-0.0.28.orig/python_multipart/multipart.py
+++ python_multipart-0.0.28/python_multipart/multipart.py
@@ -125,7 +125,6 @@ COLON = b":"[0]
 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]
@@ -853,13 +852,13 @@ class QuerystringParser(BaseParser):
                 # 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
@@ -877,9 +876,7 @@ class QuerystringParser(BaseParser):
             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
@@ -887,7 +884,7 @@ class QuerystringParser(BaseParser):
                 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.
@@ -934,11 +931,8 @@ class QuerystringParser(BaseParser):
                         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.
Index: python_multipart-0.0.28/tests/test_multipart.py
===================================================================
--- python_multipart-0.0.28.orig/tests/test_multipart.py
+++ python_multipart-0.0.28/tests/test_multipart.py
@@ -464,10 +464,10 @@ class TestQuerystringParser(unittest.Tes
         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
