mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
225 lines
8.9 KiB
Python
225 lines
8.9 KiB
Python
"""Offline AX worker entry controls; HTTP is loopback only."""
|
|||
|
|
|
||
|
|
import importlib.util
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
import tarfile
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import urllib.error
|
||
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
SCRIPTS = Path(__file__).resolve().parents[1] / "benchmarks/learn"
|
||
|
|
|
||
|
|
|
||
|
|
def load(name):
|
||
|
|
with pytest.MonkeyPatch.context() as patch:
|
||
|
|
patch.syspath_prepend(str(SCRIPTS))
|
||
|
|
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
|
||
|
|
module = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(module)
|
||
|
|
return module
|
||
|
|
|
||
|
|
|
||
|
|
def test_engine_cluster_heap_flags(monkeypatch):
|
||
|
|
worker = load("engine_trace_worker")
|
||
|
|
monkeypatch.setenv("JAVA_TOOL_OPTIONS", "-Xmx9999m")
|
||
|
|
backend = worker.MeasuredBackend(30, 10, lambda: None, heap_profile="cluster")
|
||
|
|
flags = (
|
||
|
|
"-Xms1700m -Xmx1700m -XX:MaxMetaspaceSize=256m -XX:ReservedCodeCacheSize=128m "
|
||
|
|
"-XX:MaxDirectMemorySize=256m -Xss512k -XX:ActiveProcessorCount=1 -XX:+UseG1GC"
|
||
|
|
)
|
||
|
|
assert backend._jvm_env()["JAVA_TOOL_OPTIONS"] == flags
|
||
|
|
assert backend._heap_flag() == "-J-Xmx1700m"
|
||
|
|
assert worker.MeasuredBackend(30, 10, lambda: None)._heap_flag() == "-J-Xmx2560m"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("readiness", ["ready", "delayed", "closed"])
|
||
|
|
@pytest.mark.parametrize("failure", [False, True])
|
||
|
|
@pytest.mark.parametrize("transport", ["memory", "loopback"])
|
||
|
|
def test_task_entry_http_roundtrip(tmp_path, monkeypatch, failure, transport, readiness):
|
||
|
|
entry = load("engine_task_entry")
|
||
|
|
archive = io.BytesIO()
|
||
|
|
with tarfile.open(fileobj=archive, mode="w") as tar:
|
||
|
|
data = b"print('input proof')\n"
|
||
|
|
info = tarfile.TarInfo("app.py")
|
||
|
|
info.size = len(data)
|
||
|
|
tar.addfile(info, io.BytesIO(data))
|
||
|
|
pin = {"pin": "abc", "units": [{"unit": "u", "supported": True}], "questions": {"u": {}}}
|
||
|
|
uploads = []
|
||
|
|
gets = []
|
||
|
|
puts = []
|
||
|
|
started = time.monotonic()
|
||
|
|
|
||
|
|
class Handler(BaseHTTPRequestHandler):
|
||
|
|
def do_GET(self):
|
||
|
|
gets.append((self.path, time.monotonic() - started))
|
||
|
|
if readiness == "closed" or (readiness == "delayed" and gets[-1][1] < 3):
|
||
|
|
self.send_response(403 if len(gets) % 2 else 503)
|
||
|
|
self.end_headers()
|
||
|
|
return
|
||
|
|
data = archive.getvalue() if self.path == "/source" else json.dumps(pin).encode()
|
||
|
|
self.send_response(200)
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(data)
|
||
|
|
|
||
|
|
def do_PUT(self):
|
||
|
|
body = self.rfile.read(int(self.headers["Content-Length"]))
|
||
|
|
puts.append(time.monotonic())
|
||
|
|
if readiness == "closed" and len(puts) == 1:
|
||
|
|
self.send_response(403)
|
||
|
|
self.end_headers()
|
||
|
|
return
|
||
|
|
uploads.append(body)
|
||
|
|
self.send_response(200)
|
||
|
|
self.end_headers()
|
||
|
|
|
||
|
|
def log_message(self, *args):
|
||
|
|
pass
|
||
|
|
|
||
|
|
fake_worker = tmp_path / "worker.py"
|
||
|
|
fake_worker.write_text(
|
||
|
|
"import argparse,json,pathlib,sys\np=argparse.ArgumentParser()\n"
|
||
|
|
"for key in ['worker','root','output','deadline','question-deadline','heap-profile']: p.add_argument('--'+key)\n"
|
||
|
|
"a=p.parse_args()\nassert a.heap_profile == 'cluster'\n"
|
||
|
|
"assert (pathlib.Path(a.root)/'app.py').stat().st_size > 0\n"
|
||
|
|
+ ("sys.exit(7)\n" if failure else "pathlib.Path(a.output).write_text(json.dumps({'done':True,'units':[]}))\n")
|
||
|
|
)
|
||
|
|
if transport == "loopback":
|
||
|
|
try:
|
||
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||
|
|
except PermissionError:
|
||
|
|
pytest.skip("sandbox prohibits loopback sockets; in-memory HTTP roundtrip still runs")
|
||
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||
|
|
thread.start()
|
||
|
|
base = f"http://127.0.0.1:{server.server_port}"
|
||
|
|
else:
|
||
|
|
base = "https://fake-store.invalid"
|
||
|
|
|
||
|
|
def urlopen(request, timeout):
|
||
|
|
method = request.get_method() if hasattr(request, "get_method") else "GET"
|
||
|
|
url = request.full_url if hasattr(request, "full_url") else request
|
||
|
|
body = request.data or b"" if method == "PUT" else b""
|
||
|
|
raw = f"{method} {url.removeprefix(base)} HTTP/1.0\r\nContent-Length: {len(body)}\r\n\r\n".encode() + body
|
||
|
|
|
||
|
|
class Socket:
|
||
|
|
def __init__(self):
|
||
|
|
self.sent = bytearray()
|
||
|
|
|
||
|
|
def makefile(self, *args):
|
||
|
|
return io.BytesIO(raw)
|
||
|
|
|
||
|
|
def sendall(self, data):
|
||
|
|
self.sent.extend(data)
|
||
|
|
|
||
|
|
connection = Socket()
|
||
|
|
Handler(connection, ("127.0.0.1", 0), None)
|
||
|
|
headers, payload = bytes(connection.sent).split(b"\r\n\r\n", 1)
|
||
|
|
status = int(headers.split()[1])
|
||
|
|
if status != 200:
|
||
|
|
raise urllib.error.HTTPError(url, status, "denied", {}, io.BytesIO(payload))
|
||
|
|
return io.BytesIO(payload)
|
||
|
|
|
||
|
|
monkeypatch.setattr(entry.urllib.request, "urlopen", urlopen)
|
||
|
|
try:
|
||
|
|
for name, suffix in [("SOURCE_URL", "source"), ("QUESTIONS_URL", "questions"), ("RESULT_URL", "result")]:
|
||
|
|
monkeypatch.setenv(name, base + "/" + suffix)
|
||
|
|
budget = 0.7 if readiness == "closed" else 60
|
||
|
|
assert entry.main(work_root=tmp_path / "work", worker_script=fake_worker, readiness_budget=budget) == 0
|
||
|
|
if readiness == "delayed":
|
||
|
|
assert len(gets) >= 5
|
||
|
|
assert gets[-1][1] >= 3
|
||
|
|
if readiness == "closed":
|
||
|
|
assert 0.7 <= puts[0] - started < 1.5
|
||
|
|
assert len(puts) == 2
|
||
|
|
assert puts[1] - puts[0] >= 0.5
|
||
|
|
assert all(path == "/questions" for path, _ in gets)
|
||
|
|
finally:
|
||
|
|
if transport == "loopback":
|
||
|
|
server.shutdown()
|
||
|
|
thread.join()
|
||
|
|
server.server_close()
|
||
|
|
assert len(uploads) == 1
|
||
|
|
with tarfile.open(fileobj=io.BytesIO(uploads[0])) as tar:
|
||
|
|
result = json.load(tar.extractfile("result.json"))
|
||
|
|
assert result["done"]
|
||
|
|
if readiness == "closed":
|
||
|
|
assert result["status"] == "failed"
|
||
|
|
assert result["reason"] == "egress not open after 60 s"
|
||
|
|
assert json.loads((tmp_path / "work/out/result.json").read_text()) == result
|
||
|
|
elif failure:
|
||
|
|
assert result["units"][0]["status"] == "failed"
|
||
|
|
assert "7" in result["units"][0]["reason"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_engine_cluster_profile_recorded_on_input_failure(tmp_path):
|
||
|
|
worker = load("engine_trace_worker")
|
||
|
|
pin = {
|
||
|
|
"units": [{"unit": "u", "file": "missing.py", "language": "python", "supported": True, "reason": ""}],
|
||
|
|
"questions": {"u": {}},
|
||
|
|
}
|
||
|
|
result = worker.worker(pin, tmp_path, tmp_path / "result.json", 30, 10, heap_profile="cluster")
|
||
|
|
assert result["units"][0]["status"] == "failed"
|
||
|
|
assert result["instrument"]["heap_profile"] == "cluster"
|
||
|
|
assert result["instrument"]["heap_mb"] == 1700
|
||
|
|
assert result["instrument"]["java_tool_options"] == worker.CLUSTER_JVM_FLAGS
|
||
|
|
|
||
|
|
|
||
|
|
def test_task_entry_rejects_tar_traversal(tmp_path):
|
||
|
|
entry = load("engine_task_entry")
|
||
|
|
source = tmp_path / "source.tar"
|
||
|
|
with tarfile.open(source, "w") as archive:
|
||
|
|
member = tarfile.TarInfo("../escape")
|
||
|
|
member.size = 1
|
||
|
|
archive.addfile(member, io.BytesIO(b"x"))
|
||
|
|
with pytest.raises(ValueError, match="unsafe"):
|
||
|
|
entry.unpack(source, tmp_path / "case")
|
||
|
|
assert not (tmp_path / "escape").exists()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("error", [urllib.error.URLError("refused"), ConnectionResetError("reset")])
|
||
|
|
def test_readiness_connection_errors_back_off_to_cap(tmp_path, monkeypatch, error):
|
||
|
|
entry = load("engine_task_entry")
|
||
|
|
now = [0.0]
|
||
|
|
pauses = []
|
||
|
|
timeouts = []
|
||
|
|
|
||
|
|
def pause(seconds):
|
||
|
|
pauses.append(seconds)
|
||
|
|
now[0] += seconds
|
||
|
|
|
||
|
|
def refused(request, timeout):
|
||
|
|
timeouts.append(timeout)
|
||
|
|
raise error
|
||
|
|
|
||
|
|
monkeypatch.setattr(entry.time, "monotonic", lambda: now[0])
|
||
|
|
monkeypatch.setattr(entry.time, "sleep", pause)
|
||
|
|
monkeypatch.setattr(entry.urllib.request, "urlopen", refused)
|
||
|
|
with pytest.raises(RuntimeError, match="^egress not open after 60 s$"):
|
||
|
|
entry.download("https://fake.invalid", tmp_path / "input", readiness_budget=60)
|
||
|
|
assert now[0] == 60
|
||
|
|
assert pauses[:5] == [0.5, 1, 2, 4, 8]
|
||
|
|
assert max(pauses) == 8
|
||
|
|
assert timeouts[-1] == pauses[-1]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("status, attempts", [(403, 3), (503, 3), (404, 1)])
|
||
|
|
def test_later_transfers_keep_short_retry(monkeypatch, status, attempts):
|
||
|
|
entry = load("engine_task_entry")
|
||
|
|
calls = []
|
||
|
|
pauses = []
|
||
|
|
|
||
|
|
def refused(request, timeout):
|
||
|
|
calls.append(timeout)
|
||
|
|
raise urllib.error.HTTPError(request, status, "denied", {}, io.BytesIO())
|
||
|
|
|
||
|
|
monkeypatch.setattr(entry.time, "sleep", pauses.append)
|
||
|
|
monkeypatch.setattr(entry.urllib.request, "urlopen", refused)
|
||
|
|
with pytest.raises(urllib.error.HTTPError):
|
||
|
|
entry.transfer("https://fake.invalid")
|
||
|
|
assert len(calls) == attempts
|
||
|
|
assert pauses == list(range(1, attempts))
|