Files
Open_Ultra_SAST/tests/test_cpg_session.py
T

320 lines
14 KiB
Python
Raw Normal View History

"""M2 11.1: the session is a transport whose failures are never answers."""
from __future__ import annotations
import os
import signal
import sys
import textwrap
import time
from pathlib import Path
import pytest
from openultrasast.cpg.session import EngineSession, _redirected
from openultrasast.model.contracts import ExecutionBudget
FAKE = textwrap.dedent(
"""
import json, os, sys, time
from http.server import BaseHTTPRequestHandler, HTTPServer
MODE = os.environ.get("FAKE_MODE", "ok")
PORT = int(sys.argv[sys.argv.index("--server-port") + 1])
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
code = body["query"]
if MODE == "hang" and "ousastHang" in code:
time.sleep(120)
if MODE == "crash" and "cpg." in code:
os._exit(9)
# Honour the redirection wrapper by extracting its receipt and target.
if MODE == "noreceipt":
answer = json.dumps({"success": True, "stdout": "DIAGNOSTIC: not compiled"}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
return
if MODE in ("ok", "empty", "wrongreceipt") and "__ousastPath" in code:
target = code.split('java.nio.file.Path.of("', 1)[1].split('"', 1)[0]
nonce = code.split('__ousastPath, "', 1)[1].split(chr(92) + "n", 1)[0]
payload = "" if MODE == "empty" else "PAYLOAD-" + nonce
written = ("BAD" if MODE == "wrongreceipt" else nonce) + "\\n" + payload
with open(target, "w") as handle:
handle.write(written)
answer = json.dumps({"success": MODE != "unsuccessful", "stdout": ""}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
"""
)
@pytest.fixture
def fake(tmp_path):
script = tmp_path / "fake-joern"
script.write_text("#!" + sys.executable + "\n" + FAKE)
script.chmod(0o755)
return script
def session(tmp_path, fake, *, mode="ok", seconds=30.0, allowance=2.0):
return EngineSession(
tmp_path / "scratch",
ExecutionBudget(time.monotonic() + seconds, allowance),
{**os.environ, "FAKE_MODE": mode},
joern=str(fake),
startup_timeout_seconds=30.0,
)
def test_a_started_session_answers_only_with_its_own_receipt(tmp_path, fake):
with session(tmp_path, fake) as live:
assert live.start() and live.alive and live.startup_seconds > 0
answer = live.evaluate("cpg.file.size", timeout=10)
assert answer is not None and answer.stdout.startswith("PAYLOAD-")
assert live.failure == "" and live.requests == 1
# The private answer file is removed once verified, so no later call can reread it.
assert not list((tmp_path / "scratch").glob("answer-*.txt"))
def test_a_mismatched_receipt_poisons_the_session(tmp_path, fake):
with session(tmp_path, fake, mode="wrongreceipt") as live:
assert live.start()
assert live.evaluate("cpg.file.size", timeout=10) is None
assert live.failure == "session_receipt_mismatch"
# Poisoned means poisoned: no later request may answer from an unknown state.
assert live.evaluate("cpg.method.size", timeout=10) is None and not live.alive
def test_an_unsuccessful_response_is_not_an_answer(tmp_path, fake):
with session(tmp_path, fake, mode="unsuccessful") as live:
assert live.start()
assert live.evaluate("cpg.file.size", timeout=10) is None
assert live.failure == "session_request_unsuccessful"
def test_an_empty_body_is_an_answer_only_when_its_receipt_is_present(tmp_path, fake):
with session(tmp_path, fake, mode="empty") as live:
assert live.start()
answer = live.evaluate("cpg.file.size", timeout=10)
assert answer is not None and answer.stdout == ""
assert live.failure == ""
def test_a_crashed_session_answers_nothing_afterwards(tmp_path, fake):
with session(tmp_path, fake, mode="crash") as live:
assert live.start()
assert live.evaluate("cpg.file.size", timeout=10) is None
assert live.evaluate("1", timeout=10) is None
assert live.failure
def test_cancellation_kills_the_process_group(tmp_path, fake):
live = session(tmp_path, fake, mode="hang", seconds=3.0)
assert live.start()
pid = live._process.pid
started = time.monotonic()
assert live.evaluate("ousastHang()", timeout=1.0) is None
live.close()
assert time.monotonic() - started < 6.0
with pytest.raises(ProcessLookupError):
for _ in range(100):
os.killpg(pid, 0)
time.sleep(0.02)
def test_an_exhausted_deadline_starts_nothing(tmp_path, fake):
live = session(tmp_path, fake, seconds=0.0)
assert live.start() is False and live.failure == "deadline_exhausted"
assert live._process is None
live.close()
def test_load_records_the_resident_graph_and_forgets_it_on_failure(tmp_path, fake):
with session(tmp_path, fake) as live:
assert live.start()
graph = tmp_path / "cpg.bin"
assert live.load(graph, timeout=10) and live.loaded_graph == str(graph)
with session(tmp_path, fake, mode="wrongreceipt") as broken:
assert broken.start()
assert broken.load(tmp_path / "other.bin", timeout=10) is False
assert broken.loaded_graph == ""
def test_the_wrapper_writes_its_receipt_before_running_the_body():
code = _redirected("cpg.file.size", Path("/tmp/x.txt"), "abc")
assert code.index("writeString") < code.index("scala.Console.withOut")
assert '"abc\\n"' in code and "/tmp/x.txt" in code
# The Joern REPL shadows `Console`, so an unqualified name compiles to the wrong object.
assert "scala.Console.withOut" in code
def test_a_launch_failure_is_recorded_not_raised(tmp_path):
live = EngineSession(tmp_path / "s", ExecutionBudget(time.monotonic() + 10, 1.0), dict(os.environ), joern=str(tmp_path / "absent"))
assert live.start() is False and live.failure.startswith("session_launch_failed")
live.close()
def test_signal_module_is_used_for_group_termination():
assert signal.SIGKILL
def test_a_definition_is_sent_unwrapped_and_is_not_proof_of_anything(tmp_path, fake):
"""M2 11.1: `@main` cannot be nested, and the server answers success for compile errors."""
with session(tmp_path, fake) as live:
assert live.start()
sent = live.define("@main def exec(cpgFile: String) = println(1)", timeout=10)
# The fake only writes a receipt file for wrapped requests, so an unwrapped one has none.
assert sent is not None and live.failure == ""
assert not list((tmp_path / "scratch").glob("answer-*.txt"))
def test_a_definition_on_a_dead_session_is_refused(tmp_path, fake):
with session(tmp_path, fake, mode="wrongreceipt") as live:
assert live.start()
assert live.evaluate("cpg.file.size", timeout=10) is None
assert live.define("def x = 1", timeout=10) is None
def test_the_engine_body_is_retained_so_a_missing_receipt_can_be_explained(tmp_path, fake):
"""M2 11.1: success with no receipt is a compile error, and the diagnostic is the only clue."""
with session(tmp_path, fake, mode="noreceipt") as live:
assert live.start()
assert live.evaluate("bogus(", timeout=10) is None
assert live.failure == "session_answer_missing"
assert live.last_body == "DIAGNOSTIC: not compiled"
def test_the_backend_leaves_the_session_off_by_default(tmp_path):
"""M2 11.3: shipped behaviour stays the disposable invocation that has been measured."""
from openultrasast.cpg.backend import JoernBackend
backend = JoernBackend()
assert backend.session_transport is False
assert backend._session_for(tmp_path / "cpg.bin") is None
assert backend._session_payload(tmp_path / "cpg.bin", "census", {"cpgFile": "x"}) is None
def test_a_session_that_cannot_start_falls_back_rather_than_answering(tmp_path):
from openultrasast.cpg.backend import JoernBackend
backend = JoernBackend(
session_transport=True,
execution_budget=ExecutionBudget(time.monotonic() + 10, 1.0),
)
backend.__dict__["_session"] = None
# No such engine: starting fails, the reason is recorded, and the caller gets None to fall back.
object.__setattr__(backend, "queries_dir", Path("/nonexistent"))
assert backend._session_payload(tmp_path / "cpg.bin", "census", {"cpgFile": "x"}) is None
assert any("session_unavailable" in note or "session" in note for note in backend._diagnostics) or backend._diagnostics == []
backend.close_session()
def test_an_overlay_that_saves_no_graph_keeps_the_raw_one(tmp_path, monkeypatch):
"""M2 11.4: reported success with no saved graph must not read as an applied overlay."""
from openultrasast.cpg.backend import JoernBackend
backend = JoernBackend(execution_budget=ExecutionBudget(time.monotonic() + 30, 1.0))
graph = tmp_path / "cpg.bin"
graph.write_bytes(b"raw")
scratch = tmp_path / "scratch"
scratch.mkdir()
# The engine answers, but nothing lands in any workspace.
monkeypatch.setattr("openultrasast.cpg.backend.shutil.which", lambda name: "/usr/bin/" + name)
monkeypatch.setattr(JoernBackend, "_run", lambda *a, **k: _completed('---OUSAST-CPG-BEGIN---\n{"files":"1"}\n---OUSAST-CPG-END---'))
assert backend._apply_overlays(graph, scratch) is False
assert graph.read_bytes() == b"raw"
assert "overlay_saved_graph_missing" in backend._diagnostics
def test_an_overlay_saved_under_the_session_workspace_is_found(tmp_path, monkeypatch):
from openultrasast.cpg.backend import JoernBackend
from openultrasast.cpg.session import EngineSession
backend = JoernBackend(session_transport=True, execution_budget=ExecutionBudget(time.monotonic() + 30, 1.0))
graph = tmp_path / "cpg.bin"
graph.write_bytes(b"raw")
scratch = tmp_path / "scratch"
scratch.mkdir()
session_scratch = tmp_path / "session"
saved = session_scratch / "workspace" / graph.name
saved.mkdir(parents=True)
(saved / "cpg.bin").write_bytes(b"overlaid")
live = EngineSession(session_scratch, ExecutionBudget(time.monotonic() + 30, 1.0), {})
monkeypatch.setattr(JoernBackend, "_session_for", lambda self, path: live)
monkeypatch.setattr(
JoernBackend, "_session_payload", lambda self, path, query, params: '---OUSAST-CPG-BEGIN---\n{"files":"1"}\n---OUSAST-CPG-END---'
)
assert backend._apply_overlays(graph, scratch) is True
assert graph.read_bytes() == b"overlaid"
def _completed(stdout):
import subprocess
return subprocess.CompletedProcess(["joern"], 0, stdout, "")
def test_the_session_switch_is_explicit_and_defaults_off(monkeypatch):
"""M2 11.3: a measured run can enable the transport without changing any default."""
from openultrasast.cpg.backend import SESSION_ENV, JoernBackend
monkeypatch.delenv(SESSION_ENV, raising=False)
assert JoernBackend().session_transport is False
for value in ("", "0", "true", "yes", "2"):
monkeypatch.setenv(SESSION_ENV, value)
assert JoernBackend().session_transport is False, value
monkeypatch.setenv(SESSION_ENV, "1")
assert JoernBackend().session_transport is True
# An explicit argument still wins over the environment in both directions.
assert JoernBackend(session_transport=False).session_transport is False
def test_a_transport_failure_carries_the_server_log(tmp_path, fake):
"""M2: `session_request_failed` alone cannot tell a crash from an OOM kill."""
with session(tmp_path, fake) as live:
assert live.start()
(tmp_path / "scratch" / "session.log").write_text("java.lang.OutOfMemoryError: Java heap space\n")
live._port = 1 # nothing is listening there
assert live.evaluate("cpg.file.size", timeout=2) is None
assert live.failure == "session_request_failed"
assert "OutOfMemoryError" in live.last_body
assert "OutOfMemoryError" in live.log_tail()
def test_a_build_closes_the_session_it_started(tmp_path, fake, monkeypatch):
"""M2: an unclosed session leaks a JVM holding the configured heap for the whole process."""
from openultrasast.cpg.backend import JoernBackend
closed: list[str] = []
monkeypatch.setattr(EngineSession, "start", lambda self: True)
monkeypatch.setattr(EngineSession, "close", lambda self: closed.append(str(self.scratch)))
backend = JoernBackend(session_transport=True)
# The build runs on a copy, so the session it starts is the copy's to close.
monkeypatch.setattr(JoernBackend, "_build_impl", lambda self, root, **k: self._session_for(tmp_path / "cpg.bin"))
backend.build(tmp_path, execution_budget=ExecutionBudget(time.monotonic() + 20, 1.0))
assert closed, "the build copy left its session open, which leaks a JVM holding the heap"
assert backend._session is None
def test_the_driver_ends_the_session_with_the_transaction(tmp_path):
from openultrasast.cpg.backend import JoernBackend
from openultrasast.model.scan import scan_repository
backend = JoernBackend()
closed: list[bool] = []
backend.close_session = lambda: closed.append(True) # type: ignore[method-assign]
(tmp_path / "a.js").write_text("const x = 1;\n")
scan_repository(tmp_path, [], backend=backend)
assert closed == [True]