Repository: falconry/falcon

Issue:
uri.parse_query_string() incorrectly parses an empty string
cf
```python
>>> from urllib import parse
>>> from falcon.util import uri
>>> parse.parse_qs('', keep_blank_values=True)
{}
>>> uri.parse_query_string('', keep_blank=True)
{'': ''}
```

The good ol' _an empty string splits to a list with one empty string item_ trick is biting us here as I understand.


Discussion:
Hi :wave:,

Thanks for using Falcon. The large amount of time and effort needed to
maintain the project and develop new features is not sustainable without
the generous financial support of community members like you.

Please consider helping us secure the future of the Falcon framework with a
one-time or recurring donation.

Thank you for your support!

Note that I left a `TODO` in `falcon/media/urlencoded.py`, remove short-circuiting to `{}` when fixing this.

Resolving patch:
diff --git a/falcon/media/urlencoded.py b/falcon/media/urlencoded.py
--- a/falcon/media/urlencoded.py
+++ b/falcon/media/urlencoded.py
@@ -35,12 +35,6 @@ def deserialize(self, stream, content_type, content_length):
         # catch malicious input.
         body = body.decode('ascii')
 
-        # TODO(vytas): We are not short-circuiting here for performance (as
-        #   empty URL-encoded payload should not be a common case), but to work
-        #   around #1600
-        if not body:
-            return {}
-
         return parse_query_string(body,
                                   keep_blank=self.keep_blank,
                                   csv=self.csv)
diff --git a/falcon/util/uri.py b/falcon/util/uri.py
--- a/falcon/util/uri.py
+++ b/falcon/util/uri.py
@@ -24,6 +24,16 @@
 
 """
 
+try:
+    from falcon.cyutil.uri import (
+        decode as _cy_decode,
+        parse_query_string as _cy_parse_query_string,
+    )
+except ImportError:
+    _cy_decode = None
+    _cy_parse_query_string = None
+
+
 # NOTE(kgriffs): See also RFC 3986
 _UNRESERVED = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ'
                'abcdefghijklmnopqrstuvwxyz'
@@ -264,7 +274,7 @@ def parse_query_string(query_string, keep_blank=False, csv=True):
     # both short and long query strings. Tested on CPython 3.4.
     for field in query_string.split('&'):
         k, _, v = field.partition('=')
-        if not (v or keep_blank):
+        if not v and (not keep_blank or not k):
             continue
 
         # Note(steffgrez): Falcon first decode name parameter for handle
@@ -394,3 +404,19 @@ def unquote_string(quoted):
     else:
         return '\\'.join([q.replace('\\', '')
                           for q in tmp_quoted.split(r'\\')])
+
+
+# TODO(vytas): Restructure this in favour of a cleaner way to hoist the pure
+# Cython functions into this module.
+decode = _cy_decode or decode  # NOQA
+parse_query_string = _cy_parse_query_string or parse_query_string  # NOQA
+
+
+__all__ = [
+    'decode',
+    'encode',
+    'encode_value',
+    'parse_host',
+    'parse_query_string',
+    'unquote_string',
+]
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -39,31 +39,39 @@
         CYTHON = False
 
 if CYTHON:
-    def list_modules(dirname):
-        filenames = glob.glob(path.join(dirname, '*.py'))
+    def list_modules(dirname, pattern):
+        filenames = glob.glob(path.join(dirname, pattern))
 
         module_names = []
         for name in filenames:
             module, ext = path.splitext(path.basename(name))
             if module != '__init__':
-                module_names.append(module)
+                module_names.append((module, ext))
 
         return module_names
 
     package_names = [
         'falcon',
+        'falcon.cyutil',
         'falcon.media',
         'falcon.routing',
         'falcon.util',
         'falcon.vendor.mimeparse',
     ]
+
+    cython_package_names = frozenset([
+        'falcon.cyutil',
+    ])
+
     ext_modules = [
         Extension(
             package + '.' + module,
-            [path.join(*(package.split('.') + [module + '.py']))]
+            [path.join(*(package.split('.') + [module + ext]))]
         )
         for package in package_names
-        for module in list_modules(path.join(MYDIR, *package.split('.')))
+        for module, ext in list_modules(
+            path.join(MYDIR, *package.split('.')),
+            ('*.pyx' if package in cython_package_names else '*.py'))
     ]
 
     cmdclass = {'build_ext': build_ext}


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -2,6 +2,7 @@
 
 from datetime import datetime
 import functools
+import itertools
 import random
 from urllib.parse import quote, unquote_plus
 
@@ -201,6 +202,15 @@ def test_uri_decode(self):
             'http://example.com?x=ab%2Bcd%3D42%2C9'
         ) == 'http://example.com?x=ab+cd=42,9'
 
+    @pytest.mark.parametrize('encoded,expected', [
+        ('+%80', ' �'),
+        ('+++%FF+++', '   �   '),  # impossible byte
+        ('%fc%83%bf%bf%bf%bf', '������'),  # overlong sequence
+        ('%ed%ae%80%ed%b0%80', '������'),  # paired UTF-16 surrogates
+    ])
+    def test_uri_decode_replace_bad_unicode(self, encoded, expected):
+        assert uri.decode(encoded) == expected
+
     def test_uri_decode_unquote_plus(self):
         assert uri.decode('/disk/lost+found/fd0') == '/disk/lost found/fd0'
         assert uri.decode('/disk/lost+found/fd0', unquote_plus=True) == (
@@ -250,7 +260,7 @@ def test_unquote_string(self):
         assert uri.unquote_string('"partial-quoted"') == 'partial-quoted'
 
     def test_parse_query_string(self):
-        query_strinq = (
+        query_string = (
             'a=http%3A%2F%2Ffalconframework.org%3Ftest%3D1'
             '&b=%7B%22test1%22%3A%20%22data1%22%'
             '2C%20%22test2%22%3A%20%22data2%22%7D'
@@ -263,7 +273,7 @@ def test_parse_query_string(self):
         decoded_url = 'http://falconframework.org?test=1'
         decoded_json = '{"test1": "data1", "test2": "data2"}'
 
-        result = uri.parse_query_string(query_strinq)
+        result = uri.parse_query_string(query_string)
         assert result['a'] == decoded_url
         assert result['b'] == decoded_json
         assert result['c'] == ['1', '2', '3']
@@ -272,7 +282,7 @@ def test_parse_query_string(self):
         assert result['f'] == ['a', 'a=b']
         assert result['é'] == 'a=b'
 
-        result = uri.parse_query_string(query_strinq, True)
+        result = uri.parse_query_string(query_string, True)
         assert result['a'] == decoded_url
         assert result['b'] == decoded_json
         assert result['c'] == ['1', '2', '3']
@@ -281,6 +291,43 @@ def test_parse_query_string(self):
         assert result['f'] == ['a', 'a=b']
         assert result['é'] == 'a=b'
 
+    @pytest.mark.parametrize('query_string,keep_blank,expected', [
+        ('', True, {}),
+        ('', False, {}),
+        ('flag1&&&&&flag2&&&', True, {'flag1': '', 'flag2': ''}),
+        ('flag1&&&&&flag2&&&', False, {}),
+        ('malformed=%FG%1%Hi%%%a', False, {'malformed': '%FG%1%Hi%%%a'}),
+        ('=', False, {}),
+        ('==', False, {'': '='}),
+        (
+            '%==+==&&&&&&&&&%%==+=&&&&&&%0g%=%=',
+            False,
+            {'%': '= ==', '%%': '= =', '%0g%': '%='},
+        ),
+        ('%=&%%=&&%%%=', False, {}),
+        ('%=&%%=&&%%%=', True, {'%': '', '%%': '', '%%%': ''}),
+        ('+=&%+=&&%++=', True, {' ': '', '% ': '', '%  ': ''}),
+        ('=x=&=x=+1=x=&%=x', False, {'': ['x=', 'x= 1=x='], '%': 'x'}),
+        (
+            ''.join(itertools.chain.from_iterable(
+                itertools.permutations('%=+&', 4))),
+            False,
+            {
+                '': ['%', ' %', '%', ' ', ' =%', '%', '% ', ' %'],
+                ' ': ['=% ', ' %', '%'],
+                '%': [' ', ' ', ' '],
+            },
+        ),
+        # NOTE(vytas): Sanity check that we do not accidentally use C-strings
+        #   anywhere in the cythonized variant.
+        ('%%%\x00%\x00==\x00\x00==', True, {'%%%\x00%\x00': '=\x00\x00=='}),
+        ('spade=♠&spade=♠', False, {'spade': ['♠', '♠']}),  # Unicode query
+    ])
+    def test_parse_query_string_edge_cases(
+            self, query_string, keep_blank, expected):
+        assert uri.parse_query_string(query_string, keep_blank=keep_blank) == (
+            expected)
+
     def test_parse_host(self):
         assert uri.parse_host('::1') == ('::1', None)
         assert uri.parse_host('2001:ODB8:AC10:FE01::') == ('2001:ODB8:AC10:FE01::', None)