Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES/13433.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fixed dynamic and static routes with spaces or non-ASCII characters in
fixed path segments being unreachable — the resolver walked the decoded
path but the resource index key and regex pattern used the encoded form
-- by :user:`silentiris`.
1 change: 1 addition & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ Yuval Ofir
Yuvi Panda
Zainab Lawal
Zeal Wierslee
Zhao Peiwen
Zlatan Sičanica
Łukasz Setla
Марк Коренберг
Expand Down
22 changes: 15 additions & 7 deletions aiohttp/web_urldispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,25 +409,31 @@ def __init__(self, path: str, *, name: str | None = None) -> None:
self._orig_path = path
pattern = ""
formatter = ""
canonical = ""
for part in ROUTE_RE.split(path):
match = self.DYN.fullmatch(part)
if match:
pattern += "(?P<{}>{})".format(match.group("var"), self.GOOD)
formatter += "{" + match.group("var") + "}"
canonical += "{" + match.group("var") + "}"
continue

match = self.DYN_WITH_RE.fullmatch(part)
if match:
pattern += "(?P<{var}>{re})".format(**match.groupdict())
formatter += "{" + match.group("var") + "}"
canonical += "{" + match.group("var") + "}"
continue

if "{" in part or "}" in part:
raise ValueError(f"Invalid path '{path}'['{part}']")

part = _requote_path(part)
formatter += part
# Use the decoded part for the regex pattern so it matches
# the decoded path_safe used by the resolver. The formatter
# uses the encoded form so url_for() produces valid URLs.
pattern += re.escape(part)
formatter += _requote_path(part)
canonical += part

try:
compiled = re.compile(pattern)
Expand All @@ -437,17 +443,19 @@ def __init__(self, path: str, *, name: str | None = None) -> None:
assert formatter.startswith("/")
self._pattern = compiled
self._formatter = formatter
self._canonical = canonical

@property
def canonical(self) -> str:
return self._formatter
return self._canonical

def add_prefix(self, prefix: str) -> None:
assert prefix.startswith("/")
assert not prefix.endswith("/")
assert len(prefix) > 1
self._pattern = re.compile(re.escape(prefix) + self._pattern.pattern)
self._formatter = prefix + self._formatter
self._formatter = _requote_path(prefix) + self._formatter
self._canonical = prefix + self._canonical

def _match(self, path: str) -> dict[str, str] | None:
match = self._pattern.fullmatch(path)
Expand Down Expand Up @@ -477,8 +485,8 @@ def __init__(self, prefix: str, *, name: str | None = None) -> None:
assert not prefix or prefix.startswith("/"), prefix
assert prefix in ("", "/") or not prefix.endswith("/"), prefix
super().__init__(name=name)
self._prefix = _requote_path(prefix)
self._prefix2 = self._prefix + "/"
self._prefix = prefix
self._prefix2 = prefix + "/"

@property
def canonical(self) -> str:
Expand Down Expand Up @@ -546,7 +554,7 @@ def url_for( # type: ignore[override]
append_version = self._append_version
filename = str(filename).lstrip("/")

url = URL.build(path=self._prefix, encoded=True)
url = URL.build(path=_requote_path(self._prefix), encoded=True)
# filename is not encoded
url = url / filename

Expand Down
45 changes: 45 additions & 0 deletions tests/test_urldispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,51 @@ def test_route_dynamic_quoting(router: web.UrlDispatcher) -> None:
)


async def test_dynamic_match_with_percent_encoded_fixed_part(
router: web.UrlDispatcher,
) -> None:
"""A dynamic route with a percent-encodable fixed segment is resolvable.

Regression test for #13433: the fixed part was percent-encoded at
registration time and used for both the regex pattern and the resource
index key, but the resolver walks the *decoded* path. The encoded
index key was never probed, so the resource was never a candidate.
"""
handler = make_handler()
router.add_route("GET", "/hello world/{name}", handler)

req = make_mocked_request("GET", "/hello%20world/john")
match_info = await router.resolve(req)
assert {"name": "john"} == match_info


async def test_dynamic_url_for_round_trip_with_percent_encoded_fixed_part(
router: web.UrlDispatcher,
) -> None:
"""url_for() produces a URL that the router can resolve back."""
handler = make_handler()
route = router.add_route("GET", "/hello world/{name}", handler)

url = route.url_for(name="john")
assert str(url) == "/hello%20world/john"

req = make_mocked_request("GET", str(url))
match_info = await router.resolve(req)
assert {"name": "john"} == match_info


async def test_static_match_with_percent_encoded_prefix(
router: web.UrlDispatcher, tmp_path: pathlib.Path
) -> None:
"""A static route with a percent-encodable prefix is resolvable."""
(tmp_path / "file.txt").write_text("hello")
router.add_static("/static files", tmp_path)

req = make_mocked_request("GET", "/static%20files/file.txt")
match_info = await router.resolve(req)
assert match_info["filename"] == "file.txt"


async def test_regular_match_info(router: web.UrlDispatcher) -> None:
handler = make_handler()
router.add_route("GET", "/get/{name}", handler)
Expand Down
Loading