From d456f7f49e8af6808dbce29713e870d238280b08 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Thu, 16 Jul 2026 05:14:20 +0200 Subject: [PATCH] Percent-encode literal template text per RFC 6570 Section 3.1 Literal characters outside of expressions were copied verbatim during expansion, so a non-ASCII literal such as the accented e in "cafe/{var}" was emitted raw instead of percent-encoded. RFC 6570 Section 3.1 requires literal text that is not URI-legal to be encoded as its UTF-8 percent-encoded triplets. _expand now encodes each literal run around the expanded expressions instead of relying on a single re.sub pass, which cannot do this without re-encoding the already-encoded expansion output. Existing percent-encoded triplets in the literal are preserved. This matches the official uritemplate-test "Literal Encoding" cases, added here to the bundled fixtures. --- HISTORY.rst | 7 +++++ tests/fixtures/extended-tests.json | 11 +++++++ tests/test_from_fixtures.py | 4 +++ tests/test_uritemplate.py | 46 ++++++++++++++++++++++++++++++ uritemplate/template.py | 19 ++++++++---- uritemplate/variable.py | 22 ++++++++++++++ 6 files changed, 103 insertions(+), 6 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index b5aaf87..6251721 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,13 @@ Changelog - uritemplate ======================= +Unreleased +---------- + +- Percent-encode disallowed characters in template literal text as required + by RFC 6570 Section 3.1 (e.g. the literal in ``café/{x}`` now expands to + ``caf%C3%A9/...``). + 4.2.0 - 2025-06-01 ------------------ diff --git a/tests/fixtures/extended-tests.json b/tests/fixtures/extended-tests.json index df27d95..e38620c 100644 --- a/tests/fixtures/extended-tests.json +++ b/tests/fixtures/extended-tests.json @@ -145,5 +145,16 @@ ["{#keys}", "#key1,val1%2F,key2,val2%2F"], ["{keys}", "key1,val1%252F,key2,val2%252F"] ] + }, + "Additional Examples 8: Literal Encoding":{ + "level":4, + "variables":{ + "var" : "value" + }, + "testcases":[ + [ "café/{var}" , "caf%C3%A9/value" ], + [ "x%20y/{var}" , "x%20y/value" ], + [ "x%20y{var}z%20w" , "x%20yvaluez%20w" ] + ] } } diff --git a/tests/test_from_fixtures.py b/tests/test_from_fixtures.py index d497510..8411a5d 100644 --- a/tests/test_from_fixtures.py +++ b/tests/test_from_fixtures.py @@ -146,3 +146,7 @@ def test_additional_examples_5(self) -> None: def test_additional_examples_6(self) -> None: """Check Additional Examples 6.""" self._test("Additional Examples 6: Reserved Expansion") + + def test_additional_examples_8(self) -> None: + """Check Additional Examples 8.""" + self._test("Additional Examples 8: Literal Encoding") diff --git a/tests/test_uritemplate.py b/tests/test_uritemplate.py index dfe19f5..325c405 100644 --- a/tests/test_uritemplate.py +++ b/tests/test_uritemplate.py @@ -450,6 +450,45 @@ def test_expand(self) -> None: t.expand({"repo": "github3.py"}, user="sigmavirus24"), expanded ) + def test_literal_encoding(self) -> None: + """Literal text is percent-encoded per RFC 6570 Section 3.1.""" + # Official uri-templates/uritemplate-test "Literal Encoding" cases. + self.assertEqual( + URITemplate("café/{var}").expand(var="value"), "caf%C3%A9/value" + ) + self.assertEqual( + URITemplate("x%20y/{var}").expand(var="value"), "x%20y/value" + ) + self.assertEqual( + URITemplate("x%20y{var}z%20w").expand(var="value"), + "x%20yvaluez%20w", + ) + # Multi-octet literal and a literal with no expression at all. + self.assertEqual(URITemplate("€/{v}").expand(v="1"), "%E2%82%AC/1") + self.assertEqual(URITemplate("café").expand(), "caf%C3%A9") + # Space and a bare percent sign are not URI-legal and get encoded. + self.assertEqual(URITemplate("a b/{v}").expand(v="1"), "a%20b/1") + self.assertEqual(URITemplate("100%/{v}").expand(v="1"), "100%25/1") + # Reserved characters and existing triples are kept verbatim. + self.assertEqual(URITemplate("a,b:c/{v}").expand(v="1"), "a,b:c/1") + self.assertEqual(URITemplate("%C3%A9/{v}").expand(v="1"), "%C3%A9/1") + + def test_literal_encoding_across_operators(self) -> None: + """Literal runs are encoded around every expression operator.""" + for expr in ("{v}", "{+v}", "{#v}", "{/v}", "{.v}", "{?v}", "{&v}"): + expanded = URITemplate("café" + expr).expand(v="x") + self.assertTrue(expanded.startswith("caf%C3%A9"), expanded) + # The expanded value is not double-encoded by the literal pass. + self.assertEqual( + URITemplate("café{?q}").expand(q="thé"), "caf%C3%A9?q=th%C3%A9" + ) + + def test_literal_encoding_idempotent_on_partial(self) -> None: + """Partial expansion keeps literals encoded exactly once.""" + template = URITemplate("café{?q}").partial() + self.assertEqual(str(template), "caf%C3%A9{?q}") + self.assertEqual(template.expand(q="thé"), "caf%C3%A9?q=th%C3%A9") + def test_str_repr(self) -> None: uri = "https://api.github.com{/endpoint}" t = URITemplate(uri) @@ -612,6 +651,13 @@ def test_list_of_tuples_test(self) -> None: d = dict(a_list) self.assertEqual(variable.dict_test(d), True) + def test_encode_literal(self) -> None: + self.assertEqual(variable.encode_literal("café"), "caf%C3%A9") + self.assertEqual(variable.encode_literal("x%20y"), "x%20y") + self.assertEqual(variable.encode_literal("a,b:c"), "a,b:c") + self.assertEqual(variable.encode_literal("100%"), "100%25") + self.assertEqual(variable.encode_literal(""), "") + class TestAPI(unittest.TestCase): uri = "https://api.github.com{/endpoint}" diff --git a/uritemplate/template.py b/uritemplate/template.py index 7582f2f..087e817 100644 --- a/uritemplate/template.py +++ b/uritemplate/template.py @@ -99,13 +99,9 @@ def __hash__(self) -> int: def _expand( self, var_dict: variable.VariableValueMapping, replace: bool ) -> str: - if not self.variables: - return self.uri - - expansion = var_dict expanded: t.Dict[str, str] = {} for v in self.variables: - expanded.update(v.expand(expansion)) + expanded.update(v.expand(var_dict)) def replace_all(match: "re.Match[str]") -> str: return expanded.get(match.groups()[0], "") @@ -117,7 +113,18 @@ def replace_partial(match: "re.Match[str]") -> str: replace_func = replace_partial if replace else replace_all - return template_re.sub(replace_func, self.uri) + # Percent-encode literal runs (RFC 6570 3.1) around each expanded + # expression; a single-pass re.sub would re-encode the expansion + # output, so encode the literals and expand the expressions separately. + result: t.List[str] = [] + index = 0 + for match in template_re.finditer(self.uri): + start, end = match.start(), match.end() + result.append(variable.encode_literal(self.uri[index:start])) + result.append(replace_func(match)) + index = end + result.append(variable.encode_literal(self.uri[index:])) + return "".join(result) def expand( self, diff --git a/uritemplate/variable.py b/uritemplate/variable.py index 1f7993c..7ed99af 100644 --- a/uritemplate/variable.py +++ b/uritemplate/variable.py @@ -17,6 +17,7 @@ import collections.abc import enum +import re import string import typing as t import urllib.parse @@ -39,6 +40,8 @@ _SUB_DELIMS: t.Final[str] = "!$&'()*+,;=" _RESERVED_CHARACTERS: t.Final[str] = f"{_GEN_DELIMS}{_SUB_DELIMS}" +_pct_encoded = re.compile("%[0-9A-Fa-f]{2}") + class Operator(enum.Enum): # Section 2.2. Expressions @@ -562,3 +565,22 @@ def quote(value: t.Any, safe: str) -> str: if not isinstance(value, (str, bytes)): value = str(value) return urllib.parse.quote(_encode(value), safe) + + +def encode_literal(literal: str) -> str: + """Percent-encode template literal text per RFC 6570 Section 3.1. + + Characters allowed in a URI (unreserved, reserved, or already + percent-encoded) are copied verbatim; any other character is encoded as + its UTF-8 percent-encoded octets. Existing ``%XX`` triplets are preserved + rather than being re-encoded. + """ + encoded = [] + index = 0 + for match in _pct_encoded.finditer(literal): + start, end = match.start(), match.end() + encoded.append(quote(literal[index:start], _RESERVED_CHARACTERS)) + encoded.append(match.group()) + index = end + encoded.append(quote(literal[index:], _RESERVED_CHARACTERS)) + return "".join(encoded)