Repository: twisted/twisted

Issue:
Regression handling multiple files in multipart/form-data with same name
**Summary** 

We have found a regression with latest releases of Twisted in `twisted.web.http` due to replacement of `cgi.parse_multipart` in commit 4579398 which has resulted in our web server only being able to retrieve a single file from a request that contained multiple files.

**Environment**
 - Twisted version 24.x

**Description**

The web app has a file picker form where a user can select multiple files to upload to the web server.  Here is a sample snippet of request received by web server:

```
Content-Disposition: form-data; name="file"; filename="test1.torrent"
Content-Type: application/x-bittorrent
...
Content-Disposition: form-data; name="file"; filename="tes2.torrent"
Content-Type: application/x-bittorrent
```

The web server parses out the filenames as follows:

    files = request.args.get(b'file', [])

**Expected**

A list containing both files

**Actual**

A list containing only the first file

**Investigation notes**

The  content-disposition of the file upload form is using the same `name` parameter for both files. However the commit to replace `cgi` results in only a single file ever being assigned to a name:

```
        result[name.encode("utf8")] = [payload]
```

According to  [rfc7578](https://www.rfc-editor.org/rfc/rfc7578) this is correct request

> To match widely deployed implementations, multiple files MUST be sent
   by supplying each file in a separate part but all with the same
   "name" parameter.

The solution to the problem is to assign result to a  `defaultdict(list)` then for the same key append payloads found to the list. I'll push a PR shortly...



Discussion:
codspeed-hq[bot]: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/twisted/twisted/branches/cas--%3A12423-fix-web-http-multipart-files)

### Merging #12424 will **not alter performance**

<sub>Comparing <code>cas--:12423-fix-web-http-multipart-files</code> (20f6fbb) with <code>trunk</code> (6baa16d)</sub>



### Summary

`✅ 34` untouched benchmarks  





Patch:
diff --git a/src/twisted/web/http.py b/src/twisted/web/http.py
index 4a7cdbf13d..3575a934fb 100644
--- a/src/twisted/web/http.py
+++ b/src/twisted/web/http.py
@@ -108,6 +108,7 @@ import os
 import re
 import tempfile
 import warnings
+from collections import defaultdict
 from email import message_from_bytes
 from email.message import EmailMessage, Message
 from io import BufferedIOBase, BytesIO, TextIOWrapper
@@ -323,7 +324,7 @@ def _getMultiPartArgs(content: bytes, ctype: bytes) -> dict[bytes, list[bytes]]:
     """
     Parse the content of a multipart/form-data request.
     """
-    result = {}
+    result = defaultdict(list)
     multiPartHeaders = b"MIME-Version: 1.0\r\n" + b"Content-Type: " + ctype + b"\r\n"
     msg = message_from_bytes(multiPartHeaders + content)
     if not msg.is_multipart():
@@ -339,7 +340,7 @@ def _getMultiPartArgs(content: bytes, ctype: bytes) -> dict[bytes, list[bytes]]:
         if not name:
             continue
         payload: bytes = part.get_payload(decode=True)  # type:ignore[assignment]
-        result[name.encode("utf8")] = [payload]
+        result[name.encode("utf8")].append(payload)
     return result
 
 
diff --git a/src/twisted/web/newsfragments/12423.bugfix b/src/twisted/web/newsfragments/12423.bugfix
new file mode 100644
index 0000000000..ecd4b057dd
--- /dev/null
+++ b/src/twisted/web/newsfragments/12423.bugfix
@@ -0,0 +1,1 @@
+twisted.web.http.Request.requestReceived now handles multiple files in multipart/form-data with the same "name" parameter. This was a regression introduced in Twisted 24.3.0. (#12423)


Test patch:
diff --git a/src/twisted/web/test/test_http.py b/src/twisted/web/test/test_http.py
index f40c08b67c..a27c9ebfb3 100644
--- a/src/twisted/web/test/test_http.py
+++ b/src/twisted/web/test/test_http.py
@@ -2550,12 +2550,12 @@ abasdfg
         self.assertEqual(len(processed), 2)
         self.assertEqual(processed[1].args, {})
 
-    def test_multipartFileData(self):
+    def test_multipartFilesData(self):
         """
         If the request has a Content-Type of C{multipart/form-data}, the
-        C{Request} is told to parse the body, and the form data is parseable
-        and contains files, the file portions will be added to the request's
-        args.
+        C{Request} is told to parse the body, if the form data is parseable
+        and contains files, each of the file portions will be added to the
+        request's args in the same order.
         """
         processed = []
 
@@ -2566,10 +2566,14 @@ abasdfg
                 self.finish()
 
         body = b"""-----------------------------738837029596785559389649595
-Content-Disposition: form-data; name="uploadedfile"; filename="test"
+Content-Disposition: form-data; name="uploadedfiles"; filename="testC"
 Content-Type: application/octet-stream
 
 abasdfg
+-----------------------------738837029596785559389649595
+Content-Disposition: form-data; name="uploadedfiles"; filename="testB"
+
+qwerty
 -----------------------------738837029596785559389649595--
 """
 
@@ -2587,7 +2591,7 @@ Content-Length: """
         channel = self.runRequest(req.encode("ascii") + body, MyRequest, success=False)
         self.assertEqual(channel.transport.value(), b"HTTP/1.0 200 OK\r\n\r\ndone")
         self.assertEqual(len(processed), 1)
-        self.assertEqual(processed[0].args, {b"uploadedfile": [b"abasdfg"]})
+        self.assertEqual(processed[0].args, {b"uploadedfiles": [b"abasdfg", b"qwerty"]})
 
         # Now check the disabled case:
         channel = self.runRequest(