Repository: pallets/werkzeug (python)

Issue:
v2.2.0 breaks previous URL map logic for url-encoded path segments containing %2F
Version 2.2.0 seems to change the way url-encoded path segments (specifically paths which contain the escape sequence for `/`, `%2F`) are handled in rules/maps in the routing module.

Here's a self-contained example demonstrating the difference in routing behavior, save this in `werkzeug_test.py` for example:
```python
from werkzeug.routing import Map, Rule
from werkzeug.wrappers import Request, Response


class PathTester:

    URL_MAP = Map(
        [
            Rule(
                "/<path:some_path>/some_route",
                endpoint="simple_route",
            ),
            Rule(
                "/<path:some_path>/<segment_a>/<segment_b>",
                endpoint="complex_route",
            ),
        ]
    )

    def on_simple_route(self, request, some_path):
        return Response("Matched simple route")

    def on_complex_route(self, request, some_path, segment_a, segment_b):
        return Response("Matched complex route")

    def dispatch_request(self, request):
        adapter = self.URL_MAP.bind_to_environ(request.environ)
        endpoint, values = adapter.match()
        return getattr(self, f"on_{endpoint}")(request, **values)

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        return self.wsgi_app(environ, start_response)


if __name__ == "__main__":
    from werkzeug.serving import run_simple
    run_simple("127.0.0.1", 5000, PathTester())
```

Running this example using werkzeug 2.1.2:
```shell
$ pip install --upgrade Werkzeug==2.1.2
$ python werkzeug_test.py

# … in another terminal

$ curl http://localhost:5000/my%2Fpath/some_route
Matched simple route
```

Now, running this example using werkzeug 2.2.0:
```shell
$ pip install --upgrade Werkzeug==2.2.0
$ python werkzeug_test.py

# … in another terminal

$ curl http://localhost:5000/my%2Fpath/some_route
Matched complex route
```

Environment:
- Python version: 3.9.13
- Werkzeug version: 2.2.0


Pull request:
Bugfix the calculation of weighting based on static parts
Closes #2471

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [x] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 70d7b971..30ae859c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -9,6 +9,8 @@ Unreleased
     slashes mode is disabled for the rule. :issue:`2467`
 -   Fix router so that partial part matches are not allowed
     i.e. ``/2df`` does not match ``/<int>``. :pr:`2470`
+-   Fix router static part weighting, so that simpler routes are matched
+    before more complex ones. :issue:`2471`
 -   Restore ``ValidationError`` to be importable from
     ``werkzeug.routing``. :issue:`2465`
 
diff --git a/src/werkzeug/routing/rules.py b/src/werkzeug/routing/rules.py
index 29c78568..cecdc000 100644
--- a/src/werkzeug/routing/rules.py
+++ b/src/werkzeug/routing/rules.py
@@ -592,8 +592,12 @@ class Rule(RuleFactory):
 
             data = match.groupdict()
             if data["static"]:
-                static_weights.append((static_parts, -len(data["static"])))
-                static_parts += 1
+                # Only static parts beyond the `/` count towards the
+                # weighting (more static parts mean the rule should be
+                # weighted first).
+                if data["static"] != "/":
+                    static_weights.append((static_parts, -len(data["static"])))
+                    static_parts += 1
                 self._trace.append((False, data["static"]))
 
             if data["variable"] is not None:


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
index 4f9fc2cc..d8361d8c 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -1394,6 +1394,18 @@ def test_rule_websocket_methods():
     r.Rule("/ws", endpoint="ws", websocket=True, methods=["get", "head", "options"])
 
 
+def test_path_weighting():
+    m = r.Map(
+        [
+            r.Rule("/<path:path>/c", endpoint="simple"),
+            r.Rule("/<path:path>/<a>/<b>", endpoint="complex"),
+        ]
+    )
+    a = m.bind("localhost", path_info="/a/b/c")
+
+    assert a.match() == ("simple", {"path": "a/b"})
+
+
 def test_newline_match():
     m = r.Map([r.Rule("/hello", endpoint="hello")])
     a = m.bind("localhost")