Skip to content
Open
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/1751.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fixed the Cython quoting backend retaining a lone surrogate code
point when it was the only character forcing a rewrite; the
pure-Python backend already dropped lone surrogates, so both now
drop them and produce identical output
-- by :user:`rodrigobnogueira`.
27 changes: 27 additions & 0 deletions tests/test_quoting.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,33 @@ def test_quote_ignore_broken_unicode(quoter: type[_Quoter]) -> None:
assert quoter()(s) == s


def test_quote_drops_lone_surrogate(quoter: type[_Quoter]) -> None:
# A lone Unicode surrogate (0xD800..0xDFFF) cannot be UTF-8 encoded, so
# both quoters must drop it. When the surrogate is the only non-safe
# character, the C quoter used to leave it in the output; the Python
# quoter has always dropped it via errors="ignore". Keep every other
# character safe ASCII so the surrogate is the sole driver of any change;
# an unsafe character such as "/" would mask the bug by forcing a rewrite.
q = quoter()
assert q("\ud800") == ""
assert q("\udfff") == ""
assert q("a\ud800b") == "ab"
# The result must be pure ASCII, never a retained surrogate.
assert q("a\ud800b").encode("ascii") == b"ab"


def test_quote_drops_surrogate_with_encoded_char(quoter: type[_Quoter]) -> None:
# Parity guard for the mixed case: a lone surrogate alongside a character
# that does require percent-encoding ("é" -> %C3%A9) must drop the
# surrogate while still encoding the real character, on both backends.
# This does not isolate the C bug ("é" forces a rewrite on its own, so it
# passes even unpatched); test_quote_drops_lone_surrogate is the guard.
q = quoter()
assert q("é\ud800") == "%C3%A9"
assert q("\ud800é") == "%C3%A9"
assert q("é\ud800é") == "%C3%A9%C3%A9"


def test_unquote_to_bytes(unquoter: type[_Unquoter]) -> None:
assert unquoter()("abc%20def") == "abc def"
assert unquoter()("") == ""
Expand Down
8 changes: 8 additions & 0 deletions tests/test_url_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,3 +771,11 @@ def test_relative_path_with_underscore_prefix_not_affected(self) -> None:
assert u.scheme == ""
assert ":" in str(u)
assert "%3A" not in str(u)


def test_raw_path_drops_lone_surrogate() -> None:
"""A lone surrogate in the path is dropped; raw_path stays ASCII."""
url = URL("http://example.com/\ud800path")
assert url.raw_path == "/path"
# raw_path is always percent-encoded ASCII; a retained surrogate is a bug.
assert url.raw_path.encode("ascii") == b"/path"
3 changes: 2 additions & 1 deletion yarl/_quoting_c.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ cdef inline int _write_utf8(Writer* writer, Py_UCS4 symbol):
return -1
return _write_pct(writer, <uint8_t>(0x80 | (utf & 0x3f)), True)
elif 0xD800 <= utf <= 0xDFFF:
# surogate pair, ignored
# lone surrogate, dropped to match str.encode("utf8", errors="ignore")
writer.changed = True
return 0
elif utf < 0x10000:
if _write_pct(writer, <uint8_t>(0xe0 | (utf >> 12)), True) < 0:
Expand Down
Loading