Skip to content

Commit 63b19ae

Browse files
committed
test(python): audit every inline Cypher statement, and scope every arm of _SOURCES
The multi-application audit saw class-level statements and the child walk; the ~20 statements written inline at self._run( sites were outside both. The audit now reassembles each of the 44 sites from the class source (constants, f-strings, + chains, .format receivers, self.<ATTR> strings and local assignments) and classifies every statement as prefix-scoped, :PyApplication{name}-anchored, id-keyed or server introspection -- anything else fails, and so does a statement naming :PyCanNode. Stripping the prefix from one inline statement turns the audit red. _SOURCES' body-node and ghost arms carried no prefix, so describe() reported another application's ref as "found, with nothing to read"; both arms now scope on the prefix, and the two-application fake evaluates each arm on its own predicate.
1 parent eec105c commit 63b19ae

2 files changed

Lines changed: 141 additions & 15 deletions

File tree

‎cldk/analysis/python/neo4j/neo4j_backend.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1672,14 +1672,14 @@ def flows_to_argument(self, src: str, callee: str, arg: str, *, within: str, dep
16721672
#: are one, its interior the other). The ``:PyBodyNode`` and ``:PyExternal`` arms return
16731673
#: ``null`` code deliberately: the graph carries no text below callable granularity, and a
16741674
#: ghost was never analysed, so those rows say "found, and there is nothing to read", which is
1675-
#: what keeps that apart from "not found" (see :meth:`PythonAnalysisBackend.describe`). Only
1676-
#: the callable arm carries the prefix predicate: a body-node id and a ghost id both embed the
1677-
#: application, while a signature does not.
1675+
#: what keeps that apart from "not found" (see :meth:`PythonAnalysisBackend.describe`). All
1676+
#: three arms carry the prefix predicate: a ref naming another application's node must read as
1677+
#: "not found" here, not as "found, with nothing to read".
16781678
_SOURCES = (
16791679
"MATCH (c:PyCallable) WHERE c.id STARTS WITH $prefix AND (c.id IN $refs OR c.signature IN $refs) "
16801680
"RETURN c.id AS id, c.signature AS sig, c.code AS code "
1681-
"UNION MATCH (b:PyBodyNode) WHERE b.id IN $refs RETURN b.id AS id, null AS sig, null AS code "
1682-
"UNION MATCH (e:PyExternal) WHERE e.id IN $refs RETURN e.id AS id, null AS sig, null AS code"
1681+
"UNION MATCH (b:PyBodyNode) WHERE b.id STARTS WITH $prefix AND b.id IN $refs RETURN b.id AS id, null AS sig, null AS code "
1682+
"UNION MATCH (e:PyExternal) WHERE e.id STARTS WITH $prefix AND e.id IN $refs RETURN e.id AS id, null AS sig, null AS code"
16831683
)
16841684

16851685
def _sources_for(self, refs: Sequence[str]) -> Dict[str, "str | None"]:

‎tests/analysis/python/test_neo4j_multi_application_scope.py‎

Lines changed: 136 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@
4545

4646
from __future__ import annotations
4747

48+
import ast
4849
import inspect
4950
import re
51+
from collections import defaultdict
5052
from typing import Any, Dict, List
5153

5254
import pytest
@@ -79,8 +81,8 @@ def _node(module: str, key: str, **props: Any) -> Dict[str, Any]:
7981
"class_methods": (
8082
CLASS_SIG,
8183
{
82-
APP_A_MODULE: _node(APP_A_MODULE, "Widget/render", signature=METHOD_SIG, name="alpha_method", path=APP_A_MODULE),
83-
APP_B_MODULE: _node(APP_B_MODULE, "Widget/render", signature=METHOD_SIG, name="beta_method", path=APP_B_MODULE),
84+
APP_A_MODULE: _node(APP_A_MODULE, "Widget/render", signature=METHOD_SIG, name="alpha_method", path=APP_A_MODULE, code="alpha code"),
85+
APP_B_MODULE: _node(APP_B_MODULE, "Widget/render", signature=METHOD_SIG, name="beta_method", path=APP_B_MODULE, code="beta code"),
8486
},
8587
),
8688
"class_attributes": (
@@ -145,6 +147,14 @@ def _node(module: str, key: str, **props: Any) -> Dict[str, Any]:
145147
_node(APP_B_MODULE, "Widget", signature=CLASS_SIG, name="Widget", path=APP_B_MODULE),
146148
]
147149

150+
#: What ``_SOURCES`` (``describe``) may be handed, per label and per application: the shared method
151+
#: (by signature), each application's one call-site body node, and one ``@external`` ghost each.
152+
_SOURCE_NODES: Dict[str, List[Dict[str, Any]]] = {
153+
"PyCallable": list(_CHILDREN["class_methods"][1].values()),
154+
"PyBodyNode": list(_CHILDREN["callable_callsites"][1].values()),
155+
"PyExternal": [{"id": f"can://python/{app}/@external/os/path", "name": "path", "module": "os"} for app in (APP_A, APP_B)],
156+
}
157+
148158
#: The three spellings of the application scope a statement may carry: the whole application
149159
#: (``$prefix``); for a narrowed bulk fetch, a list of per-module prefixes (``$prefixes``); and for
150160
#: ``locate``, one module's own prefix per position (``pos.module_prefix``, minted from the same
@@ -198,6 +208,14 @@ def _in_scope(query: str, params: Dict[str, Any], props: Dict[str, Any]) -> bool
198208

199209
def _fake_two_app_cypher(query: str, **params: Any) -> List[Dict[str, Any]]:
200210
"""Answer the statements a class reconstruction issues, honestly (see :func:`_in_scope`)."""
211+
if "UNION MATCH (b:PyBodyNode)" in query: # _SOURCES: three arms, each judged on its own predicate
212+
rows = []
213+
for arm in query.split(" UNION "):
214+
label = re.search(r"MATCH \(\w+:(\w+)\)", arm)[1]
215+
for props in _SOURCE_NODES[label]:
216+
if (props["id"] in params["refs"] or props.get("signature") in params["refs"]) and _in_scope(arm, params, props):
217+
rows.append({"id": props["id"], "sig": props.get("signature"), "code": props.get("code")})
218+
return rows
201219
bucket = _bucket_of(query)
202220
if bucket == "module_functions":
203221
# Keyed by file_key: the per-parent twin names it (``$fk``), the bulk twin lists the scope
@@ -307,6 +325,18 @@ def test_a_module_key_shared_by_two_applications_does_not_leak(bulk: bool):
307325
assert set(module.functions) == {"alpha_fn"}, "the module-keyed fetch leaked another application's function"
308326

309327

328+
def test_describe_does_not_find_another_applications_body_nodes_or_ghosts():
329+
"""``_SOURCES`` answers ``describe`` for callables, body nodes and ghosts in one statement. A
330+
body-node or ghost id embeds its application, but "found, with nothing to read" and "not found"
331+
are different answers, and a ref from another application must get the second -- so every arm
332+
carries the prefix, not only the callable one, and the shared signature resolves to A's code."""
333+
backend = _two_app_backend()
334+
a_body, b_body = (n["id"] for n in _SOURCE_NODES["PyBodyNode"])
335+
a_ghost, b_ghost = (n["id"] for n in _SOURCE_NODES["PyExternal"])
336+
found = backend._sources_for([METHOD_SIG, a_body, b_body, a_ghost, b_ghost])
337+
assert found == {METHOD_SIG: "alpha code", a_body: None, a_ghost: None}
338+
339+
310340
def test_every_child_statement_carries_the_application_scope():
311341
"""The four module-keyed collections cannot be caught by the fixture above (see the module
312342
docstring), so they are caught here: every statement either path issues to fetch children is
@@ -345,13 +375,107 @@ def _class_level_statements() -> Dict[str, str]:
345375
}
346376

347377

378+
def _inline_statements() -> Dict[str, str]:
379+
"""The Cypher written *inline* at every ``self._run(`` site, keyed ``<method>@<line>`` -- the
380+
statements the class-attribute enumeration cannot see (about twenty of the 44 sites).
381+
382+
Reassembled from the class's own source: string constants verbatim; f-strings and ``+`` chains
383+
joined; ``.format(...)`` unwrapped to its receiver; ``self.<ATTR>`` replaced by the class-level
384+
string it names; a local name replaced by every assignment to it in the enclosing method, in
385+
order (``query += " AND ..."``); anything else becomes ``{…}``. A site whose first argument is
386+
a bare parameter or a lookup resolves to ``{…}`` alone -- ``_children`` (its callers' literals
387+
are judged by the child walk above), ``_collect`` (``_BULK_CHILD_QUERIES``, judged directly)
388+
and ``_paths`` (``_PATHS`` / ``_CALL_PATHS``, class-level) -- and is reported as such.
389+
"""
390+
class_strings = {name: value for name, value in vars(PyNeo4jBackend).items() if isinstance(value, str)}
391+
out: Dict[str, str] = {}
392+
for fn in ast.walk(ast.parse(inspect.getsource(PyNeo4jBackend))):
393+
if not isinstance(fn, ast.FunctionDef):
394+
continue
395+
assigned: Dict[str, List[ast.expr]] = defaultdict(list)
396+
for node in ast.walk(fn):
397+
if isinstance(node, ast.Assign):
398+
for target in node.targets:
399+
if isinstance(target, ast.Name):
400+
assigned[target.id].append(node.value)
401+
elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
402+
assigned[node.target.id].append(node.value)
403+
404+
def text(e: ast.expr, depth: int = 0) -> str:
405+
if depth > 8:
406+
return "{…}"
407+
if isinstance(e, ast.Constant) and isinstance(e.value, str):
408+
return e.value
409+
if isinstance(e, ast.JoinedStr):
410+
return "".join(text(v.value if isinstance(v, ast.FormattedValue) else v, depth + 1) for v in e.values)
411+
if isinstance(e, ast.BinOp) and isinstance(e.op, ast.Add):
412+
return text(e.left, depth + 1) + text(e.right, depth + 1)
413+
if isinstance(e, ast.Call) and isinstance(e.func, ast.Attribute) and e.func.attr == "format":
414+
return text(e.func.value, depth + 1)
415+
if isinstance(e, ast.Attribute) and isinstance(e.value, ast.Name) and e.value.id == "self" and e.attr in class_strings:
416+
return class_strings[e.attr]
417+
if isinstance(e, ast.Name) and e.id in assigned:
418+
return "".join(text(v, depth + 1) for v in assigned[e.id])
419+
return "{…}"
420+
421+
for call in ast.walk(fn):
422+
if isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute) and call.func.attr == "_run" and isinstance(call.func.value, ast.Name) and call.func.value.id == "self" and call.args:
423+
out.setdefault(f"{fn.name}@{call.lineno}", text(call.args[0]))
424+
return out
425+
426+
427+
#: The four ways a statement stays inside one application. ``introspection`` is the server, not
428+
#: the data (``CALL db.relationshipTypes()``, ``CALL dbms.components()``); ``application`` walks out
429+
#: from ``(:PyApplication {name: $app})`` and cannot leave it; ``prefix`` and ``id`` are the two
430+
#: ``can://``-stamped forms the docstring below explains. Anything else is unscoped.
431+
_INTROSPECTION = re.compile(r"^\s*CALL (db|dbms)\.")
432+
_ANCHORED_ON_THE_APPLICATION = re.compile(r"\(\w*:PyApplication \{name: \$app\}\)")
433+
434+
435+
def _scope_kind(statement: str) -> str | None:
436+
if _INTROSPECTION.match(statement):
437+
return "introspection"
438+
if _is_scoped(statement):
439+
return "prefix"
440+
if _ANCHORED_ON_THE_APPLICATION.search(statement):
441+
return "application"
442+
if _MATCHES_BY_ID.search(statement):
443+
return "id"
444+
return None
445+
446+
447+
def _every_statement() -> Dict[str, str]:
448+
"""Class-level statements (minus fragments) plus every inline one that resolved to Cypher."""
449+
inline = {name: s for name, s in _inline_statements().items() if s != "{…}"}
450+
return {**{n: s for n, s in _class_level_statements().items() if n not in _FRAGMENTS}, **inline}
451+
452+
348453
def test_the_audit_sees_the_dataflow_statements_too():
349454
names = set(_class_level_statements())
350455
for expected in ("_REACHES", "_CONE", "_PATHS", "_CALL_PATHS", "_VALUE_REACHES", "_CALLEE_VALUES", "_SOURCES", "_SLICE", "_CALLERS", "_CALLEES", "_OWN_EDGES", "_LOCATE_QUERY", "_OVERVIEW_PROJECTION"):
351456
assert expected in names, f"{expected} is not a class-level statement any more; move it back or extend the audit"
352457

353458

354-
@pytest.mark.parametrize("name", sorted(set(_class_level_statements()) - _FRAGMENTS))
459+
def test_the_audit_sees_every_inline_statement_too():
460+
"""One harvested statement per ``self._run(`` in the source -- a site the harvester cannot
461+
reassemble shows up as ``{…}`` and must be one of the three known indirections, so a new
462+
``self._run(some_variable)`` cannot slip past unjudged."""
463+
inline = _inline_statements()
464+
assert len(inline) == inspect.getsource(PyNeo4jBackend).count("self._run("), "a self._run( site the harvester did not see"
465+
indirect = sorted(name.split("@")[0] for name, s in inline.items() if s == "{…}")
466+
assert indirect == ["_children", "_collect", "_paths"], f"unjudged statements passed through a variable: {indirect}"
467+
assert all(re.match(r"(MATCH|OPTIONAL MATCH|UNWIND|CALL)\b", s.lstrip()) for s in inline.values() if s != "{…}"), "an inline statement does not start with a Cypher clause"
468+
for expected in ("get_source", "get_class", "get_python_file", "get_method_bodies", "resolve_value", "_call_rows", "_bounded_call_rows", "_get_module_functions", "get_callsites_for", "get_config_uses", "get_config_readers", "get_decorated_callables", "get_entrypoints", "get_entrypoint_classes", "_probe_resolution_edges", "_own_edges", "get_dependencies"):
469+
assert any(name.startswith(expected + "@") for name in inline), f"{expected}'s statement is not harvested"
470+
471+
472+
def test_no_statement_names_pycannode():
473+
"""1.4.1's ``:PyCanNode`` label was measured and rejected as a seek anchor (the spec's F2 table)
474+
and does not exist on 1.4.0 graphs; a statement naming it would match nothing there."""
475+
assert [name for name, s in _every_statement().items() if "PyCanNode" in s] == []
476+
477+
478+
@pytest.mark.parametrize("name", sorted(_every_statement()))
355479
def test_every_statement_is_application_scoped_or_keyed_by_an_application_stamped_id(name):
356480
"""Two ways a statement stays inside one application, and every statement must use one.
357481
@@ -361,14 +485,16 @@ def test_every_statement_is_application_scoped_or_keyed_by_an_application_stampe
361485
A body-node or ghost **id** embeds the application (``can://python/<app>/…``) and the emitter
362486
only ever links nodes from its own run, so a statement keyed *only* by id is scoped by
363487
construction and may omit the predicate -- ``_SLICE``, ``_PATHS`` and ``_VALUE_REACHES`` do,
364-
for the measured cost of testing 195,784 reached nodes against a list. A statement keyed by
365-
neither would be unscoped and fails here.
488+
for the measured cost of testing 195,784 reached nodes against a list. A statement anchored
489+
on ``(:PyApplication {name: $app})`` walks out from the application node and cannot leave it
490+
(the module list, artifacts, config keys, the entrypoint report). A statement keyed by none
491+
of these would be unscoped and fails here -- class-level and inline alike.
366492
"""
367-
statement = _class_level_statements()[name]
368-
by_signature, by_id, scoped = bool(_MATCHES_BY_SIGNATURE.search(statement)), bool(_MATCHES_BY_ID.search(statement)), _is_scoped(statement)
369-
assert scoped or by_id, f"{name} carries no application scope and is not keyed by an application-stamped id"
370-
if by_signature:
371-
assert scoped, f"{name} matches by signature without the application scope"
493+
statement = _every_statement()[name]
494+
kind = _scope_kind(statement)
495+
assert kind is not None, f"{name} carries no application scope and is not keyed by an application-stamped id: {statement[:160]!r}"
496+
if _MATCHES_BY_SIGNATURE.search(statement):
497+
assert kind == "prefix", f"{name} matches by signature without the application prefix"
372498

373499

374500
def test_the_overview_projection_is_only_ever_appended_to_a_scoped_match():

0 commit comments

Comments
 (0)