Cleanup URL percent-encoding behavior. (#2990)

* Replace path_query_fragment encoding tests

* Remove replaced test cases

* Fix test case to use correct hex sequence for 'abc'

* Fix 'quote' behaviour so we don't double-escape.

* Add '/' to safe chars in query strings

* Update docstring

* Linting

* Update outdated comment.

* Revert unrelated change

---------

Co-authored-by: Kar Petrosyan <92274156+karpetrosyan@users.noreply.github.com>
This commit is contained in:
Tom Christie 2023-12-15 11:35:16 +00:00 committed by GitHub
parent 3b9060ee11
commit a11fc3849b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 113 additions and 42 deletions

View File

@ -260,10 +260,8 @@ def urlparse(url: str = "", **kwargs: typing.Optional[str]) -> ParseResult:
# For 'path' we need to drop ? and # from the GEN_DELIMS set.
parsed_path: str = quote(path, safe=SUB_DELIMS + ":/[]@")
# For 'query' we need to drop '#' from the GEN_DELIMS set.
# We also exclude '/' because it is more robust to replace it with a percent
# encoding despite it not being a requirement of the spec.
parsed_query: typing.Optional[str] = (
None if query is None else quote(query, safe=SUB_DELIMS + ":?[]@")
None if query is None else quote(query, safe=SUB_DELIMS + ":/?[]@")
)
# For 'fragment' we can include all of the GEN_DELIMS set.
parsed_fragment: typing.Optional[str] = (
@ -432,13 +430,12 @@ def is_safe(string: str, safe: str = "/") -> bool:
if char not in NON_ESCAPED_CHARS:
return False
# Any '%' characters must be valid '%xx' escape sequences.
return string.count("%") == len(PERCENT_ENCODED_REGEX.findall(string))
return True
def quote(string: str, safe: str = "/") -> str:
def percent_encoded(string: str, safe: str = "/") -> str:
"""
Use percent-encoding to quote a string if required.
Use percent-encoding to quote a string.
"""
if is_safe(string, safe=safe):
return string
@ -449,6 +446,39 @@ def quote(string: str, safe: str = "/") -> str:
)
def quote(string: str, safe: str = "/") -> str:
"""
Use percent-encoding to quote a string, omitting existing '%xx' escape sequences.
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
* `string`: The string to be percent-escaped.
* `safe`: A string containing characters that may be treated as safe, and do not
need to be escaped. Unreserved characters are always treated as safe.
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.3
"""
parts = []
current_position = 0
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
start_position, end_position = match.start(), match.end()
matched_text = match.group(0)
# Add any text up to the '%xx' escape sequence.
if start_position != current_position:
leading_text = string[current_position:start_position]
parts.append(percent_encoded(leading_text, safe=safe))
# Add the '%xx' escape sequence.
parts.append(matched_text)
current_position = end_position
# Add any text after the final '%xx' escape sequence.
if current_position != len(string):
trailing_text = string[current_position:]
parts.append(percent_encoded(trailing_text, safe=safe))
return "".join(parts)
def urlencode(items: typing.List[typing.Tuple[str, str]]) -> str:
"""
We can use a much simpler version of the stdlib urlencode here because
@ -464,4 +494,9 @@ def urlencode(items: typing.List[typing.Tuple[str, str]]) -> str:
- https://github.com/encode/httpx/issues/2721
- https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode
"""
return "&".join([quote(k, safe="") + "=" + quote(v, safe="") for k, v in items])
return "&".join(
[
percent_encoded(k, safe="") + "=" + percent_encoded(v, safe="")
for k, v in items
]
)

View File

@ -70,36 +70,79 @@ def test_url_no_authority():
# Tests for percent encoding across path, query, and fragment...
def test_path_percent_encoding():
# Test percent encoding for SUB_DELIMS ALPHA NUM and allowable GEN_DELIMS
url = httpx.URL("https://example.com/!$&'()*+,;= abc ABC 123 :/[]@")
assert url.raw_path == b"/!$&'()*+,;=%20abc%20ABC%20123%20:/[]@"
assert url.path == "/!$&'()*+,;= abc ABC 123 :/[]@"
assert url.query == b""
assert url.fragment == ""
def test_query_percent_encoding():
# Test percent encoding for SUB_DELIMS ALPHA NUM and allowable GEN_DELIMS
url = httpx.URL("https://example.com/?!$&'()*+,;= abc ABC 123 :/[]@" + "?")
assert url.raw_path == b"/?!$&'()*+,;=%20abc%20ABC%20123%20:%2F[]@?"
assert url.path == "/"
assert url.query == b"!$&'()*+,;=%20abc%20ABC%20123%20:%2F[]@?"
assert url.fragment == ""
def test_fragment_percent_encoding():
# Test percent encoding for SUB_DELIMS ALPHA NUM and allowable GEN_DELIMS
url = httpx.URL("https://example.com/#!$&'()*+,;= abc ABC 123 :/[]@" + "?#")
assert url.raw_path == b"/"
assert url.path == "/"
assert url.query == b""
assert url.fragment == "!$&'()*+,;= abc ABC 123 :/[]@?#"
@pytest.mark.parametrize(
"url,raw_path,path,query,fragment",
[
# URL with unescaped chars in path.
(
"https://example.com/!$&'()*+,;= abc ABC 123 :/[]@",
b"/!$&'()*+,;=%20abc%20ABC%20123%20:/[]@",
"/!$&'()*+,;= abc ABC 123 :/[]@",
b"",
"",
),
# URL with escaped chars in path.
(
"https://example.com/!$&'()*+,;=%20abc%20ABC%20123%20:/[]@",
b"/!$&'()*+,;=%20abc%20ABC%20123%20:/[]@",
"/!$&'()*+,;= abc ABC 123 :/[]@",
b"",
"",
),
# URL with mix of unescaped and escaped chars in path.
# WARNING: This has the incorrect behaviour, adding the test as an interim step.
(
"https://example.com/ %61%62%63",
b"/%20%61%62%63",
"/ abc",
b"",
"",
),
# URL with unescaped chars in query.
(
"https://example.com/?!$&'()*+,;= abc ABC 123 :/[]@?",
b"/?!$&'()*+,;=%20abc%20ABC%20123%20:/[]@?",
"/",
b"!$&'()*+,;=%20abc%20ABC%20123%20:/[]@?",
"",
),
# URL with escaped chars in query.
(
"https://example.com/?!$&%27()*+,;=%20abc%20ABC%20123%20:%2F[]@?",
b"/?!$&%27()*+,;=%20abc%20ABC%20123%20:%2F[]@?",
"/",
b"!$&%27()*+,;=%20abc%20ABC%20123%20:%2F[]@?",
"",
),
# URL with mix of unescaped and escaped chars in query.
(
"https://example.com/?%20%97%98%99",
b"/?%20%97%98%99",
"/",
b"%20%97%98%99",
"",
),
# URL encoding characters in fragment.
(
"https://example.com/#!$&'()*+,;= abc ABC 123 :/[]@?#",
b"/",
"/",
b"",
"!$&'()*+,;= abc ABC 123 :/[]@?#",
),
],
)
def test_path_query_fragment(url, raw_path, path, query, fragment):
url = httpx.URL(url)
assert url.raw_path == raw_path
assert url.path == path
assert url.query == query
assert url.fragment == fragment
def test_url_query_encoding():
"""
URL query parameters should use '%20' to encoding spaces,
URL query parameters should use '%20' for encoding spaces,
and should treat '/' as a safe character. This behaviour differs
across clients, but we're matching browser behaviour here.
@ -107,19 +150,12 @@ def test_url_query_encoding():
and https://github.com/encode/httpx/discussions/2460
"""
url = httpx.URL("https://www.example.com/?a=b c&d=e/f")
assert url.raw_path == b"/?a=b%20c&d=e%2Ff"
assert url.raw_path == b"/?a=b%20c&d=e/f"
url = httpx.URL("https://www.example.com/", params={"a": "b c", "d": "e/f"})
assert url.raw_path == b"/?a=b%20c&d=e%2Ff"
def test_url_with_url_encoded_path():
url = httpx.URL("https://www.example.com/path%20to%20somewhere")
assert url.path == "/path to somewhere"
assert url.query == b""
assert url.raw_path == b"/path%20to%20somewhere"
def test_url_params():
url = httpx.URL("https://example.org:123/path/to/somewhere", params={"a": "123"})
assert str(url) == "https://example.org:123/path/to/somewhere?a=123"