Repository: falconry/falcon

Issue:
Parameters unexpectedly captured from field expressions in multiple routes
Based on a report by @tim-clipchamp on Gitter.

Consider the following example
```python
import falcon
import falcon.testing


class Resource:
    def __init__(self, name):
        self.name = name

    def on_get(self, req, res, **kw):
        print(self.name, kw)


api = falcon.API()
api.add_route("/upload/{service}/auth/token", Resource('service'))
api.add_route("/upload/youtube/{project_id}/share", Resource('project'))

client = falcon.testing.TestClient(api)
client.simulate_get('/upload/youtube/auth/token')
```
which produces the following output (tested with Falcon 1.4.1, 2.0 and 3.0.0a2; the 3.0 series also emit the expected `API` deprecation warning):
```
service {'project_id': 'auth', 'service': 'youtube'}
```

The expectation is that only field expressions from the matched route should be used.
Instead, the observed result here is that the `{project_id}` field expression from a non-matching route is also present in the evaluated responder parameters.


Discussion:
This is the find src generated. The issue is obvious looking at it, but I'm not sure how to best solve it

```py
def find(path, return_values, patterns, converters, params):
    path_len = len(path)
    if path_len > 0:
        if path[0] == 'upload':
            if path_len > 1:
                if path[1] == 'youtube':
                    if path_len > 2:
                        params['project_id'] = path[2]
                        if path_len > 3:
                            if path[3] == 'share':
                                if path_len == 4:
                                    return return_values[0]
                params['service'] = path[1]
                if path_len > 2:
                    if path[2] == 'auth':
                        if path_len > 3:
                            if path[3] == 'token':
                                if path_len == 4:
                                    return return_values[1]
            return None
        return None
    return None
```
I've traced this issue since at least falcon 1.1 (and is probably even older, but falcon 1.0 does not have a test client)
It might be easier said than done, but one possible path of solution is trying to unroll conditional branches to reflect the routing decision tree first, and only assign to params inside the last block just before `return`.

This could slightly increase the bytecode size (and thus CPU cache locality), but it should not otherwise affect the compiled code speed.
The easy alternative is probably to add `del param['key']` if the return is missed, but it incurs a performance hit
> 
> 
> The easy alternative is probably to add `del param['key']` if the return is missed, but it incurs a performance hit

nevermind. this does not work every time, since the params could come from a regexp.

I've gone for @vytas7 suggestion of delaying param setting until the return is reached.
The code is larger, but i think we could also gain something in terms of speed, since we may avoid dict key set while descending branches that are not taken

Resolving patch:
diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -26,6 +26,8 @@
 from falcon.util.misc import is_python_func
 from falcon.util.sync import _should_wrap_non_coroutines, wrap_sync_to_async
 
+if False:  # TODO: switch to TYPE_CHECKING once support for py3.5 is dropped
+    from typing import Any
 
 _TAB_STR = ' ' * 4
 _FIELD_PATTERN = re.compile(
@@ -368,8 +370,21 @@ def _validate_template_segment(self, segment, used_names):
                     msg = 'Cannot instantiate converter "{}"'.format(name)
                     raise ValueError(msg) from e
 
-    def _generate_ast(self, nodes, parent, return_values, patterns, level=0, fast_return=True):
+    def _generate_ast(
+        self,
+        nodes: list,
+        parent,
+        return_values: list,
+        patterns: list,
+        params_stack: list,
+        level=0,
+        fast_return=True
+    ):
         """Generate a coarse AST for the router."""
+        # NOTE(caselit): setting of the parameters in the params dict is delayed until
+        # a match has been found by adding them to the param_stack. This way superfluous
+        # parameters are not set to the params dict while descending on branches that
+        # ultimately do not match.
 
         # NOTE(kgriffs): Base case
         if not nodes:
@@ -400,7 +415,11 @@ def _generate_ast(self, nodes, parent, return_values, patterns, level=0, fast_re
 
                 fast_return = not found_var_nodes
 
+        construct = None  # type: Any
+        setter = None  # type: Any
+        original_params_stack = params_stack.copy()
         for node in nodes:
+            params_stack = original_params_stack.copy()
             if node.is_var:
                 if node.is_complex:
                     # NOTE(richardolsson): Complex nodes are nodes which
@@ -417,10 +436,13 @@ def _generate_ast(self, nodes, parent, return_values, patterns, level=0, fast_re
 
                     if node.var_converter_map:
                         parent.append_child(_CxPrefetchGroupsFromPatternMatch())
-                        parent = self._generate_conversion_ast(parent, node)
+                        parent = self._generate_conversion_ast(parent, node, params_stack)
 
                     else:
-                        parent.append_child(_CxSetParamsFromPatternMatch())
+                        construct = _CxVariableFromPatternMatch(len(params_stack) + 1)
+                        setter = _CxSetParamsFromDict(construct.dict_variable_name)
+                        params_stack.append(setter)
+                        parent.append_child(construct)
 
                 else:
                     # NOTE(kgriffs): Simple nodes just capture the entire path
@@ -442,15 +464,14 @@ def _generate_ast(self, nodes, parent, return_values, patterns, level=0, fast_re
                         converter_idx = len(self._converters)
                         self._converters.append(converter_obj)
 
-                        construct = _CxIfConverterField(
-                            field_name,
-                            converter_idx,
-                        )
+                        construct = _CxIfConverterField(len(params_stack) + 1, converter_idx)
+                        setter = _CxSetParamFromValue(field_name, construct.field_variable_name)
+                        params_stack.append(setter)
 
                         parent.append_child(construct)
                         parent = construct
                     else:
-                        parent.append_child(_CxSetParam(node.var_name, level))
+                        params_stack.append(_CxSetParamFromPath(node.var_name, level))
 
                     # NOTE(kgriffs): We don't allow multiple simple var nodes
                     # to exist at the same level, e.g.:
@@ -479,6 +500,7 @@ def _generate_ast(self, nodes, parent, return_values, patterns, level=0, fast_re
                 parent,
                 return_values,
                 patterns,
+                params_stack.copy(),
                 level + 1,
                 fast_return
             )
@@ -491,6 +513,8 @@ def _generate_ast(self, nodes, parent, return_values, patterns, level=0, fast_re
                 # the segments for the requested route; otherwise we could
                 # mistakenly match "/foo/23/bar" against "/foo/{id}".
                 construct = _CxIfPathLength('==', level + 1)
+                for params in params_stack:
+                    construct.append_child(params)
                 construct.append_child(_CxReturnValue(resource_idx))
                 parent.append_child(construct)
 
@@ -502,7 +526,9 @@ def _generate_ast(self, nodes, parent, return_values, patterns, level=0, fast_re
         if not found_simple and fast_return:
             parent.append_child(_CxReturnNone())
 
-    def _generate_conversion_ast(self, parent, node):
+    def _generate_conversion_ast(self, parent, node: 'CompiledRouterNode', params_stack: list):
+        construct = None  # type: Any
+        setter = None  # type: Any
         # NOTE(kgriffs): Unroll the converter loop into
         # a series of nested "if" constructs.
         for field_name, converter_name, converter_argstr in node.var_converter_map:
@@ -517,10 +543,9 @@ def _generate_conversion_ast(self, parent, node):
 
             parent.append_child(_CxSetFragmentFromField(field_name))
 
-            construct = _CxIfConverterField(
-                field_name,
-                converter_idx,
-            )
+            construct = _CxIfConverterField(len(params_stack) + 1, converter_idx)
+            setter = _CxSetParamFromValue(field_name, construct.field_variable_name)
+            params_stack.append(setter)
 
             parent.append_child(construct)
             parent = construct
@@ -528,7 +553,10 @@ def _generate_conversion_ast(self, parent, node):
         # NOTE(kgriffs): Add remaining fields that were not
         # converted, if any.
         if node.num_fields > len(node.var_converter_map):
-            parent.append_child(_CxSetParamsFromPatternMatchPrefetched())
+            construct = _CxVariableFromPatternMatchPrefetched(len(params_stack) + 1)
+            setter = _CxSetParamsFromDict(construct.dict_variable_name)
+            params_stack.append(setter)
+            parent.append_child(construct)
 
         return parent
 
@@ -553,7 +581,8 @@ def _compile(self):
             self._roots,
             self._ast,
             self._return_values,
-            self._patterns
+            self._patterns,
+            params_stack=[]
         )
 
         src_lines.append(self._ast.src(0))
@@ -859,7 +888,7 @@ def _children_src(self, indentation):
 
 class _CxIfPathLength(_CxParent):
     def __init__(self, comparison, length):
-        super(_CxIfPathLength, self).__init__()
+        super().__init__()
         self._comparison = comparison
         self._length = length
 
@@ -875,7 +904,7 @@ def src(self, indentation):
 
 class _CxIfPathSegmentLiteral(_CxParent):
     def __init__(self, segment_idx, literal):
-        super(_CxIfPathSegmentLiteral, self).__init__()
+        super().__init__()
         self._segment_idx = segment_idx
         self._literal = literal
 
@@ -891,7 +920,7 @@ def src(self, indentation):
 
 class _CxIfPathSegmentPattern(_CxParent):
     def __init__(self, segment_idx, pattern_idx, pattern_text):
-        super(_CxIfPathSegmentPattern, self).__init__()
+        super().__init__()
         self._segment_idx = segment_idx
         self._pattern_idx = pattern_idx
         self._pattern_text = pattern_text
@@ -912,21 +941,22 @@ def src(self, indentation):
 
 
 class _CxIfConverterField(_CxParent):
-    def __init__(self, field_name, converter_idx):
-        super(_CxIfConverterField, self).__init__()
-        self._field_name = field_name
+    def __init__(self, unique_idx, converter_idx):
+        super().__init__()
         self._converter_idx = converter_idx
+        self._unique_idx = unique_idx
+        self.field_variable_name = 'field_value_{0}'.format(unique_idx)
 
     def src(self, indentation):
         lines = [
-            '{0}field_value = converters[{1}].convert(fragment)'.format(
+            '{0}{1} = converters[{2}].convert(fragment)'.format(
                 _TAB_STR * indentation,
+                self.field_variable_name,
                 self._converter_idx,
             ),
-            '{0}if field_value is not None:'.format(_TAB_STR * indentation),
-            "{0}params['{1}'] = field_value".format(
-                _TAB_STR * (indentation + 1),
-                self._field_name,
+            '{0}if {1} is not None:'.format(
+                _TAB_STR * indentation,
+                self.field_variable_name
             ),
             self._children_src(indentation + 1),
         ]
@@ -956,17 +986,27 @@ def src(self, indentation):
         )
 
 
-class _CxSetParamsFromPatternMatch:
+class _CxVariableFromPatternMatch:
+    def __init__(self, unique_idx):
+        self._unique_idx = unique_idx
+        self.dict_variable_name = 'dict_match_{0}'.format(unique_idx)
+
     def src(self, indentation):
-        return '{0}params.update(match.groupdict())'.format(
-            _TAB_STR * indentation
+        return '{0}{1} = match.groupdict()'.format(
+            _TAB_STR * indentation,
+            self.dict_variable_name
         )
 
 
-class _CxSetParamsFromPatternMatchPrefetched:
+class _CxVariableFromPatternMatchPrefetched:
+    def __init__(self, unique_idx):
+        self._unique_idx = unique_idx
+        self.dict_variable_name = 'dict_groups_{0}'.format(unique_idx)
+
     def src(self, indentation):
-        return '{0}params.update(groups)'.format(
-            _TAB_STR * indentation
+        return '{0}{1} = groups'.format(
+            _TAB_STR * indentation,
+            self.dict_variable_name
         )
 
 
@@ -993,7 +1033,7 @@ def src(self, indentation):
         )
 
 
-class _CxSetParam:
+class _CxSetParamFromPath:
     def __init__(self, param_name, segment_idx):
         self._param_name = param_name
         self._segment_idx = segment_idx
@@ -1004,3 +1044,27 @@ def src(self, indentation):
             self._param_name,
             self._segment_idx,
         )
+
+
+class _CxSetParamFromValue:
+    def __init__(self, param_name, field_value_name):
+        self._param_name = param_name
+        self._field_value_name = field_value_name
+
+    def src(self, indentation):
+        return "{0}params['{1}'] = {2}".format(
+            _TAB_STR * indentation,
+            self._param_name,
+            self._field_value_name,
+        )
+
+
+class _CxSetParamsFromDict:
+    def __init__(self, dict_value_name):
+        self._dict_value_name = dict_value_name
+
+    def src(self, indentation):
+        return '{0}params.update({1})'.format(
+            _TAB_STR * indentation,
+            self._dict_value_name,
+        )


Test patch:
diff --git a/tests/test_default_router.py b/tests/test_default_router.py
--- a/tests/test_default_router.py
+++ b/tests/test_default_router.py
@@ -653,3 +653,31 @@ def test_options_converters_invalid_name_on_update(router):
             'valid_name': SpamConverter,
             '7eleven': SpamConverter,
         })
+
+
+@pytest.fixture
+def param_router():
+    r = DefaultRouter()
+
+    r.add_route('/c/foo/{bar}/baz', ResourceWithId(1))
+    r.add_route('/c/{foo}/bar/other', ResourceWithId(2))
+    r.add_route('/c/foo/{a:int}-{b}/a', ResourceWithId(3))
+    r.add_route('/upload/{service}/auth/token', ResourceWithId(4))
+    r.add_route('/upload/youtube/{project_id}/share', ResourceWithId(5))
+    r.add_route('/x/y/{a}.{b}/z', ResourceWithId(6))
+    r.add_route('/x/{y}/o.o/w', ResourceWithId(7))
+    return r
+
+
+@pytest.mark.parametrize('route, expected, num', (
+    ('/c/foo/arg/baz', {'bar': 'arg'}, 1),
+    ('/c/foo/bar/other', {'foo': 'foo'}, 2),
+    ('/c/foo/42-7/baz', {'bar': '42-7'}, 1),
+    ('/upload/youtube/auth/token', {'service': 'youtube'}, 4),
+    ('/x/y/o.o/w', {'y': 'y'}, 7),
+))
+def test_params_in_non_taken_branches(param_router, route, expected, num):
+    resource, __, params, __ = param_router.find(route)
+
+    assert resource.resource_id == num
+    assert params == expected