diff --git a/README.md b/README.md index b2a40f0..4e9f2cb 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,9 @@ conditional expressions and comprehensions, laid out as real branches and loops synthetic local, set literals, chained assignments, assignments to `global` names, lambdas and nested function definitions, analysed as functions of their own whose captured variables are implicit parameters so taint flows through closures like through -any call, `del`, loop `else` clauses, and `match` over literal, singleton, capture, wildcard -and or-patterns with guards, laid out as an `if` chain. Not yet: sequence, mapping and +any call, `del`, loop `else` clauses, and `match` over literal, singleton, capture, wildcard, +or, sequence, mapping and keyword class patterns with guards, laid out as an `if` chain +over length, index, membership, attribute and `isinstance` tests. Not yet: positional class patterns, `nonlocal` assignments, classes defined inside functions, and a conditional expression or comprehension inside a `while` condition. Blocks inside a `try` body carry exception edges to the handlers; `finally` is modelled on the normal path only. Methods of module-level classes are analysed like functions; other module-level diff --git a/src/coretrace_python/cfg/builder.py b/src/coretrace_python/cfg/builder.py index c144d7f..671db81 100644 --- a/src/coretrace_python/cfg/builder.py +++ b/src/coretrace_python/cfg/builder.py @@ -343,7 +343,9 @@ def match_statement( chain = (nodes.Assign(subject, node.subject, node.span), *statements) return self.sequence(chain, block, continuation, node.span) - def pattern(self, node: nodes.Pattern, subject: nodes.Name) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]: + def pattern( + self, node: nodes.Pattern, subject: nodes.Expression + ) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]: """The condition a pattern tests on ``subject`` and the names it binds.""" if isinstance(node, nodes.ValuePattern): @@ -366,14 +368,93 @@ def pattern(self, node: nodes.Pattern, subject: nodes.Name) -> tuple[nodes.Expre conditions.append(condition) bindings.extend(inner) return nodes.BoolOp("or", tuple(conditions), node.span), tuple(bindings) + if isinstance(node, nodes.SequencePattern): + return self.sequence_pattern(node, subject) + if isinstance(node, nodes.MappingPattern): + return self.mapping_pattern(node, subject) + if isinstance(node, nodes.ClassPattern): + return self.class_pattern(node, subject) + if isinstance(node, nodes.StarPattern): + raise CFGError(f"{node.span.display()}: a star pattern only belongs in a sequence pattern") raise CFGError(f"{node.span.display()}: match pattern {node.kind} is not supported yet") + def sequence_pattern( + self, node: nodes.SequencePattern, subject: nodes.Expression + ) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]: + """``[a, 0, *rest, b]``: the length fits, each item matches its sub-pattern and + the star captures the middle slice.""" + + span = node.span + count = len(node.patterns) + star = next((i for i, p in enumerate(node.patterns) if isinstance(p, nodes.StarPattern)), None) + length = nodes.Call(nodes.Name("len", span), (subject,), (), span) + if star is None: + conditions: list[nodes.Expression] = [nodes.Compare("eq", length, nodes.Constant(count, span), span)] + else: + conditions = [nodes.Compare("gt_eq", length, nodes.Constant(count - 1, span), span)] + bindings: list[nodes.Statement] = [] + for index, sub in enumerate(node.patterns): + if isinstance(sub, nodes.StarPattern): + if sub.name is not None: + upper = None if index == count - 1 else nodes.Constant(index - count + 1, span) + piece = nodes.Subscript(subject, nodes.Slice(nodes.Constant(index, span), upper, None, span), span) + bindings.append(nodes.Assign(nodes.Name(sub.name, sub.span), piece, sub.span)) + continue + position = index if star is None or index < star else index - count + item = nodes.Subscript(subject, nodes.Constant(position, span), span) + condition, inner = self.pattern(sub, item) + conditions.append(condition) + bindings.extend(inner) + return _conjunction(conditions, span), tuple(bindings) + + def mapping_pattern( + self, node: nodes.MappingPattern, subject: nodes.Expression + ) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]: + """``{key: pattern, **rest}``: every key is present and its value matches.""" + + span = node.span + conditions: list[nodes.Expression] = [] + bindings: list[nodes.Statement] = [] + for key, sub in zip(node.keys, node.patterns, strict=True): + conditions.append(nodes.Compare("in", key, subject, span)) + condition, inner = self.pattern(sub, nodes.Subscript(subject, key, span)) + conditions.append(condition) + bindings.extend(inner) + if node.rest is not None: + bindings.append(nodes.Assign(nodes.Name(node.rest, span), subject, span)) + return _conjunction(conditions, span), tuple(bindings) + + def class_pattern( + self, node: nodes.ClassPattern, subject: nodes.Expression + ) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]: + """``Cls(name=pattern)``: an instance of the class whose attributes match.""" + + span = node.span + if node.patterns: + raise CFGError( + f"{span.display()}: positional class patterns need the class's __match_args__ " + "and are not supported yet" + ) + conditions: list[nodes.Expression] = [ + nodes.Call(nodes.Name("isinstance", span), (subject, node.cls), (), span) + ] + bindings: list[nodes.Statement] = [] + for name, sub in zip(node.keyword_names, node.keyword_patterns, strict=True): + condition, inner = self.pattern(sub, nodes.Attribute(subject, name, span)) + conditions.append(condition) + bindings.extend(inner) + return _conjunction(conditions, span), tuple(bindings) + def loop(self, keyword: str, span: SourceSpan) -> tuple[BlockId, BlockId]: if not self.loops: raise CFGError(f"{span.display()}: '{keyword}' outside loop") return self.loops[-1] +def _conjunction(conditions: list[nodes.Expression], span: SourceSpan) -> nodes.Expression: + return conditions[0] if len(conditions) == 1 else nodes.BoolOp("and", tuple(conditions), span) + + def _may_hold_expressions(value: object) -> bool: return isinstance(value, tuple) or (is_dataclass(value) and not isinstance(value, type)) diff --git a/src/coretrace_python/frontend/ast_adapter.py b/src/coretrace_python/frontend/ast_adapter.py index c78f9b3..07be2f5 100644 --- a/src/coretrace_python/frontend/ast_adapter.py +++ b/src/coretrace_python/frontend/ast_adapter.py @@ -350,6 +350,25 @@ def pattern(self, node: ast.pattern) -> nodes.Pattern: return nodes.CapturePattern(node.name, inner, span) if isinstance(node, ast.MatchOr): return nodes.OrPattern(tuple(self.pattern(p) for p in node.patterns), span) + if isinstance(node, ast.MatchStar): + return nodes.StarPattern(node.name, span) + if isinstance(node, ast.MatchSequence): + return nodes.SequencePattern(tuple(self.pattern(p) for p in node.patterns), span) + if isinstance(node, ast.MatchMapping): + return nodes.MappingPattern( + tuple(self.expression(k) for k in node.keys), + tuple(self.pattern(p) for p in node.patterns), + node.rest, + span, + ) + if isinstance(node, ast.MatchClass): + return nodes.ClassPattern( + self.expression(node.cls), + tuple(self.pattern(p) for p in node.patterns), + tuple(node.kwd_attrs), + tuple(self.pattern(p) for p in node.kwd_patterns), + span, + ) return nodes.UnsupportedPattern(type(node).__name__, span) def block(self, statements: list[ast.stmt]) -> tuple[nodes.Statement, ...]: diff --git a/src/coretrace_python/hir/nodes.py b/src/coretrace_python/hir/nodes.py index 250f5ec..4b56a63 100644 --- a/src/coretrace_python/hir/nodes.py +++ b/src/coretrace_python/hir/nodes.py @@ -302,16 +302,61 @@ class OrPattern: span: SourceSpan +@dataclass(frozen=True, slots=True) +class StarPattern: + """``*rest`` inside a sequence pattern; ``name`` is ``None`` for ``*_``.""" + + name: str | None + span: SourceSpan + + +@dataclass(frozen=True, slots=True) +class SequencePattern: + patterns: tuple[Pattern, ...] + span: SourceSpan + + +@dataclass(frozen=True, slots=True) +class MappingPattern: + """``{key: pattern, ..., **rest}``; keys are expressions, ``rest`` a name or ``None``.""" + + keys: tuple[Expression, ...] + patterns: tuple[Pattern, ...] + rest: str | None + span: SourceSpan + + +@dataclass(frozen=True, slots=True) +class ClassPattern: + """``Cls(p, ..., name=pattern, ...)``: positional sub-patterns need the class's + ``__match_args__``; keyword ones match attributes.""" + + cls: Expression + patterns: tuple[Pattern, ...] + keyword_names: tuple[str, ...] + keyword_patterns: tuple[Pattern, ...] + span: SourceSpan + + @dataclass(frozen=True, slots=True) class UnsupportedPattern: - """A sequence, mapping, class or star pattern; the CFG builder reports it.""" + """A pattern the HIR does not represent; the CFG builder reports it.""" kind: str span: SourceSpan Pattern: TypeAlias = ( - ValuePattern | SingletonPattern | WildcardPattern | CapturePattern | OrPattern | UnsupportedPattern + ValuePattern + | SingletonPattern + | WildcardPattern + | CapturePattern + | OrPattern + | StarPattern + | SequencePattern + | MappingPattern + | ClassPattern + | UnsupportedPattern ) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index cc72f2f..4cbfd9d 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -394,6 +394,23 @@ def pattern(self, node: nodes.Pattern, scope: _ScopeBuilder) -> None: elif isinstance(node, nodes.OrPattern): for alternative in node.alternatives: self.pattern(alternative, scope) + elif isinstance(node, nodes.StarPattern): + if node.name is not None: + scope.bind(node.name, BindingKind.LOCAL, node.span) + elif isinstance(node, nodes.SequencePattern): + for sub in node.patterns: + self.pattern(sub, scope) + elif isinstance(node, nodes.MappingPattern): + for key in node.keys: + self.expression(key, scope) + for sub in node.patterns: + self.pattern(sub, scope) + if node.rest is not None: + scope.bind(node.rest, BindingKind.LOCAL, node.span) + elif isinstance(node, nodes.ClassPattern): + self.expression(node.cls, scope) + for sub in (*node.patterns, *node.keyword_patterns): + self.pattern(sub, scope) def target(self, node: nodes.Target, scope: _ScopeBuilder) -> None: if isinstance(node, nodes.Name): diff --git a/tests/test_factories_match_loop_else.py b/tests/test_factories_match_loop_else.py index 52c0daf..b4ef8b5 100644 --- a/tests/test_factories_match_loop_else.py +++ b/tests/test_factories_match_loop_else.py @@ -188,7 +188,7 @@ def test_match_wildcard_and_subject_taint() -> None: def test_unsupported_patterns_are_reported_per_function() -> None: - findings = check("def f(p):\n match p:\n case [x, y]:\n return x\n case _:\n return 0\n") + findings = check("def f(p):\n match p:\n case Point(0, y):\n return y\n case _:\n return 0\n") assert [f.rule_id for f in findings] == ["unsupported-syntax"] assert "pattern" in findings[0].message diff --git a/tests/test_match_patterns.py b/tests/test_match_patterns.py new file mode 100644 index 0000000..bfeb8ba --- /dev/null +++ b/tests/test_match_patterns.py @@ -0,0 +1,154 @@ +"""Acceptance tests for sequence, mapping and class patterns in ``match`` (option 2, +last point of the consolidation). + +The CFG builder already lays a ``match`` out as an ``if`` chain over a hidden subject for +literal, capture, wildcard and or-patterns. Structural patterns join them: a sequence +pattern tests the length and applies its sub-patterns to indexed items, a star +capturing the middle slice; a mapping pattern tests each key's membership and applies +sub-patterns to the values; a class pattern tests ``isinstance`` and applies keyword +sub-patterns to attributes. Positional class patterns need ``__match_args__`` and stay +reported. Captures bind ordinary locals, so taint flows through them. + +Expected to remain red until ``SequencePattern``, ``MappingPattern``, ``ClassPattern`` +and their layout exist. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.findings import Finding +from coretrace_python.frontend import build_hir +from coretrace_python.hir import nodes +from coretrace_python.ir.lowering import lower_module +from coretrace_python.ir.printer import format_module +from coretrace_python.source import SourceManager + +try: + from coretrace_python.hir.nodes import ( + ClassPattern, + MappingPattern, + SequencePattern, + StarPattern, + ) +except ImportError as error: # pragma: no cover - red until the patterns land + MISSING: Exception | None = error +else: + MISSING = None + + +@pytest.fixture(autouse=True) +def require_patterns() -> None: + if MISSING is not None: + pytest.fail(f"structural match patterns are not implemented yet: {MISSING}") + + +REPO = Path(__file__).resolve().parent.parent +PLUGINS = REPO / "plugins" + + +def hir(text: str) -> nodes.Module: + return build_hir(SourceManager().add_source("m.py", text)) + + +def printed(text: str) -> str: + return format_module(lower_module(hir(text))) + + +def check(text: str) -> tuple[Finding, ...]: + return engine.check(SourceManager().add_source("m.py", text), [PLUGINS]) + + +def rules(findings: tuple[Finding, ...]) -> list[tuple[str, int]]: + return sorted((f.rule_id, f.span.start_line) for f in findings) + + +def notes(text: str) -> list[str]: + return [f.message for f in check(text) if f.rule_id in ("syntax-error", "unsupported-syntax")] + + +# --------------------------------------------------------------------------- HIR + + +def test_structural_patterns_are_represented() -> None: + module = hir( + "def f(p):\n" + " match p:\n" + " case [x, 0, *rest]:\n return x\n" + " case {'op': op, **extra}:\n return op\n" + " case Point(x=0, y=y):\n return y\n" + ) + statement = module.body[0].body[0] # type: ignore[union-attr] + assert isinstance(statement, nodes.Match) + sequence, mapping, klass = (case.pattern for case in statement.cases) + + assert isinstance(sequence, SequencePattern) + assert [type(p).__name__ for p in sequence.patterns] == ["CapturePattern", "ValuePattern", "StarPattern"] + assert isinstance(sequence.patterns[2], StarPattern) and sequence.patterns[2].name == "rest" + assert isinstance(mapping, MappingPattern) and mapping.rest == "extra" + assert [k.value for k in mapping.keys if isinstance(k, nodes.Constant)] == ["op"] + assert isinstance(klass, ClassPattern) + assert isinstance(klass.cls, nodes.Name) and klass.cls.identifier == "Point" + assert klass.keyword_names == ("x", "y") and len(klass.keyword_patterns) == 2 and klass.patterns == () + + +def test_captures_inside_structural_patterns_are_locals() -> None: + from coretrace_python.semantic.scopes import BindingKind, analyze_scopes + + module = hir("def f(p):\n match p:\n case [x, *rest]:\n return x, rest\n case {'k': v}:\n return v\n case P(a=a):\n return a\n") + scopes = analyze_scopes(module) + f = next(s for s in scopes.children(scopes.module_scope.id) if s.name == "f") + + assert all(f.bindings[name].kind is BindingKind.LOCAL for name in ("x", "rest", "v", "a")) + + +# --------------------------------------------------------------------------- layout + + +def test_sequence_patterns_test_the_length_and_index_the_items() -> None: + text = printed("def f(p):\n match p:\n case [a, 'b']:\n return a\n case [first, *rest]:\n return rest\n case _:\n return None\n") + + assert "python.builtins.len" in text + assert "compare.eq" in text and "compare.gt_eq" in text + assert "build_slice" in text + assert notes("def f(p):\n match p:\n case [a, 'b']:\n return a\n") == [] + + +def test_mapping_and_class_patterns_lower_to_membership_and_isinstance() -> None: + text = printed( + "def f(p):\n match p:\n case {'op': op, 'n': 1}:\n return op\n" + " case Point(x=0, y=y):\n return y\n" + ) + assert "compare.in" in text + assert "python.builtins.isinstance" in text + assert notes("def f(p):\n match p:\n case {'op': op}:\n return op\n case Point(y=y):\n return y\n") == [] + + +def test_positional_class_patterns_are_still_reported() -> None: + found = notes("def f(p):\n match p:\n case Point(0, y):\n return y\n") + assert len(found) == 1 and "positional" in found[0] + + +# --------------------------------------------------------------------------- taint + + +def test_taint_flows_through_captures_of_every_pattern_kind() -> None: + assert rules( + check("import os\n\ndef f():\n match input().split():\n case [cmd, *args]:\n os.system(cmd)\n os.system(args[0])\n") + ) == [("command-injection", 6), ("command-injection", 7)] + assert rules( + check("import os\nimport json\n\ndef f():\n match json.loads(input()):\n case {'cmd': cmd, **rest}:\n os.system(cmd)\n os.system(rest['x'])\n") + ) == [("command-injection", 7), ("command-injection", 8)] + assert rules( + check("import os\n\ndef f(req):\n match req:\n case Request(body=body) if body:\n os.system(input() + body)\n") + ) == [("command-injection", 6)] + + +def test_guards_see_the_captures() -> None: + assert rules( + check("import os\n\ndef f():\n match input().split():\n case [cmd, count] if count.isdigit():\n os.system(cmd)\n") + ) == [("command-injection", 6)] + assert check("import os\n\ndef f():\n match input().split():\n case [cmd] if cmd.isdigit():\n os.system(cmd)\n") == ()