FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(navigation): decode percent-encoded deep-link parameters by Adebowale-Morakinyo · Pull Request #106 · pythonnative/pythonnative · GitHub

Repository navigation

Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .py  (2) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
40 changes: 38 additions & 2 deletions src/pythonnative/navigation/linking.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,19 @@
and the query string supplies the rest) or a dict with ``path``,
``parse`` (per-param converters), and ``screens`` for a nested
navigator.

Captured path params arrive percent-decoded, once, before any ``parse``
converter runs, so ``u/Ada%20Lovelace`` gives ``"Ada Lovelace"`` and an
encoded ``%2F`` stays inside one value. A ``+`` in a path is a literal
plus, unlike in the query string, where it means a space. Bytes that
don't decode as UTF-8 are left as they arrived. Literal segments are
compared exactly as written.
"""

from __future__ import annotations

from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union
from urllib.parse import parse_qsl, quote, urlencode, urlsplit
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit

from .state import NavigationState, Route

Expand Down Expand Up @@ -56,7 +63,7 @@ def match(self, parts: Sequence[str]) -> Optional[Dict[str, Any]]:
params: Dict[str, Any] = {}
for pattern, actual in zip(self.segments, parts):
if pattern.startswith(":"):
params[pattern[1:]] = actual
params[pattern[1:]] = _decode_segment(actual)
elif pattern != actual:
return None
return params
Expand All @@ -65,6 +72,11 @@ def match(self, parts: Sequence[str]) -> Optional[Dict[str, Any]]:
class LinkingConfig:
"""URL <-> navigation state mapping for a navigator tree.

Captured path params are percent-decoded once, after the path is
split into segments and before ``parse`` converters run; ``+`` in a
path stays a literal plus, and undecodable bytes are left as-is.
Literal segments match exactly as configured.

Args:
prefixes: URL prefixes this app answers to (schemes such as
``"myapp://"`` or web origins). Matching is case-insensitive
Expand Down Expand Up @@ -185,6 +197,30 @@ def url_from_state(self, state: NavigationState) -> Optional[str]:
return f"{url}?{query}" if query else url


def _decode_segment(segment: str) -> str:
"""Percent-decode one captured path segment, or return it unchanged.

Strict decoding with a fallback to the raw segment is deliberately
non-regressive: every valid encoding is fixed, and a segment that
isn't valid UTF-8 (``%FF``, Latin-1 ``caf%E9``) stays exactly what it
was before decoding existed. ``errors="replace"`` would instead turn
such a segment into U+FFFD, destroying information that survives
today. It uses ``unquote`` rather than ``unquote_plus`` because a
``+`` is a literal character in a path.

Args:
segment: One path segment, already split on ``/``, so an encoded
``%2F`` decodes to a slash inside the value.

Returns:
The decoded segment, or ``segment`` itself if it isn't valid UTF-8.
"""
try:
return unquote(segment, errors="strict")
except UnicodeDecodeError:
return segment


def _split_path(path: Optional[str]) -> Tuple[str, ...]:
if not path:
return ()
Expand Down
133 changes: 132 additions & 1 deletion tests/test_navigation.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the navigation package: state, core, navigators, hooks, linking."""

from typing import Any, Dict, List, NotRequired, TypedDict
from typing import Any, Dict, List, NotRequired, Optional, TypedDict

import pytest

Expand Down Expand Up @@ -29,6 +29,7 @@
use_navigation,
use_route,
)
from pythonnative.navigation import linking as navigation_linking
from pythonnative.testing import FakeHost, render, render_hook

# ======================================================================
Expand Down Expand Up @@ -1164,6 +1165,136 @@ def test_linking_url_from_state() -> None:
assert cfg.url_from_state(NavigationState([Route("Nowhere")])) is None


def _profile_linking() -> LinkingConfig:
return LinkingConfig(prefixes=["myapp://"], screens={"Profile": "u/:user"})


def _leaf(state: Optional[NavigationState]) -> Route:
assert state is not None
route = state.current
while route.state is not None:
route = route.state.current
return route


@pytest.mark.parametrize(
"value",
[
pytest.param("Ada Lovelace", id="space"),
pytest.param("Zo\u00eb \u03a9mega \u65e5\u672c", id="unicode"),
pytest.param("c++", id="literal-plus"),
pytest.param("100%", id="percent-sign"),
pytest.param("a/b", id="slash"),
],
)
def test_linking_path_param_round_trips(value: str) -> None:
cfg = _profile_linking()
url = cfg.url_from_state(NavigationState([Route("Profile", {"user": value})]))
assert url is not None

assert _leaf(cfg.state_from_url(url)).params == {"user": value}


def test_linking_every_capture_is_decoded() -> None:
# Two captured segments, each encoded differently, so a decode applied
# to only one of them (say, the last) leaves the other raw.
cfg = LinkingConfig(prefixes=["myapp://"], screens={"Thread": "org/:org/repo/:repo"})
params = {"org": "Ada Lovelace", "repo": "a/b"}
url = cfg.url_from_state(NavigationState([Route("Thread", params)]))
assert url == "myapp://org/Ada%20Lovelace/repo/a%2Fb"

route = _leaf(cfg.state_from_url(url))
assert route.name == "Thread"
assert route.params == params


def test_linking_captures_are_decoded_across_a_nested_navigator() -> None:
# One capture belongs to the outer navigator's path and one to the
# inner screen's; both land on the leaf and both must be decoded.
cfg = LinkingConfig(
prefixes=["myapp://"],
screens={"Org": {"path": "org/:org", "screens": {"Repo": "repo/:repo"}}},
)
params = {"org": "Zo\u00eb", "repo": "c++"}
state = NavigationState([Route("Org", state=NavigationState([Route("Repo", params)]))])
url = cfg.url_from_state(state)
assert url == "myapp://org/Zo%C3%AB/repo/c%2B%2B"

read = cfg.state_from_url(url)
assert read is not None and read.current.name == "Org"
route = _leaf(read)
assert route.name == "Repo"
assert route.params == params


def test_linking_encoded_slash_stays_in_one_value() -> None:
# Decoding happens per segment, after the path is split, so %2F can't
# become a segment boundary and shift the match.
route = _leaf(_profile_linking().state_from_url("myapp://u/a%2Fb"))
assert route.name == "Profile"
assert route.params == {"user": "a/b"}


def test_linking_double_encoded_value_decodes_once() -> None:
assert _leaf(_profile_linking().state_from_url("myapp://u/%252F")).params == {"user": "%2F"}


def test_linking_converter_sees_the_decoded_value() -> None:
route = _leaf(_linking().state_from_url("myapp://item/%34%32"))
assert route.name == "Detail"
assert route.params == {"id": 42}


def test_linking_plus_is_literal_in_the_path_and_a_space_in_the_query() -> None:
cfg = _profile_linking()
route = _leaf(cfg.state_from_url("myapp://u/a+b?q=a+b"))
assert route.params == {"user": "a+b", "q": "a b"}


@pytest.mark.parametrize(
"raw",
[pytest.param("%FF", id="invalid-utf8"), pytest.param("caf%E9", id="latin-1")],
)
def test_linking_undecodable_path_param_is_left_raw(raw: str) -> None:
# Strict decoding with a raw fallback: a value that isn't valid UTF-8
# reads back exactly as it did before decoding existed, rather than
# being replaced with U+FFFD.
assert _leaf(_profile_linking().state_from_url(f"myapp://u/{raw}")).params == {"user": raw}


def test_linking_failing_converter_keeps_the_raw_undecodable_value() -> None:
# int() fails and the converter's except leaves the value in place, so
# what's left must be the original "%FF", not an unrecoverable "\ufffd".
route = _leaf(_linking().state_from_url("myapp://item/%FF"))
assert route.params == {"id": "%FF"}


def test_linking_decode_fallback_only_swallows_unicode_errors(monkeypatch: pytest.MonkeyPatch) -> None:
# The raw-segment fallback is for undecodable bytes only. Anything else
# going wrong inside the decode is a bug and must surface, not quietly
# hand the screen an undecoded value.
def _boom(*args: Any, **kwargs: Any) -> str:
raise RuntimeError("not a decoding problem")

monkeypatch.setattr(navigation_linking, "unquote", _boom)

with pytest.raises(RuntimeError, match="not a decoding problem"):
_profile_linking().state_from_url("myapp://u/ada")


def test_linking_literal_segments_are_not_decoded() -> None:
# Only captured values are decoded. An encoded "new" still falls through
# to the :param route, exactly as before, rather than matching the
# literal route. Decoding every segment would route this to New instead.
cfg = LinkingConfig(prefixes=["myapp://"], screens={"New": "u/new", "Profile": "u/:user"})

encoded = _leaf(cfg.state_from_url("myapp://u/%6Eew"))
assert encoded.name == "Profile"
assert encoded.params == {"user": "new"}

assert _leaf(cfg.state_from_url("myapp://u/new")).name == "New"


def test_container_seeds_from_launch_url_and_follows_later_links(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(linking_module, "_initial_url", "myapp://item/5")
monkeypatch.setattr(linking_module, "_url_listeners", [])
Expand Down
Loading

Back | FazBrowse Home | New Git URL