Repository: falconry/falcon

Issue:
falcon.uri.encode_value does not encode the percent character
[`falcon.uri.encode_value`](https://falcon.readthedocs.io/en/latest/api/util.html#falcon.uri.encode_value) says:
> An escaped version of *uri*, where all disallowed characters have been percent-encoded.

However, it seems that the percent character itself is not encoded...

Cf
```python
>>> from urllib.parse import quote
>>> from falcon import uri
>>> quote('%26')
'%2526'
>>> uri.encode('%26')
'%26'
>>> uri.encode_value('%26')
'%26'
>>> uri.decode(uri.encode_value('%26'))
'&'
>>> uri.decode(quote('%26'))
'%26'
```


Discussion:

Resolving patch:
diff --git a/falcon/response.py b/falcon/response.py
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -31,8 +31,8 @@
 from falcon.util import structures
 from falcon.util import TimezoneGMT
 from falcon.util.deprecation import deprecated
-from falcon.util.uri import encode as uri_encode
-from falcon.util.uri import encode_value as uri_encode_value
+from falcon.util.uri import encode_check_escaped as uri_encode
+from falcon.util.uri import encode_value_check_escaped as uri_encode_value
 
 
 GMT_TIMEZONE = TimezoneGMT()
diff --git a/falcon/uri.py b/falcon/uri.py
--- a/falcon/uri.py
+++ b/falcon/uri.py
@@ -15,7 +15,9 @@
 
 from falcon.util.uri import decode
 from falcon.util.uri import encode
+from falcon.util.uri import encode_check_escaped
 from falcon.util.uri import encode_value
+from falcon.util.uri import encode_value_check_escaped
 from falcon.util.uri import parse_host
 from falcon.util.uri import parse_query_string
 from falcon.util.uri import unquote_string
diff --git a/falcon/util/uri.py b/falcon/util/uri.py
--- a/falcon/util/uri.py
+++ b/falcon/util/uri.py
@@ -68,7 +68,7 @@ def _create_char_encoder(allowed_chars):
     return lookup.__getitem__
 
 
-def _create_str_encoder(is_value):
+def _create_str_encoder(is_value, check_is_escaped=False):
 
     allowed_chars = _UNRESERVED if is_value else _ALL_ALLOWED
     allowed_chars_plus_percent = allowed_chars + '%'
@@ -79,9 +79,14 @@ def encoder(uri):
         if not uri.rstrip(allowed_chars):
             return uri
 
-        if not uri.rstrip(allowed_chars_plus_percent):
+        if check_is_escaped and not uri.rstrip(allowed_chars_plus_percent):
             # NOTE(kgriffs): There's a good chance the string has already
             # been escaped. Do one more check to increase our certainty.
+            # NOTE(minesja): Per issue #1872, there's only certain situations
+            # in which we should check again (ex. location, content_location,
+            # append_link).In all other cases we should allow characters that
+            # could appear escaped to still be encoded (ex. '%' would be encoded
+            # as '%25).
             tokens = uri.split('%')
             for token in tokens[1:]:
                 hex_octet = token[:2]
@@ -139,7 +144,6 @@ def encoder(uri):
 
 """
 
-
 encode_value = _create_str_encoder(True)
 encode_value.name = 'encode_value'
 encode_value.__doc__ = """Encodes a value string according to RFC 3986.
@@ -168,6 +172,59 @@ def encoder(uri):
 
 """
 
+encode_check_escaped = _create_str_encoder(False, True)
+encode_check_escaped.name = 'encode_check_escaped'
+encode_check_escaped.__doc__ = """Encodes a full or relative URI according to RFC 3986.
+
+RFC 3986 defines a set of "unreserved" characters as well as a
+set of "reserved" characters used as delimiters. This function escapes
+all other "disallowed" characters by percent-encoding them unless they
+appear to have been previously encoded. For example, '%26' will not be
+encoded again as it follows the format of an encoded value.
+
+Note:
+    This utility is faster in the average case than the similar
+    `quote` function found in ``urlib``. It also strives to be easier
+    to use by assuming a sensible default of allowed characters.
+
+Args:
+    uri (str): URI or part of a URI to encode.
+
+Returns:
+    str: An escaped version of `uri`, where all disallowed characters
+    have been percent-encoded.
+
+"""
+
+encode_value_check_escaped = _create_str_encoder(True, True)
+encode_value_check_escaped.name = 'encode_value_check_escaped'
+encode_value_check_escaped.__doc__ = """Encodes a value string according to RFC 3986.
+
+RFC 3986 defines a set of "unreserved" characters as well as a
+set of "reserved" characters used as delimiters. Disallowed characters
+are percent-encoded in a way that models ``urllib.parse.quote(safe="~")``
+unless they appear to have been previously encoded. For example, '%26'
+will not be encoded again as it follows the format of an encoded value.
+
+All reserved characters are lumped together into a single set of
+"delimiters", and everything in that set is escaped.
+
+Note:
+    This utility is faster in the average case than the similar
+    `quote` function found in ``urlib``. It also strives to be easier
+    to use by assuming a sensible default of allowed characters.
+
+Args:
+    uri (str): URI fragment to encode. It is assumed not to cross delimiter
+        boundaries, and so any reserved URI delimiter characters
+        included in it will be percent-encoded.
+
+Returns:
+    str: An escaped version of `uri`, where all disallowed characters
+    have been percent-encoded.
+
+"""
+
 
 def _join_tokens_bytearray(tokens):
     decoded_uri = bytearray(tokens[0])
@@ -492,6 +549,8 @@ def unquote_string(quoted):
     'decode',
     'encode',
     'encode_value',
+    'encode_check_escaped',
+    'encode_value_check_escaped',
     'parse_host',
     'parse_query_string',
     'unquote_string',


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -239,33 +239,37 @@ def test_uri_encode(self):
                     '?limit=3&e%C3%A7ho=true')
         assert uri.encode(url) == expected
 
+        # NOTE(minesja): Addresses #1872
+        assert uri.encode('%26') == '%2526'
+        assert uri.decode(uri.encode('%26')) == '%26'
+
     def test_uri_encode_double(self):
         url = 'http://example.com/v1/fiz bit/messages'
         expected = 'http://example.com/v1/fiz%20bit/messages'
-        assert uri.encode(uri.encode(url)) == expected
+        assert uri.encode_check_escaped(uri.encode_check_escaped(url)) == expected
 
         url = 'http://example.com/v1/fizbit/messages?limit=3&e\u00e7ho=true'
         expected = ('http://example.com/v1/fizbit/messages'
                     '?limit=3&e%C3%A7ho=true')
-        assert uri.encode(uri.encode(url)) == expected
+        assert uri.encode_check_escaped(uri.encode_check_escaped(url)) == expected
 
         url = 'http://example.com/v1/fiz%bit/mess%ages/%'
         expected = 'http://example.com/v1/fiz%25bit/mess%25ages/%25'
-        assert uri.encode(uri.encode(url)) == expected
+        assert uri.encode_check_escaped(uri.encode_check_escaped(url)) == expected
 
         url = 'http://example.com/%%'
         expected = 'http://example.com/%25%25'
-        assert uri.encode(uri.encode(url)) == expected
+        assert uri.encode_check_escaped(uri.encode_check_escaped(url)) == expected
 
         # NOTE(kgriffs): Specific example cited in GH issue
         url = 'http://something?redirect_uri=http%3A%2F%2Fsite'
-        assert uri.encode(url) == url
+        assert uri.encode_check_escaped(url) == url
 
         hex_digits = 'abcdefABCDEF0123456789'
         for c1 in hex_digits:
             for c2 in hex_digits:
                 url = 'http://example.com/%' + c1 + c2
-                encoded = uri.encode(uri.encode(url))
+                encoded = uri.encode_check_escaped(uri.encode_check_escaped(url))
                 assert encoded == url
 
     def test_uri_encode_value(self):
@@ -277,6 +281,10 @@ def test_uri_encode_value(self):
         assert uri.encode_value('ab/cd') == 'ab%2Fcd'
         assert uri.encode_value('ab+cd=42,9') == 'ab%2Bcd%3D42%2C9'
 
+        # NOTE(minesja): Addresses #1872
+        assert uri.encode_value('%26') == '%2526'
+        assert uri.decode(uri.encode_value('%26')) == '%26'
+
     def test_uri_decode(self, decode_approach):
         assert uri.decode('abcd') == 'abcd'
         assert uri.decode('ab%20cd') == 'ab cd'