Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 82 additions & 1 deletion src/coretrace_python/cfg/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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))

Expand Down
19 changes: 19 additions & 0 deletions src/coretrace_python/frontend/ast_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]:
Expand Down
49 changes: 47 additions & 2 deletions src/coretrace_python/hir/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand Down
17 changes: 17 additions & 0 deletions src/coretrace_python/semantic/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_factories_match_loop_else.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
154 changes: 154 additions & 0 deletions tests/test_match_patterns.py
Original file line number Diff line number Diff line change
@@ -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") == ()
Loading