mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 09:48:18 +00:00
feat(access-control-prover): 8.3 exact, finite-candidate and unresolved call targets
This commit is contained in:
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -23,6 +23,7 @@ from ..semantic.obligations.facts import load_obligation_facts
|
||||
from ..semantic.obligations.operations import Discharge, Operation, find_discharges, find_operations, valid_witness
|
||||
from ..semantic.obligations.paths import compose_paths
|
||||
from ..semantic.obligations.policy import DEFAULT_POLICY_RELPATH, load_declared_policy
|
||||
from ..semantic.obligations.resolution import CallBindings, bind_calls
|
||||
from ..semantic.obligations.trace import build_trace
|
||||
from .producers import Context, _safe_path
|
||||
|
||||
@@ -41,6 +42,9 @@ class Coverage:
|
||||
# until the prover opts into abstract judgment (task 10.6). Indeterminate pairs are counted
|
||||
# neither clean nor discharged.
|
||||
pair_judgments: PairJudgmentCounts = PairJudgmentCounts()
|
||||
# Requirement 3.3/3.4 (access-control-prover): call-resolution outcome totals. Prover-mode
|
||||
# resolution has no bare-name fallback; every call lands in exactly one bucket.
|
||||
resolution_counts: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,6 +55,52 @@ class ProverResult:
|
||||
traces: tuple[dict[str, Any], ...] = ()
|
||||
|
||||
|
||||
def _resolution_bindings(
|
||||
readable: list[str],
|
||||
read_ir: Callable[[str], FileIR | None],
|
||||
functions: Mapping[tuple[str, str], FunctionIR],
|
||||
ctx: Context,
|
||||
reasons: Counter[str],
|
||||
degradations: list[dict[str, str]],
|
||||
) -> CallBindings:
|
||||
"""Parse every readable file and resolve all calls (Reqs 3.3/3.4)."""
|
||||
for path in readable:
|
||||
try:
|
||||
read_ir(path)
|
||||
except Exception as error:
|
||||
reasons[type(error).__name__] += 1
|
||||
degradations.append({"path": path, "reason": type(error).__name__})
|
||||
functions_by_module: dict[str, list[FunctionIR]] = {}
|
||||
for (module, _name), function_ir in functions.items():
|
||||
functions_by_module.setdefault(module, []).append(function_ir)
|
||||
return bind_calls(functions_by_module=functions_by_module, index=ctx.symbol_index)
|
||||
|
||||
|
||||
def _exact_callers(bindings: CallBindings, ctx: Context, path: str, function: str) -> list[tuple[str, int]]:
|
||||
"""Caller sites from exact bindings only; no bare-name fallback (Reqs 3.3/3.4)."""
|
||||
out = []
|
||||
for symbol in ctx.symbol_index.by_name.get(function, ()):
|
||||
if symbol.module != path:
|
||||
continue
|
||||
for binding in bindings.exact_callers(symbol):
|
||||
out.append((f"{binding.module}::{binding.function}", binding.line))
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _targets_from_bindings(
|
||||
touched: set[tuple[str, str]],
|
||||
functions: Mapping[tuple[str, str], FunctionIR],
|
||||
bindings: CallBindings,
|
||||
) -> set[tuple[str, str]]:
|
||||
"""Targets join by resolved symbol identity, never by trailing name (Req 3.3)."""
|
||||
target_functions = set(touched)
|
||||
for key in touched:
|
||||
for call in functions[key].calls:
|
||||
for target_set in bindings.resolutions.get((key[0], key[1], call.line), ()):
|
||||
target_functions.update((symbol.module, symbol.name) for symbol in target_set.targets)
|
||||
return target_functions
|
||||
|
||||
|
||||
def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = None, *, max_depth: int = 3) -> ProverResult:
|
||||
"""Check touched head functions and their direct operation-bearing callees, with no model."""
|
||||
if max_depth < 0:
|
||||
@@ -162,32 +212,15 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
|
||||
if file.get("head_lines") and not selected:
|
||||
reasons["function_unavailable"] += 1
|
||||
|
||||
# The existing name-based index supplies call sites; retain file-qualified enclosing functions.
|
||||
def callers(path: str, function: str) -> list[tuple[str, int]]:
|
||||
del path
|
||||
out = []
|
||||
for caller_path, line in source.callers_of(root, function, files=readable, texts=ctx.texts, index=ctx.call_index):
|
||||
ir = read_ir(caller_path)
|
||||
if ir is None:
|
||||
continue
|
||||
spans = [fn for fn in ir.functions if fn.name != "<module>" and fn.start_line <= line <= fn.end_line]
|
||||
if spans:
|
||||
fn = min(spans, key=lambda item: item.end_line - item.start_line)
|
||||
out.append((f"{caller_path}::{fn.name}", line))
|
||||
return out
|
||||
# Full IR coverage first: conservative resolution (Req 3.3/3.4) needs every readable file parsed.
|
||||
bindings = _resolution_bindings(readable, read_ir, functions, ctx, reasons, degradations)
|
||||
|
||||
target_functions = set(touched)
|
||||
call_names = {trailing_name(call.name) for key in touched for call in functions[key].calls}
|
||||
if call_names:
|
||||
for path in readable:
|
||||
# Ranges are cached and avoid parsing unrelated files just to look for direct callees.
|
||||
try:
|
||||
if any(span.name in call_names for span in ctx.ranges(path)):
|
||||
read_ir(path)
|
||||
except Exception as error:
|
||||
reasons[type(error).__name__] += 1
|
||||
degradations.append({"path": path, "reason": type(error).__name__})
|
||||
target_functions.update(key for key, ops in operations.items() if key[1] in call_names and ops)
|
||||
# Conservative caller resolution: only exact (complete, single-target) bindings supply callers;
|
||||
# the global bare-name index fallback is removed from the prover path (Req 3.3/3.4).
|
||||
def callers(path: str, function: str) -> list[tuple[str, int]]:
|
||||
return _exact_callers(bindings, ctx, path, function)
|
||||
|
||||
target_functions = _targets_from_bindings(touched, functions, bindings)
|
||||
paths, unreached = compose_paths(
|
||||
callers=callers, entries={f"{e.path}::{e.function_name}" for e in entries}, targets=sorted(target_functions), max_depth=max_depth
|
||||
)
|
||||
@@ -343,6 +376,7 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
|
||||
len(checked),
|
||||
len(checked - undischarged),
|
||||
dict(reasons),
|
||||
resolution_counts=dict(bindings.counts),
|
||||
)
|
||||
return ProverResult(tuple(findings.values()), coverage, tuple(degradations), tuple(traces))
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..cst import DeclarationScope, declaration_scopes
|
||||
from ..ir import CallSite
|
||||
from .abstract_domain import Completeness
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -74,3 +76,151 @@ def _inheritance_edges(module: str, scopes: Iterable[DeclarationScope]) -> dict[
|
||||
if scope.enclosing_type is not None:
|
||||
edges[(module, scope.enclosing_type)] = scope.enclosing_base_types
|
||||
return edges
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CallTargetSet:
|
||||
"""The outcome of resolving one call: complete exact targets, incomplete candidates, or unresolved."""
|
||||
|
||||
targets: tuple[SymbolId, ...]
|
||||
completeness: Completeness
|
||||
|
||||
|
||||
_SELF_RECEIVERS = frozenset({"self", "$this", "this", "cls", "static"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CallerBinding:
|
||||
"""One call site bound to a target declaration, with whether the binding is exact."""
|
||||
|
||||
module: str
|
||||
function: str
|
||||
line: int
|
||||
exact: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CallBindings:
|
||||
"""Forward and reverse views of every resolved call in one change."""
|
||||
|
||||
call_targets: tuple[CallTargetSet, ...]
|
||||
exact_caller_index: Mapping[SymbolId, tuple[CallerBinding, ...]]
|
||||
counts: Mapping[str, int]
|
||||
resolutions: Mapping[tuple[str, str, int], tuple[CallTargetSet, ...]] = field(default_factory=dict)
|
||||
|
||||
def exact_callers(self, target: SymbolId) -> tuple[CallerBinding, ...]:
|
||||
"""Call sites whose resolution is complete and lands on this target."""
|
||||
return self.exact_caller_index.get(target, ())
|
||||
|
||||
|
||||
def resolve_call(call: CallSite, *, module: str, index: SymbolIndex) -> CallTargetSet:
|
||||
"""Resolve one call site against the repository symbol index.
|
||||
|
||||
Exact receiver and full signature produce one complete target; partial information
|
||||
produces a finite candidate set with an incompleteness reason; nothing falls back to
|
||||
a global bare-name key (Reqs 3.3, 3.4, 11.1, 11.6).
|
||||
"""
|
||||
same_name = index.by_name.get(call.name, ())
|
||||
receiver = (call.receiver or "").strip()
|
||||
if not receiver:
|
||||
return _module_scope_targets(same_name, module, call.arity)
|
||||
if receiver in _SELF_RECEIVERS:
|
||||
return _method_targets(same_name, module, call.arity, "receiver_self_shared_across_classes")
|
||||
if _is_declared_type(same_name, receiver):
|
||||
return _type_receiver_targets(same_name, receiver, call.arity)
|
||||
return _variable_receiver_targets(same_name, call.arity)
|
||||
|
||||
|
||||
def bind_calls(*, functions_by_module: Mapping[str, Iterable], index: SymbolIndex) -> CallBindings:
|
||||
"""Resolve every call in the given function IRs and index the exact caller edges."""
|
||||
call_targets: list[CallTargetSet] = []
|
||||
caller_index: dict[SymbolId, list[CallerBinding]] = {}
|
||||
resolutions: dict[tuple[str, str, int], list[CallTargetSet]] = {}
|
||||
counts = {"complete_calls": 0, "incomplete_calls": 0, "unresolved_calls": 0}
|
||||
for module, functions in functions_by_module.items():
|
||||
for function_ir in functions:
|
||||
for call in function_ir.calls:
|
||||
target_set = resolve_call(call, module=module, index=index)
|
||||
call_targets.append(target_set)
|
||||
resolutions.setdefault((module, function_ir.name, call.line), []).append(target_set)
|
||||
_count_target_set(target_set, counts)
|
||||
if not target_set.completeness.reasons and len(target_set.targets) == 1:
|
||||
binding = CallerBinding(module=module, function=function_ir.name, line=call.line, exact=True)
|
||||
bindings = caller_index.setdefault(target_set.targets[0], [])
|
||||
if binding not in bindings:
|
||||
bindings.append(binding)
|
||||
return CallBindings(
|
||||
call_targets=tuple(call_targets),
|
||||
exact_caller_index={target: tuple(bindings) for target, bindings in sorted(caller_index.items())},
|
||||
counts=dict(sorted(counts.items())),
|
||||
resolutions={key: tuple(sets) for key, sets in sorted(resolutions.items())},
|
||||
)
|
||||
|
||||
|
||||
def _count_target_set(target_set: CallTargetSet, counts: dict[str, int]) -> None:
|
||||
if not target_set.targets:
|
||||
counts["unresolved_calls"] += 1
|
||||
elif target_set.completeness.reasons:
|
||||
counts["incomplete_calls"] += 1
|
||||
else:
|
||||
counts["complete_calls"] += 1
|
||||
|
||||
|
||||
def _module_scope_targets(same_name: tuple[SymbolId, ...], module: str, arity: int | None) -> CallTargetSet:
|
||||
# A receiver-less call (implicit this in class languages, plain call in
|
||||
# module languages) resolves against every same-name declaration in the
|
||||
# calling module: module-level functions and class methods alike. Complete
|
||||
# only when exactly one candidate exists; a shared name stays a finite
|
||||
# candidate set with an incompleteness reason, never a bare-name merge.
|
||||
module_scope = tuple(symbol for symbol in same_name if symbol.module == module)
|
||||
narrowed = _arity_narrow(module_scope, arity)
|
||||
if module_scope and not narrowed:
|
||||
return CallTargetSet(targets=(), completeness=Completeness(is_complete=False, reasons=("resolution:arity_mismatch",)))
|
||||
return _finish(narrowed, shared_reason="name_shared_across_module_scopes")
|
||||
|
||||
|
||||
def _method_targets(same_name: tuple[SymbolId, ...], module: str, arity: int | None, shared_reason: str) -> CallTargetSet:
|
||||
methods = tuple(symbol for symbol in same_name if symbol.enclosing_type is not None and symbol.module == module)
|
||||
return _narrowed_or_unresolved(methods, arity, shared_reason)
|
||||
|
||||
|
||||
def _type_receiver_targets(same_name: tuple[SymbolId, ...], type_name: str, arity: int | None) -> CallTargetSet:
|
||||
methods = tuple(symbol for symbol in same_name if symbol.enclosing_type == type_name)
|
||||
return _narrowed_or_unresolved(methods, arity, "class_shared_across_modules" if len({s.module for s in methods}) > 1 else "")
|
||||
|
||||
|
||||
def _variable_receiver_targets(same_name: tuple[SymbolId, ...], arity: int | None) -> CallTargetSet:
|
||||
methods = tuple(symbol for symbol in same_name if symbol.enclosing_type is not None)
|
||||
narrowed = _arity_narrow(methods, arity)
|
||||
if not narrowed:
|
||||
return CallTargetSet(targets=(), completeness=Completeness(is_complete=False, reasons=("resolution:no_candidate",)))
|
||||
return CallTargetSet(
|
||||
targets=narrowed,
|
||||
completeness=Completeness(is_complete=False, reasons=("resolution:receiver_type_unknown",)),
|
||||
)
|
||||
|
||||
|
||||
def _narrowed_or_unresolved(methods: tuple[SymbolId, ...], arity: int | None, shared_reason: str) -> CallTargetSet:
|
||||
narrowed = _arity_narrow(methods, arity)
|
||||
if methods and not narrowed:
|
||||
return CallTargetSet(targets=(), completeness=Completeness(is_complete=False, reasons=("resolution:arity_mismatch",)))
|
||||
return _finish(narrowed, shared_reason=shared_reason)
|
||||
|
||||
|
||||
def _is_declared_type(same_name: tuple[SymbolId, ...], receiver: str) -> bool:
|
||||
return any(symbol.enclosing_type == receiver for symbol in same_name)
|
||||
|
||||
|
||||
def _arity_narrow(symbols: tuple[SymbolId, ...], arity: int | None) -> tuple[SymbolId, ...]:
|
||||
if arity is None:
|
||||
return symbols
|
||||
narrowed = tuple(symbol for symbol in symbols if len(symbol.signature or ()) == arity)
|
||||
return narrowed or symbols
|
||||
|
||||
|
||||
def _finish(targets: tuple[SymbolId, ...], shared_reason: str) -> CallTargetSet:
|
||||
if not targets:
|
||||
return CallTargetSet(targets=(), completeness=Completeness(is_complete=False, reasons=("resolution:no_candidate",)))
|
||||
if len(targets) == 1:
|
||||
return CallTargetSet(targets=targets, completeness=Completeness(is_complete=True))
|
||||
return CallTargetSet(targets=targets, completeness=Completeness(is_complete=False, reasons=(f"resolution:{shared_reason}",)))
|
||||
|
||||
Reference in New Issue
Block a user