Repository: pallets/werkzeug

Issue:
request.files access became slower
The access to `request.files['file']` became slower when I upgraded to Werkzeug 2.3. A 12.1 MB file used to take a couple of ms, but now it takes about 8 seconds.

```python
from werkzeug import Request, Response, run_simple

@Request.application
def app(request):
    if request.method == "POST":
        return Response(request.files["file"].filename)

    return Response("""<!doctype html>
<meta charset=utf-8>
<form method=post enctype=multipart/form-data>
<input type=file name=file>
<input type=submit>
</form>
""", mimetype="text/html")

run_simple("127.0.0.1", 5000, app)
```

Environment:

- Python version: 3.10
- Werkzeug version: 2.3.0


Discussion:
When opening an issue, be sure to provide a _minimal_ reproducible example. You should be able to demonstrate this _only using Flask_, to demonstrate that it's not related to Flask-Restful, Flask-SocketIO, or Eventlet.
Sorry, I have tested it again by using only Flask and the issue is the same. 

The code that I've used:

```python
app = Flask(__name__)

@app.route("/upload", methods=['POST'])
def upload():
    if request.method == 'POST':
        file = request.files.get('file')
        file_uuid = str(uuid.uuid4())

        os.makedirs("../files", exist_ok=True)
        file.save(f"../files/{file_uuid}")

        return file_uuid, 200

app.run(debug=True, host=host, port=port, use_reloader=False)
```
Seems like the issue is related to the 2.3.0 version of Werkzeug. If I am downgrading both Flask and Werkzeug to 2.2.3 everything works as before.

Seems like only downgrading Flask to 2.2.3 still pulls the 2.3.0 version of Werkzeug. I only had Flask in my `requirements.txt` file, marked as `Flask==2.2.3`, but still ended up with this issue. This is most likely related to pip, but I was expecting the downstream dependencies of Flask's versions to remain the same.
> I only had Flask in my requirements.txt

When writing an application, you _must_ use a tool like [pip-tools](https://pypi.org/project/pip-tools/) to pin your application's full dependency tree. This gives you reproducible deployments, allowing you to control when you get updates.
It does seem a bit slower, but it's hard to quantify. @pgjones maybe #2639 affected this somehow? I can try testing before and after that commit.
Yep, looks like #2639 introduced a slowdown. Testing on 082e0e5b9c01fa3178ac0153413f082616f10914 is noticeably slower than the commit before it, 6425dbfd0d33a9ed198391838438a0659cfb3368.

Resolving patch:
diff --git a/src/werkzeug/sansio/multipart.py b/src/werkzeug/sansio/multipart.py
--- a/src/werkzeug/sansio/multipart.py
+++ b/src/werkzeug/sansio/multipart.py
@@ -121,15 +121,15 @@ def __init__(
         self._search_position = 0
         self._parts_decoded = 0
 
-    def last_newline(self) -> int:
+    def last_newline(self, data: bytes) -> int:
         try:
-            last_nl = self.buffer.rindex(b"\n")
+            last_nl = data.rindex(b"\n")
         except ValueError:
-            last_nl = len(self.buffer)
+            last_nl = len(data)
         try:
-            last_cr = self.buffer.rindex(b"\r")
+            last_cr = data.rindex(b"\r")
         except ValueError:
-            last_cr = len(self.buffer)
+            last_cr = len(data)
 
         return min(last_nl, last_cr)
 
@@ -251,17 +251,25 @@ def _parse_data(self, data: bytes, *, start: bool) -> tuple[bytes, int, bool]:
         else:
             data_start = 0
 
-        match = self.boundary_re.search(data)
-        if match is not None:
-            if match.group(1).startswith(b"--"):
-                self.state = State.EPILOGUE
-            else:
-                self.state = State.PART
-            data_end = match.start()
-            del_index = match.end()
+        if self.buffer.find(b"--" + self.boundary) == -1:
+            # No complete boundary in the buffer, but there may be
+            # a partial boundary at the end. As the boundary
+            # starts with either a nl or cr find the earliest and
+            # return up to that as data.
+            data_end = del_index = self.last_newline(data[data_start:])
+            more_data = True
         else:
-            data_end = del_index = self.last_newline()
-        more_data = match is None
+            match = self.boundary_re.search(data)
+            if match is not None:
+                if match.group(1).startswith(b"--"):
+                    self.state = State.EPILOGUE
+                else:
+                    self.state = State.PART
+                data_end = match.start()
+                del_index = match.end()
+            else:
+                data_end = del_index = self.last_newline(data[data_start:])
+            more_data = match is None
 
         return bytes(data[data_start:data_end]), del_index, more_data
 


Test patch: