From 253246d9d802e3b29f25df3cfc7bea1358ecc2db Mon Sep 17 00:00:00 2001 From: Alexander Loechel Date: Sun, 27 Sep 2026 18:44:28 +0200 Subject: [PATCH] feat: deny every ast node explicitly with a documented reason "Explicit is better than implicit." (PEP 20) Every ast node of the supported Python versions now has an explicit visit_ method in RestrictingNodeTransformer. generic_visit stays the safety net for nodes of new Python versions nobody has reviewed yet; it is no longer the place where the decision about a known language feature is recorded. - Deny explicitly the nodes which were only denied by generic_visit: AnnAssign, Match with all pattern nodes and match_case, TypeAlias, TypeVar, ParamSpec, TypeVarTuple, FunctionType and TypeIgnore. - Explain in the docstring of every denying method why the node is denied and which guard or check it would bypass; also for the existing ones (Nonlocal, TryStar, async). - Add tests which allow the pattern nodes and show the bypasses: class, mapping and sequence patterns access the subject without _getattr_, _getitem_ and _getiter_; capture names escape the check for a leading underscore. - Add a test which fails for every ast node of the running Python without a visit_ method. - Replace the open "Option 1 / Option 2" todo in the contributing documentation by the rule. Co-Authored-By: Claude Opus 5.5 --- CHANGES.rst | 12 + docs/contributing/index.rst | 108 +++---- src/RestrictedPython/transformer.py | 274 +++++++++++++++++- tests/transformer/test_explicit_deny.py | 355 ++++++++++++++++++++++++ 4 files changed, 691 insertions(+), 58 deletions(-) create mode 100644 tests/transformer/test_explicit_deny.py diff --git a/CHANGES.rst b/CHANGES.rst index 60c7eb4..3aa1851 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,18 @@ Changes providing an ``__import__`` implementation hands the security boundary to the import policy of the calling application. +- Every ast node now has an explicit ``visit_`` method in + ``RestrictingNodeTransformer``, following "Explicit is better than + implicit." (PEP 20). The nodes which were only denied implicitly by + ``generic_visit`` (``AnnAssign``, the ``match`` statement and its patterns, + the type parameter and ``type`` statement nodes of Python 3.12+, + ``FunctionType`` and ``TypeIgnore``) are now denied explicitly, and the + docstrings of all denying methods explain the reason and the security + implications. They are still denied, but no longer emit the warning + "... statement is not known to RestrictedPython". A new test fails for + every ast node of the running Python version without a ``visit_`` + method. + 8.5 (2026-08-19) ---------------- diff --git a/docs/contributing/index.rst b/docs/contributing/index.rst index 9559ae7..4214860 100644 --- a/docs/contributing/index.rst +++ b/docs/contributing/index.rst @@ -35,7 +35,7 @@ For all commits, use ``tox`` to run tests and lint, and build the docs, before p .. _new_python_version: -Preperations for a new Python version +Preparations for a new Python version +++++++++++++++++++++++++++++++++++++ RestrictedPython should be updated for each new version of Python. @@ -49,19 +49,11 @@ To do so: * For each new **AST Node** or functionality: * Add tests to ``/tests/``. - * Add a ``visit_`` to ``/src/RestrictedPython/transformer.py``. + * Add a ``visit_`` method to ``/src/RestrictedPython/transformer.py`` which either allows or denies the node, see :ref:`explicit_visitors`. + The test ``tests/transformer/test_explicit_deny.py`` fails as long as a node of the running Python version has no ``visit_`` method. - If the new AST Node should be enabled by default, with or without any modification, please add a ``visit_`` method such as the following: - - .. code-block:: python - - def visit_(self, node): - """Allow `` expressions.""" - ... # modifications - return self.node_contents_visit(node) - - All AST Nodes without an explicit ``visit_`` method, are denied by default. - So the usage of this expression and functionality is not allowed. + * Check existing nodes with new fields, too. + A new feature does not always come with a new node: lazy imports (:pep:`810`) only added the field ``is_lazy`` to ``Import`` and ``ImportFrom``, see :doc:`changes_from314to315`. * Check the documentation for `inspect `_ and adjust the ``transformer.py:INSPECT_ATTRIBUTES`` list. * Add a corresponding changelog entry. @@ -173,59 +165,71 @@ With RestrictedPython 4.0 an API compatible rewrite has happened, which supports Tests and documentation are distributed within released packages. -.. todo:: +.. _explicit_visitors: + +Every AST node has an explicit ``visit_`` method +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +RestrictedPython follows the Zen of Python (:pep:`20`): - Resolve discussion about how RestrictedPython should be treat new expressions / ``ast.Nodes``. - This belongs to :ref:`new_python_version`. +.. code-block:: pycon + :emphasize-lines: 5 - **Option 1 - reduce maintenance burden (preferred by icemac)** + >>> import this + The Zen of Python, by Tim Peters + Beautiful is better than ugly. + Explicit is better than implicit. + Simple is better than complex. + Complex is better than complicated. + Flat is better than nested. + Sparse is better than dense. + Readability counts. + Special cases aren't special enough to break the rules. + Although practicality beats purity. + Errors should never pass silently. + Unless explicitly silenced. + In the face of ambiguity, refuse the temptation to guess. + There should be one-- and preferably only one --obvious way to do it. + Although that way may not be obvious at first unless you're Dutch. + Now is better than never. + Although never is often better than *right* now. + If the implementation is hard to explain, it's a bad idea. + If the implementation is easy to explain, it may be a good idea. + Namespaces are one honking great idea -- let's do more of those! - All AST Nodes without an explicit ``visit_`` method, are denied by default. - So the usage of this expression and functionality is not allowed. +Therefore **every AST node has an explicit** ``visit_`` **method** in ``RestrictingNodeTransformer``, also the denied ones. - *This is currently the promoted version.* +``generic_visit`` denies every node without a ``visit_`` method. +It is the safety net for nodes of a new Python version which nobody has reviewed yet, not the place where the decision about a known language feature is recorded. +A node which is denied only by ``generic_visit`` looks the same as a node nobody has looked at. +An explicit method records that somebody has reviewed the node and why it is denied, at the place where the next reviewer looks for it. +In security relevant code, that knowledge is worth more than the lines it takes. - **Option 2 - be as explicit as possible (preferred by loechel)** +A denied node gets a method like the following: - If the new AST Node should be disabled by default, add a ``visit_`` method such as the following: +.. code-block:: python - .. code-block:: python + def visit_(self, node): + """Deny `` (, ). - def visit_(self, node): - """`` expression currently not allowed.""" - self.not_allowed(node) + + """ + self.not_allowed(node) - Please note, that for all AST Nodes without an explicit ``visit_`` method, a default applies which denies the usage of this expression and functionality. - As we try to be **as explicit as possible**, all language features should have a corresponding ``visit_``. +The docstring states the reason, not only the decision: - That follows the Zen of Python: +* Which guard (``_getattr_``, ``_getitem_``, ``_getiter_``, ``_write_``, ...) or check (``check_name``, import policy, ...) the node would bypass. +* Which security implications allowing it would have. +* If no concrete bypass is known, say so and say that a security review is still missing. - .. code-block:: pycon - :emphasize-lines: 5 +Where the reason is a behavior of the interpreter, add a test which allows the node and shows the bypass (see ``tests/transformer/test_explicit_deny.py``). +If the interpreter changes, the test fails and the docstring gets reviewed. - >>> import this - The Zen of Python, by Tim Peters +An allowed node gets a method which calls ``self.node_contents_visit(node)``, with the modifications needed to guard it. - Beautiful is better than ugly. - Explicit is better than implicit. - Simple is better than complex. - Complex is better than complicated. - Flat is better than nested. - Sparse is better than dense. - Readability counts. - Special cases aren't special enough to break the rules. - Although practicality beats purity. - Errors should never pass silently. - Unless explicitly silenced. - In the face of ambiguity, refuse the temptation to guess. - There should be one-- and preferably only one --obvious way to do it. - Although that way may not be obvious at first unless you're Dutch. - Now is better than never. - Although never is often better than *right* now. - If the implementation is hard to explain, it's a bad idea. - If the implementation is easy to explain, it may be a good idea. - Namespaces are one honking great idea -- let's do more of those! +The test ``tests/transformer/test_explicit_deny.py`` enforces the rule: it fails as long as a node of the running Python version has no ``visit_`` method. Technical Backgrounds - Links to External Documentation diff --git a/src/RestrictedPython/transformer.py b/src/RestrictedPython/transformer.py index e129b63..d654f8b 100644 --- a/src/RestrictedPython/transformer.py +++ b/src/RestrictedPython/transformer.py @@ -1113,6 +1113,32 @@ def visit_AugAssign(self, node: ast.AugAssign) -> _T_visit_return: raise NotImplementedError( f"Unknown target type: {type(node.target)}") + def visit_AnnAssign(self, node: ast.AnnAssign) -> _T_visit_return: + """Deny annotated assignments (`x: int = 1`, :pep:`526`). + + Why it is denied: + + * The annotation is not a value the restricted code works with, it + is stored for other code to evaluate: in the ``__annotations__`` + mapping of the module or class, as a string (:pep:`563`) or, + since Python 3.14, as a lazily evaluated ``__annotate__`` + function (:pep:`649`, :pep:`749`). + When and in which context the annotation expression runs is thus + decided by the code inspecting the annotations (for example + ``typing.get_type_hints``), not by the restricted code. + * Up to Python 3.13 (and with ``from __future__ import + annotations``) the annotation is written into + ``__annotations__`` by the bytecode (``SETUP_ANNOTATIONS`` / + ``STORE_SUBSCR``) without calling the ``_write_`` guard. Since + Python 3.14 the compiler creates an ``__annotate__`` function + instead, which runs whenever other code asks for the annotations. + + Allowing type hints is requested in + https://github.com/zopefoundation/RestrictedPython/issues/219; that + needs a security review of the points above first. + """ + self.not_allowed(node) + def visit_Raise(self, node: ast.Raise) -> _T_visit_return: """Allow `raise` statements without restrictions.""" return self.node_contents_visit(node) @@ -1172,7 +1198,14 @@ def visit_Try(self, node: ast.Try) -> _T_visit_return: return self.node_contents_visit(node) def visit_TryStar(self, node: ast.AST) -> _T_visit_return: - """Disallow `ExceptionGroup` due to a potential sandbox escape. + """Deny `try` with `except*` clauses (:pep:`654`). + + `except*` allowed a sandbox escape via a type confusion bug in the + CPython interpreter, see + https://github.com/zopefoundation/RestrictedPython/security/advisories/GHSA-gmj9-h825-chq2 + (CVE-2025-22153). Its only use is handling an ``ExceptionGroup``, + which is not in ``safe_builtins``; so there is hardly a use case in + restricted code. TODO: Change Type Annotation to ast.TryStar when Support for Python 3.10 is dropped. @@ -1204,6 +1237,118 @@ def visit_withitem(self, node: ast.withitem) -> _T_visit_return: """Allow `with` statements (context managers) without restrictions.""" return self.node_contents_visit(node) + # Structural pattern matching (:pep:`634`), new in Python 3.10 + + def visit_Match(self, node: ast.Match) -> _T_visit_return: + """Deny the `match` statement. + + Matching a pattern accesses the subject inside the interpreter + (bytecode ``MATCH_CLASS``, ``MATCH_MAPPING``, ``MATCH_SEQUENCE``, + ``MATCH_KEYS``) instead of through the ast nodes RestrictedPython + rewrites. Thus the guards are not called: + + * class patterns read attributes without ``_getattr_``, + see `visit_MatchClass`, + * mapping patterns read items without ``_getitem_``, + see `visit_MatchMapping`, + * sequence patterns iterate without ``_getiter_``, + see `visit_MatchSequence`, + * capture patterns bind names without the name checks, + see `visit_MatchAs`. + + A guarded `match` statement would need a rewrite of the whole + statement into guarded code, which has not been done. + Denying `match` makes all the pattern nodes unreachable; they have + their own `visit_` methods nevertheless to document the reasons. + """ + self.not_allowed(node) + + def visit_match_case(self, node: ast.match_case) -> _T_visit_return: + """Deny a `case` clause of a `match` statement. + + Only reachable inside a `match` statement, see `visit_Match`. + """ + self.not_allowed(node) + + def visit_MatchValue(self, node: ast.MatchValue) -> _T_visit_return: + """Deny value patterns (`case 1:`, `case Color.RED:`). + + A value pattern may be a dotted name (``case obj.attr:``). Allowed + as it is, the attribute would be read without ``_getattr_``. + Rewriting it into a ``_getattr_`` call like `visit_Attribute` does + is not possible, as ``compile()`` only accepts constants and + attribute lookups in a value pattern. + """ + self.not_allowed(node) + + def visit_MatchSingleton( + self, node: ast.MatchSingleton) -> _T_visit_return: + """Deny singleton patterns (`case None:`, `case True:`). + + They compare by identity and are harmless on their own, but they + only exist inside a `match` statement, see `visit_Match`. + """ + self.not_allowed(node) + + def visit_MatchSequence( + self, node: ast.MatchSequence) -> _T_visit_return: + """Deny sequence patterns (`case [first, *rest]:`). + + The subject is measured with ``len()`` and unpacked by the + interpreter without calling ``_getiter_``, thus bypassing the guard + which protects every other iteration (`guard_iter`). + """ + self.not_allowed(node) + + def visit_MatchMapping(self, node: ast.MatchMapping) -> _T_visit_return: + """Deny mapping patterns (`case {'key': value, **rest}:`). + + The interpreter calls ``get()`` on the subject for each key and, + for ``**rest``, reads all remaining items. None of these accesses + calls ``_getitem_``. The name bound by ``**rest`` is an identifier + string, not an ``ast.Name``, so it escapes the name checks, see + `visit_MatchAs`. + """ + self.not_allowed(node) + + def visit_MatchClass(self, node: ast.MatchClass) -> _T_visit_return: + """Deny class patterns (`case Point(x=0):`). + + The interpreter reads the attributes named by the keyword patterns + and by ``__match_args__`` of the class directly, without calling + ``_getattr_``. That bypasses the attribute guard, which is the + central protection of RestrictedPython. + """ + self.not_allowed(node) + + def visit_MatchStar(self, node: ast.MatchStar) -> _T_visit_return: + """Deny star patterns (`case [first, *rest]:`). + + The bound name is an identifier string, not an ``ast.Name``, so it + escapes the name checks, see `visit_MatchAs`. + """ + self.not_allowed(node) + + def visit_MatchAs(self, node: ast.MatchAs) -> _T_visit_return: + """Deny capture and as-patterns (`case x:`, `case [x] as y:`). + + The bound names are stored as identifier strings in the pattern + node, not as ``ast.Name`` nodes. `check_name` is never called for + them, so ``case _secret:`` binds a name starting with an underscore, + which RestrictedPython denies everywhere else. + The same class of bug was the cause of + https://github.com/zopefoundation/RestrictedPython/security/advisories/GHSA-ffg3-p8fm-mjx2 + (names of positional-only arguments). + """ + self.not_allowed(node) + + def visit_MatchOr(self, node: ast.MatchOr) -> _T_visit_return: + """Deny or-patterns (`case 1 | 2:`). + + They combine other patterns, see `visit_Match`. + """ + self.not_allowed(node) + # Function and class definitions def visit_FunctionDef(self, node: ast.FunctionDef) -> _T_visit_return: @@ -1250,7 +1395,16 @@ def visit_Global(self, node: ast.Global) -> _T_visit_return: return self.node_contents_visit(node) def visit_Nonlocal(self, node: ast.Nonlocal) -> _T_visit_return: - """Deny `nonlocal` statements.""" + """Deny `nonlocal` statements. + + `nonlocal` lets a nested function rebind a variable of an enclosing + function. The names are identifier strings in the node, not + ``ast.Name`` nodes, so `check_name` does not see them here. + No concrete bypass is known, as the variable itself has to be + defined in the enclosing function, where its name is checked. + It has been denied since the start of the ast based implementation + without a security review; it needs one before allowing it. + """ self.not_allowed(node) def visit_ClassDef(self, node: ast.ClassDef) -> _T_visit_return: @@ -1287,23 +1441,131 @@ def visit_Module(self, node: ast.Module) -> _T_visit_return: self.inject_print_collector(node, position) return node + # Type parameters and type aliases (:pep:`695`), new in Python 3.12 + + def visit_TypeAlias(self, node: ast.AST) -> _T_visit_return: + """Deny the `type` statement (`type Alias = int`). + + * It creates a ``typing.TypeAliasType`` object without any + ``import``, so the import guard is not asked. + * The value is evaluated lazily, when other code first accesses + ``__value__``. When and in which context the restricted code runs + is then decided by that code, not by the caller of the restricted + code. + + TODO: Change Type Annotation to ast.TypeAlias when + Support for Python 3.11 is dropped. + """ + self.not_allowed(node) + + def visit_TypeVar(self, node: ast.AST) -> _T_visit_return: + """Deny type variables in type parameter lists (`def f[T]():`). + + * It creates ``typing.TypeVar`` objects without any ``import``, so + the import guard is not asked. A generic class additionally gets + ``typing.Generic`` as an implicit base class. + * Bounds, constraints and defaults are evaluated lazily, when other + code first accesses them (e.g. ``__bound__``), see + `visit_TypeAlias`. + * `visit_ClassDef` rebuilds the class node without its + ``type_params``, so allowing them would silently change the + meaning of a generic class. + + TODO: Change Type Annotation to ast.TypeVar when + Support for Python 3.11 is dropped. + """ + self.not_allowed(node) + + def visit_ParamSpec(self, node: ast.AST) -> _T_visit_return: + """Deny parameter specifications (`def f[**P]():`). + + Same reasons as for `visit_TypeVar`; it creates + ``typing.ParamSpec`` objects. + + TODO: Change Type Annotation to ast.ParamSpec when + Support for Python 3.11 is dropped. + """ + self.not_allowed(node) + + def visit_TypeVarTuple(self, node: ast.AST) -> _T_visit_return: + """Deny type variable tuples (`def f[*Ts]():`). + + Same reasons as for `visit_TypeVar`; it creates + ``typing.TypeVarTuple`` objects. + + TODO: Change Type Annotation to ast.TypeVarTuple when + Support for Python 3.11 is dropped. + """ + self.not_allowed(node) + + # Type comments (:pep:`484`) + + def visit_FunctionType(self, node: ast.FunctionType) -> _T_visit_return: + """Deny function type signatures (`(int, str) -> bool`). + + They are only created by ``ast.parse(mode='func_type')`` to read + signature type comments. They are not executable code: ``compile()`` + does not accept them. RestrictedPython never creates them, so there + is nothing to allow. + """ + self.not_allowed(node) + + def visit_TypeIgnore(self, node: ast.TypeIgnore) -> _T_visit_return: + """Deny `# type: ignore` comments in a pre-parsed ast. + + The parser only creates them with ``ast.parse(type_comments=True)``, + which RestrictedPython never uses. A pre-parsed ast passed in by the + caller could contain them. They are harmless in themselves, but + allowing them would make the result depend on how the caller parsed + the code instead of on the code. + """ + self.not_allowed(node) + # Async und await def visit_AsyncFunctionDef( self, node: ast.AsyncFunctionDef) -> _T_visit_return: - """Deny async functions.""" + """Deny async functions (`async def`, :pep:`492`). + + Calling an async function creates a coroutine object; running it + needs an event loop provided by the calling application, so the + restricted code cannot use it on its own. + The async statements inside (`await`, `async for`, `async with`) + bypass guards, see `visit_Await`, `visit_AsyncFor` and + `visit_AsyncWith`. + Denying `async def` makes them unreachable, as they are only + allowed inside an async function. + """ self.not_allowed(node) def visit_Await(self, node: ast.Await) -> _T_visit_return: - """Deny async functionality.""" + """Deny `await` expressions. + + `await` suspends the restricted code and hands control to an + awaitable which the restricted code got from outside, for example + from the calling application. Neither the suspension nor the code + running in between is covered by RestrictedPython. + Only reachable inside `async def`, see `visit_AsyncFunctionDef`. + """ self.not_allowed(node) def visit_AsyncFor(self, node: ast.AsyncFor) -> _T_visit_return: - """Deny async functionality.""" + """Deny `async for` loops. + + The loop calls ``__aiter__`` and ``__anext__`` of the iterable + without calling ``_getiter_``: `guard_iter` only protects `for` + loops and comprehensions. + Only reachable inside `async def`, see `visit_AsyncFunctionDef`. + """ self.not_allowed(node) def visit_AsyncWith(self, node: ast.AsyncWith) -> _T_visit_return: - """Deny async functionality.""" + """Deny `async with` statements. + + They call ``__aenter__`` and ``__aexit__`` and await the results, + see `visit_Await`. + Only reachable inside `async def`, see `visit_AsyncFunctionDef`. + """ self.not_allowed(node) # Assignment expressions (walrus operator ``:=``) diff --git a/tests/transformer/test_explicit_deny.py b/tests/transformer/test_explicit_deny.py new file mode 100644 index 0000000..f6e8f8f --- /dev/null +++ b/tests/transformer/test_explicit_deny.py @@ -0,0 +1,355 @@ +"""Every ast node has an explicit `visit_` method. + +"Explicit is better than implicit." (PEP 20) + +RestrictedPython rejects every ast node without a `visit_` method in +`RestrictingNodeTransformer.generic_visit`. That is the safety net for ast +nodes introduced by new Python versions. It is not the place where a decision +about a known language feature is recorded: each known node gets a +`visit_` method which either allows it (with the necessary guards) or +denies it and explains why in its docstring. +""" +import ast +import sys + +import pytest + +from RestrictedPython import compile_restricted_exec +from RestrictedPython.transformer import RestrictingNodeTransformer + + +# Base classes which are never instantiated by the parser. +ABSTRACT_NODES = { + 'AST', + 'boolop', + 'cmpop', + 'excepthandler', + 'expr', + 'expr_context', + 'mod', + 'operator', + 'pattern', + 'slice', + 'stmt', + 'type_ignore', + 'type_param', + 'unaryop', +} + +# Deprecated classes which the parser of the supported Python versions does +# not produce any more. They only exist for backwards compatibility. +DEPRECATED_NODES = { + 'AugLoad', + 'AugStore', + 'Bytes', + 'Ellipsis', + 'ExtSlice', + 'Index', + 'NameConstant', + 'Num', + 'Param', + 'Str', + 'Suite', +} + + +def concrete_ast_nodes(): + """Return the names of all ast nodes of the running Python version.""" + return sorted( + name for name in dir(ast) + if not name.startswith('_') + and isinstance(getattr(ast, name), type) + and issubclass(getattr(ast, name), ast.AST) + and name not in ABSTRACT_NODES | DEPRECATED_NODES + ) + + +def test_explicit_deny__1(): + """Every ast node of the running Python has a `visit_` method. + + If this test fails after adding a new Python version, read the section + "Preparations for a new Python version" in the contributing documentation + and add a `visit_` method for each listed node. + """ + missing = [ + name for name in concrete_ast_nodes() + if not hasattr(RestrictingNodeTransformer, f'visit_{name}') + ] + assert missing == [] + + +def assert_denied(source, node_name, lineno): + """Assert `source` is denied by an explicit `visit_`. + + An explicit `visit_` method does not emit the warning about an + unknown node which `generic_visit` emits. + """ + result = compile_restricted_exec(source) + assert result.errors == ( + f'Line {lineno}: {node_name} statements are not allowed.',) + assert result.warnings == [] + assert result.code is None + + +MATCH_EXAMPLE = """\ +match command: + case 'go': + pass +""" + + +def test_explicit_deny__Match__1(): + """It denies the `match` statement.""" + assert_denied(MATCH_EXAMPLE, 'Match', 1) + + +def test_explicit_deny__AnnAssign__1(): + """It denies annotated assignments.""" + assert_denied('x: int = 1', 'AnnAssign', 1) + + +def test_explicit_deny__AnnAssign__2(): + """It denies annotated assignments without a value.""" + assert_denied('x: int', 'AnnAssign', 1) + + +@pytest.mark.skipif( + sys.version_info < (3, 12), + reason='The `type` statement is new in Python 3.12.') +def test_explicit_deny__TypeAlias__1(): + """It denies the `type` statement.""" + assert_denied('type Alias = int', 'TypeAlias', 1) + + +@pytest.mark.skipif( + sys.version_info < (3, 12), + reason='Type parameter lists are new in Python 3.12.') +def test_explicit_deny__TypeVar__1(): + """It denies type variables in type parameter lists.""" + assert_denied('def func[T](arg):\n return arg\n', 'TypeVar', 1) + + +@pytest.mark.skipif( + sys.version_info < (3, 12), + reason='Type parameter lists are new in Python 3.12.') +def test_explicit_deny__ParamSpec__1(): + """It denies parameter specifications in type parameter lists.""" + assert_denied('class Box[**P]:\n pass\n', 'ParamSpec', 1) + + +@pytest.mark.skipif( + sys.version_info < (3, 12), + reason='Type parameter lists are new in Python 3.12.') +def test_explicit_deny__TypeVarTuple__1(): + """It denies type variable tuples in type parameter lists.""" + assert_denied('def func[*Ts]():\n pass\n', 'TypeVarTuple', 1) + + +def test_explicit_deny__TypeIgnore__1(): + """It denies `# type: ignore` comments of a pre-parsed ast. + + The parser only creates `TypeIgnore` nodes if it is asked to keep type + comments, which RestrictedPython never does. A pre-parsed ast passed in + by the caller can contain them, though. + """ + tree = ast.parse('x = 1 # type: ignore\n', type_comments=True) + result = compile_restricted_exec(tree) + assert result.errors == ('Line 1: TypeIgnore statements are not allowed.',) + assert result.warnings == [] + assert result.code is None + + +# Contains each pattern node at least once. +ALL_PATTERNS_EXAMPLE = """\ +match subject: + case 1: + pass + case None: + pass + case [first, *rest]: + pass + case {'key': value}: + pass + case Point(x=0): + pass + case 1 | 2: + pass +""" + + +def find_node(tree, node_name): + """Return the first node of type `node_name` in `tree`.""" + node_type = getattr(ast, node_name) + return next(node for node in ast.walk(tree) if isinstance(node, node_type)) + + +# Nodes which only exist inside of an already denied `Match` node. They are +# never reached from source code but have an explicit `visit_` method +# nevertheless. +PATTERN_NODES = [ + 'MatchAs', + 'MatchClass', + 'MatchMapping', + 'MatchOr', + 'MatchSequence', + 'MatchSingleton', + 'MatchStar', + 'MatchValue', + 'match_case', +] + + +@pytest.mark.parametrize('node_name', PATTERN_NODES) +def test_explicit_deny__patterns__1(node_name): + """It denies the nodes of the `match` statement one by one.""" + node = find_node(ast.parse(ALL_PATTERNS_EXAMPLE), node_name) + transformer = RestrictingNodeTransformer() + transformer.visit(node) + # `match_case` has no position. + lineno = getattr(node, 'lineno', None) + assert transformer.errors == [ + f'Line {lineno}: {node_name} statements are not allowed.'] + assert transformer.warnings == [] + + +def test_explicit_deny__FunctionType__1(): + """It denies function type signatures. + + They are only created by `ast.parse(mode='func_type')`. + """ + tree = ast.parse('(int, str) -> bool', mode='func_type') + transformer = RestrictingNodeTransformer() + transformer.visit(tree) + assert transformer.errors == [ + 'Line None: FunctionType statements are not allowed.'] + assert transformer.warnings == [] + + +# The following tests document the reasons given in the docstrings of the +# `visit_` methods: they allow a denied node and show which guard or +# check it bypasses. If one of them fails for a new Python version, the +# corresponding docstring needs a review. + + +class AllowPatternMatching(RestrictingNodeTransformer): + """Transformer which allows the `match` statement for the tests.""" + + +for _node_name in ['Match', *PATTERN_NODES]: + setattr( + AllowPatternMatching, + f'visit_{_node_name}', + RestrictingNodeTransformer.node_contents_visit) + + +def recording_globals(calls): + """Return globals with guards recording their calls into `calls`. + + The tests show that the guards are bypassed, so they are never called. + """ + def _getattr_(ob, name): # pragma: no cover + calls.append(('_getattr_', name)) + return getattr(ob, name) + + def _getitem_(ob, key): # pragma: no cover + calls.append(('_getitem_', key)) + return ob[key] + + def _getiter_(ob): # pragma: no cover + calls.append(('_getiter_',)) + return iter(ob) + + return { + '__builtins__': {}, + '_getattr_': _getattr_, + '_getitem_': _getitem_, + '_getiter_': _getiter_, + } + + +def run_allowed(source, **kw): + """Run `source` with pattern matching allowed. + + Return the globals and the recorded guard calls. + """ + result = compile_restricted_exec(source, policy=AllowPatternMatching) + assert result.errors == () + calls = [] + glb = recording_globals(calls) + glb.update(kw) + exec(result.code, glb) + return glb, calls + + +class Point: + __match_args__ = ('x',) + x = 'secret' + + +def test_explicit_deny__MatchClass__reason__1(): + """A class pattern reads attributes without calling `_getattr_`.""" + glb, calls = run_allowed( + 'match point:\n' + ' case Point(x=value):\n' + ' result = value\n', + point=Point(), Point=Point) + assert glb['result'] == 'secret' + assert calls == [] + + +def test_explicit_deny__MatchClass__reason__2(): + """Positional class patterns use `__match_args__` without `_getattr_`.""" + glb, calls = run_allowed( + 'match point:\n' + ' case Point(value):\n' + ' result = value\n', + point=Point(), Point=Point) + assert glb['result'] == 'secret' + assert calls == [] + + +def test_explicit_deny__MatchSequence__reason__1(): + """A sequence pattern iterates without calling `_getiter_`.""" + glb, calls = run_allowed( + 'match seq:\n' + ' case [first, *rest]:\n' + ' result = (first, rest)\n', + seq=[1, 2, 3]) + assert glb['result'] == (1, [2, 3]) + assert calls == [] + + +def test_explicit_deny__MatchMapping__reason__1(): + """A mapping pattern reads items without calling `_getitem_`.""" + glb, calls = run_allowed( + 'match mapping:\n' + " case {'key': value, **rest}:\n" + ' result = (value, rest)\n', + mapping={'key': 1, 'other': 2}) + assert glb['result'] == (1, {'other': 2}) + assert calls == [] + + +@pytest.mark.parametrize('pattern', [ + '_secret', + '[*_secret]', + '{**_secret}', + 'x as _secret', +]) +def test_explicit_deny__MatchAs__reason__1(pattern): + """Names bound by patterns escape the check for a leading `_`.""" + result = compile_restricted_exec( + f'match subject:\n case {pattern}:\n pass\n', + policy=AllowPatternMatching) + assert result.errors == () + assert result.code is not None + + +def test_explicit_deny__MatchValue__reason__1(): + """A guarded attribute lookup is not a valid value pattern.""" + with pytest.raises(ValueError) as err: + compile_restricted_exec( + 'match subject:\n case obj.attr:\n pass\n', + policy=AllowPatternMatching) + assert 'patterns may only match literals and attribute lookups' in str( + err.value)