Repository: pallets/werkzeug

Issue:
path converter doesn't redirect to trailing slash
It seems to me that the automatic redirect for URLs with trailing shlashes (like `/user` → `/user/`) described in [Flask docs](https://flask.palletsprojects.com/en/2.2.x/quickstart/#unique-urls-redirection-behavior) and [Werkzeug docs](https://werkzeug.palletsprojects.com/en/2.2.x/routing/#rule-format) does not work if you use PathConverter in an URL definition.

I have this straightforward Flask application to reproduce the problem:
```
from flask import Flask, Response

app = Flask(__name__)

@app.route('/')
@app.route('/<path:username>/')
def main(username=None):
    return Response(f"It works! Hello, {username}")
```
In werkzeug 2.1.2, it works as expected and if you send a request to `/user`, it redirects you via HTTP/308 to `/user/`. The log is:
```
* Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
127.0.0.1 - - [12/Oct/2022 14:47:37] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Oct/2022 14:47:45] "GET /user HTTP/1.1" 308 -
127.0.0.1 - - [12/Oct/2022 14:47:45] "GET /user/ HTTP/1.1" 200 -
```
But when I use werkzeug 2.2.0, the same request gives me HTTP/404:
```
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
127.0.0.1 - - [12/Oct/2022 14:48:43] "GET /fooo HTTP/1.1" 404 -
```
I've tried a bisection of all the commits between those versions to see whether this was an intentional change or not and find that the difference has been introduced in: https://github.com/pallets/werkzeug/commit/5a7c0dec029d296a5e8131c8db51ce19e28c0c58

The workaround for this is to define both possible URLs for a single endpoint but it's not pretty. Also, the difference might cause problems if you still support Python 3.6 because pip installs older Flask and werkzeug for Python 3.6 where the redirect works fine and newer packages for Python 3.7+ where it does not.

Is this behavior intentional and if so, could you please add it to the documentation?


Discussion:
Upgrade to 2.2.2. Duplicate of #2489
Thanks for the quick response but even with the latest version 2.2.2 together with Flask 2.2.2, the problem is still the same. Could you please reopen this issue?
Is it possible that the mentioned fix fixes only the RegexConverter but not the PathConverter where slashes are also allowed?
@pgjones looks like the path converter swallows the trailing slash before matching finishes?
I think is is that the match isn't possible without a trailing slash (it isn't considered optional for a final part). I'll look into this.

Resolving patch:
diff --git a/src/werkzeug/routing/matcher.py b/src/werkzeug/routing/matcher.py
--- a/src/werkzeug/routing/matcher.py
+++ b/src/werkzeug/routing/matcher.py
@@ -127,7 +127,14 @@ def _match(
                     remaining = []
                 match = re.compile(test_part.content).match(target)
                 if match is not None:
-                    rv = _match(new_state, remaining, values + list(match.groups()))
+                    groups = list(match.groups())
+                    if test_part.suffixed:
+                        # If a part_isolating=False part has a slash suffix, remove the
+                        # suffix from the match and check for the slash redirect next.
+                        suffix = groups.pop()
+                        if suffix == "/":
+                            remaining = [""]
+                    rv = _match(new_state, remaining, values + groups)
                     if rv is not None:
                         return rv
 
diff --git a/src/werkzeug/routing/rules.py b/src/werkzeug/routing/rules.py
--- a/src/werkzeug/routing/rules.py
+++ b/src/werkzeug/routing/rules.py
@@ -36,6 +36,7 @@ class RulePart:
     content: str
     final: bool
     static: bool
+    suffixed: bool
     weight: Weighting
 
 
@@ -631,7 +632,11 @@ def _parse_rule(self, rule: str) -> t.Iterable[RulePart]:
                         argument_weights,
                     )
                     yield RulePart(
-                        content=content, final=final, static=static, weight=weight
+                        content=content,
+                        final=final,
+                        static=static,
+                        suffixed=False,
+                        weight=weight,
                     )
                     content = ""
                     static = True
@@ -641,6 +646,12 @@ def _parse_rule(self, rule: str) -> t.Iterable[RulePart]:
 
             pos = match.end()
 
+        suffixed = False
+        if final and content[-1] == "/":
+            # If a converter is part_isolating=False (matches slashes) and ends with a
+            # slash, augment the regex to support slash redirects.
+            suffixed = True
+            content = content[:-1] + "(?<!/)(/?)"
         if not static:
             content += r"\Z"
         weight = Weighting(
@@ -649,7 +660,17 @@ def _parse_rule(self, rule: str) -> t.Iterable[RulePart]:
             -len(argument_weights),
             argument_weights,
         )
-        yield RulePart(content=content, final=final, static=static, weight=weight)
+        yield RulePart(
+            content=content,
+            final=final,
+            static=static,
+            suffixed=suffixed,
+            weight=weight,
+        )
+        if suffixed:
+            yield RulePart(
+                content="", final=False, static=True, suffixed=False, weight=weight
+            )
 
     def compile(self) -> None:
         """Compiles the regular expression and stores it."""
@@ -665,7 +686,11 @@ def compile(self) -> None:
         if domain_rule == "":
             self._parts = [
                 RulePart(
-                    content="", final=False, static=True, weight=Weighting(0, [], 0, [])
+                    content="",
+                    final=False,
+                    static=True,
+                    suffixed=False,
+                    weight=Weighting(0, [], 0, []),
                 )
             ]
         else:


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -163,6 +163,7 @@ def test_strict_slashes_redirect():
             r.Rule("/bar/", endpoint="get", methods=["GET"]),
             r.Rule("/bar", endpoint="post", methods=["POST"]),
             r.Rule("/foo/", endpoint="foo", methods=["POST"]),
+            r.Rule("/<path:var>/", endpoint="path", methods=["GET"]),
         ]
     )
     adapter = map.bind("example.org", "/")
@@ -170,6 +171,7 @@ def test_strict_slashes_redirect():
     # Check if the actual routes works
     assert adapter.match("/bar/", method="GET") == ("get", {})
     assert adapter.match("/bar", method="POST") == ("post", {})
+    assert adapter.match("/abc/", method="GET") == ("path", {"var": "abc"})
 
     # Check if exceptions are correct
     pytest.raises(r.RequestRedirect, adapter.match, "/bar", method="GET")
@@ -177,6 +179,9 @@ def test_strict_slashes_redirect():
     with pytest.raises(r.RequestRedirect) as error_info:
         adapter.match("/foo", method="POST")
     assert error_info.value.code == 308
+    with pytest.raises(r.RequestRedirect) as error_info:
+        adapter.match("/abc", method="GET")
+    assert error_info.value.new_url == "http://example.org/abc/"
 
     # Check differently defined order
     map = r.Map(
@@ -1434,6 +1439,9 @@ def test_strict_slashes_false():
         [
             r.Rule("/path1", endpoint="leaf_path", strict_slashes=False),
             r.Rule("/path2/", endpoint="branch_path", strict_slashes=False),
+            r.Rule(
+                "/<path:path>", endpoint="leaf_path_converter", strict_slashes=False
+            ),
         ],
     )
 
@@ -1443,6 +1451,14 @@ def test_strict_slashes_false():
     assert adapter.match("/path1/", method="GET") == ("leaf_path", {})
     assert adapter.match("/path2", method="GET") == ("branch_path", {})
     assert adapter.match("/path2/", method="GET") == ("branch_path", {})
+    assert adapter.match("/any", method="GET") == (
+        "leaf_path_converter",
+        {"path": "any"},
+    )
+    assert adapter.match("/any/", method="GET") == (
+        "leaf_path_converter",
+        {"path": "any/"},
+    )
 
 
 def test_invalid_rule():