Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGES/1653.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fixed the :attr:`~yarl.URL.host` property incorrectly returning the
percent-encoded zone ID separator ``%25`` instead of decoding it to ``%``
for IPv6 Zone ID URLs (e.g. ``http://[fe80::1%251]/`` now correctly exposes
``.host`` as ``fe80::1%1`` per :rfc:`6874`)
-- by :user:`rodrigobnogueira`.
46 changes: 46 additions & 0 deletions tests/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,52 @@ def test_ipv4_zone() -> None:
assert url.raw_host == SplitResult(*url._val).hostname


def test_ipv6_zone_rfc6874() -> None:
url = URL("http://[fe80::1%251]/")
assert url.raw_host == "fe80::1%251"
assert url.host == "fe80::1%1"
assert url.host_subcomponent == "[fe80::1%251]"
assert url.host_port_subcomponent == "[fe80::1%251]"
assert url.authority == "fe80::1%1:80"
assert url.human_repr() == "http://[fe80::1%1]/"
assert str(url) == "http://[fe80::1%251]/"
assert URL(str(url)) == url


def test_ipv6_zone_rfc6874_named_zone() -> None:
url = URL("http://[fe80::1%25eth0]/")
assert url.raw_host == "fe80::1%25eth0"
assert url.host == "fe80::1%eth0"
assert url.host_subcomponent == "[fe80::1%25eth0]"
assert url.authority == "fe80::1%eth0:80"
assert url.human_repr() == "http://[fe80::1%eth0]/"
assert str(url) == "http://[fe80::1%25eth0]/"
assert URL(str(url)) == url


def test_ipv6_zone_rfc6874_with_port() -> None:
url = URL("http://[fe80::1%251]:8080/")
assert url.raw_host == "fe80::1%251"
assert url.host == "fe80::1%1"
assert url.port == 8080
assert url.host_port_subcomponent == "[fe80::1%251]:8080"
assert url.authority == "fe80::1%1:8080"
assert str(url) == "http://[fe80::1%251]:8080/"


def test_ipv6_zone_rfc6874_pct_encoded_inside_zone() -> None:
# Multiple ``%25`` sequences: ``_encode_host`` partitions on the
# first one (the RFC 6874 separator); ``.host`` then decodes every
# ``%25`` in the result, including the one that originally encoded
# a ``%`` inside the zone identifier.
url = URL("http://[fe80::1%25foo%252fbar]/")
assert url.raw_host == "fe80::1%25foo%252fbar"
assert url.host == "fe80::1%foo%2fbar"
assert url.host_subcomponent == "[fe80::1%25foo%252fbar]"
assert str(url) == "http://[fe80::1%25foo%252fbar]/"
assert URL(str(url)) == url


def test_port_for_explicit_port() -> None:
url = URL("http://example.com:8888")
assert 8888 == url.port
Expand Down
20 changes: 16 additions & 4 deletions yarl/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -779,11 +779,19 @@ def host(self) -> str | None:

None for relative URLs.

For IPv6 hosts that carry an RFC 6874 zone identifier, the
``%25`` zone separator is decoded back to ``%``; the encoded
form is still available via :attr:`raw_host` and
:attr:`host_subcomponent`.

"""
if (raw := self.raw_host) is None:
return None
if raw and raw[-1].isdigit() or ":" in raw:
# IP addresses are never IDNA encoded
# IP addresses are never IDNA encoded; only the RFC 6874
# zone separator needs to be decoded.
if "%25" in raw:
return raw.replace("%25", "%")
return raw
return _idna_decode(raw)

Expand Down Expand Up @@ -1581,7 +1589,11 @@ def _encode_host(host: str, validate_host: bool) -> str:
# If the host ends with a digit or contains a colon, its likely
# an IP address.
if host and (host[-1].isdigit() or ":" in host):
raw_ip, sep, zone = host.partition("%")
# RFC 6874 spells the IPv6 zone separator as the percent-encoded
# ``%25``; bare ``%`` is still accepted so that hosts constructed
# programmatically (e.g. ``with_host("fe80::1%1")``) keep working.
part = "%25" if "%25" in host else "%"
raw_ip, sep, zone = host.partition(part)
# If it looks like an IP, we check with _ip_compressed_version
# and fall-through if its not an IP address. This is a performance
# optimization to avoid parsing IP addresses as much as possible
Expand Down Expand Up @@ -1609,8 +1621,8 @@ def _encode_host(host: str, validate_host: bool) -> str:
# LRU to keep the cache size small
host = ip.compressed
if ip.version == 6:
return f"[{host}%{zone}]" if sep else f"[{host}]"
return f"{host}%{zone}" if sep else host
return f"[{host}{sep}{zone}]" if sep else f"[{host}]"
return f"{host}{sep}{zone}" if sep else host

# IDNA encoding is slow, skip it for ASCII-only strings
if host.isascii():
Expand Down
Loading