2026-10-05 17:12:46 +02:00
|
|
|
"""Offline operator storage/egress contracts, using real local child processes."""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
2026-10-05 20:40:13 +02:00
|
|
|
import signal
|
2026-10-05 17:12:46 +02:00
|
|
|
import sys
|
2026-10-05 20:40:13 +02:00
|
|
|
import time
|
2026-10-05 17:12:46 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from openultrasast.search import executor, task_storage, verify_task
|
|
|
|
|
from openultrasast.search.verify import Side, verify_side_task
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def spec():
|
|
|
|
|
return {
|
|
|
|
|
"demo": {
|
2026-10-05 21:07:58 +02:00
|
|
|
"oracle": "path",
|
2026-10-05 17:12:46 +02:00
|
|
|
"build": {"recipe": "none", "arguments": []},
|
|
|
|
|
"start": {"runtime": "python", "path": "app.py", "arguments": [], "mode": "cli"},
|
|
|
|
|
"steps": [{"type": "cli", "arguments": []}],
|
|
|
|
|
},
|
|
|
|
|
"family": "path",
|
|
|
|
|
"timeout_seconds": 2,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def workspace(tmp_path, monkeypatch):
|
|
|
|
|
root = tmp_path / "workspace"
|
|
|
|
|
root.mkdir()
|
|
|
|
|
monkeypatch.setattr(task_storage, "WORKSPACE", root)
|
|
|
|
|
# Keep global tempfile state and environment changes local to this test.
|
|
|
|
|
monkeypatch.setattr(task_storage.tempfile, "tempdir", str(tmp_path))
|
|
|
|
|
monkeypatch.setattr(os, "environ", dict(os.environ))
|
|
|
|
|
task_storage.configure()
|
|
|
|
|
return root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_workspace_environment_and_cached_tempdir(workspace):
|
|
|
|
|
env = executor.clean_environment()
|
|
|
|
|
assert env["TMPDIR"] == str(workspace / "tmp")
|
|
|
|
|
assert task_storage.tempfile.gettempdir() == env["TMPDIR"]
|
|
|
|
|
for key, suffix in {
|
|
|
|
|
"npm_config_cache": ".npm",
|
|
|
|
|
"COMPOSER_HOME": ".composer",
|
|
|
|
|
"PIP_CACHE_DIR": ".pip",
|
|
|
|
|
"GRADLE_USER_HOME": ".gradle",
|
|
|
|
|
}.items():
|
|
|
|
|
assert env[key] == str(workspace / suffix)
|
|
|
|
|
assert env["MAVEN_OPTS"] == "-Dmaven.repo.local=" + str(workspace / ".m2")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("wait", [False, True])
|
|
|
|
|
def test_executor_checks_caches_and_stops_at_scratch_limit(workspace, monkeypatch, wait):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "input").write_text("readable-input")
|
|
|
|
|
monkeypatch.setenv("OUSAST_SCRATCH_BYTES", "20000")
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
code = (
|
|
|
|
|
"from pathlib import Path; import os,time; print(Path('input').stat().st_size); "
|
|
|
|
|
"p=Path(os.environ['PIP_CACHE_DIR']); p.mkdir(); (p/'large').write_bytes(b'x'*30000); " + ("time.sleep(10)" if wait else "")
|
|
|
|
|
)
|
|
|
|
|
result = app.submit("run", {"command": [sys.executable, "-c", code]}, timeout_seconds=3)
|
2026-10-05 17:58:27 +02:00
|
|
|
assert result["status"] == "could_not_build" and result["reason"] == "ScratchLimit: scratch limit"
|
2026-10-05 17:12:46 +02:00
|
|
|
assert result["stopped"] and app.stopped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_executor_exports_built_input_and_verifier_needs_no_build(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "app.py").write_text("print('safe')")
|
|
|
|
|
blobs = []
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
2026-10-06 08:18:34 +02:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
executor.URLTransport, "put", lambda self, url, data, timeout: blobs.append(data.read() if hasattr(data, "read") else data)
|
|
|
|
|
)
|
2026-10-05 17:12:46 +02:00
|
|
|
result = app.submit("prepare_verification", spec())
|
|
|
|
|
assert result["status"] == "ok", result
|
|
|
|
|
destination = workspace / "unpacked"
|
|
|
|
|
verify_task.extract_checkout(blobs[0], destination)
|
|
|
|
|
assert (destination / "checkout/app.py").read_text() == "print('safe')"
|
|
|
|
|
assert json.loads((destination / "spec.json").read_text()) == spec()
|
|
|
|
|
assert (destination / "products").is_dir()
|
|
|
|
|
demo = workspace / "demo"
|
|
|
|
|
demo.mkdir()
|
|
|
|
|
(demo / "demo.json").write_text(json.dumps(spec()["demo"]))
|
|
|
|
|
observation = verify_side_task(Side(destination / "checkout", products=destination / "products"), demo, "path")
|
|
|
|
|
assert observation.outcome == "observed", observation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_verifier_refuses_build_recipe():
|
|
|
|
|
value = spec()
|
|
|
|
|
value["demo"]["build"] = {"recipe": "npm", "arguments": ["project"]}
|
|
|
|
|
with pytest.raises(ValueError, match="builds are forbidden"):
|
|
|
|
|
verify_task.validate_spec(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_first_download_retries_for_60_seconds(monkeypatch):
|
|
|
|
|
import urllib.error
|
|
|
|
|
|
|
|
|
|
now, timeouts = [0.0], []
|
|
|
|
|
|
|
|
|
|
class Opener:
|
|
|
|
|
def open(self, url, timeout):
|
|
|
|
|
timeouts.append(timeout)
|
|
|
|
|
raise urllib.error.HTTPError(url, 403, "pending policy", {}, None)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(verify_task.urllib.request, "build_opener", lambda *a: Opener())
|
|
|
|
|
monkeypatch.setattr(verify_task.time, "monotonic", lambda: now[0])
|
|
|
|
|
monkeypatch.setattr(verify_task.time, "sleep", lambda seconds: now.__setitem__(0, now[0] + seconds))
|
|
|
|
|
with pytest.raises(OSError, match="egress not ready"):
|
|
|
|
|
verify_task.download("https://files.because-security.com/input", 100)
|
|
|
|
|
assert now[0] == 60 and len(timeouts) > 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_verifier_reports_scratch_limit(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
# Writes happen after readiness, in a package cache outside the checkout.
|
|
|
|
|
(repo / "app.py").write_text(
|
|
|
|
|
"import sys,os\nfrom pathlib import Path\n"
|
|
|
|
|
"if '--ready' not in sys.argv:\n"
|
|
|
|
|
" p=Path(os.environ['npm_config_cache']); p.mkdir(exist_ok=True); (p/'large').write_bytes(b'x'*30000)\n"
|
|
|
|
|
)
|
|
|
|
|
demo = workspace / "demo"
|
|
|
|
|
demo.mkdir()
|
|
|
|
|
(demo / "demo.json").write_text(json.dumps(spec()["demo"]))
|
|
|
|
|
monkeypatch.setenv("OUSAST_SCRATCH_BYTES", "25000")
|
|
|
|
|
result = verify_side_task(Side(repo), demo, "path")
|
2026-10-05 17:58:27 +02:00
|
|
|
assert result.outcome == "could_not_build" and result.reason == "ScratchLimit: scratch limit", result
|
2026-10-05 17:12:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_preparation_builds_before_export_and_exports_products(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "app.py").write_text("import dependency; print(dependency.VALUE)")
|
|
|
|
|
(repo / "requirements.txt").write_text("dependency==1")
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
|
|
|
|
blobs, commands = [], []
|
2026-10-06 08:18:34 +02:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
executor.URLTransport, "put", lambda self, url, data, timeout: blobs.append(data.read() if hasattr(data, "read") else data)
|
|
|
|
|
)
|
2026-10-05 17:12:46 +02:00
|
|
|
|
2026-10-05 21:37:56 +02:00
|
|
|
def build(argv, timeout, limits, **kwargs):
|
2026-10-05 17:12:46 +02:00
|
|
|
commands.append(argv)
|
|
|
|
|
products = Path(argv[argv.index("--target") + 1])
|
|
|
|
|
assert products.is_relative_to(workspace)
|
|
|
|
|
products.mkdir()
|
|
|
|
|
(products / "dependency.py").write_text("VALUE = 42")
|
|
|
|
|
return {"exit_code": 0, "timed_out": False, "stderr": ""}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(app, "_task_run", build)
|
|
|
|
|
request = spec()
|
|
|
|
|
request["demo"]["build"] = {"recipe": "pip", "arguments": ["requirements.txt"]}
|
|
|
|
|
result = app.submit("prepare_verification", request)
|
|
|
|
|
assert result["status"] == "ok", result
|
|
|
|
|
assert len(commands) == 1 and "--no-index" not in commands[0]
|
|
|
|
|
unpacked = workspace / "unpacked"
|
|
|
|
|
verify_task.extract_checkout(blobs[0], unpacked)
|
|
|
|
|
assert (unpacked / "products/packages/dependency.py").read_text() == "VALUE = 42"
|
|
|
|
|
assert json.loads((unpacked / "spec.json").read_text())["demo"]["build"] == {"recipe": "none", "arguments": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_open_logs_count_toward_scratch_guard(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
monkeypatch.setenv("OUSAST_SCRATCH_BYTES", "20000")
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
result = app.submit("run", {"command": [sys.executable, "-c", "print('x'*30000)"]})
|
2026-10-05 17:58:27 +02:00
|
|
|
assert result["status"] == "could_not_build" and result["reason"] == "ScratchLimit: scratch limit"
|
2026-10-05 17:12:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unreadable_scratch_is_not_reported_as_empty(tmp_path):
|
|
|
|
|
with pytest.raises(OSError, match="scratch root unavailable"):
|
|
|
|
|
task_storage.check(tmp_path / "missing")
|
2026-10-05 17:58:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pip_removing_enumerated_directory_is_not_guard_failure(workspace, monkeypatch):
|
|
|
|
|
original = task_storage.os.walk
|
|
|
|
|
|
|
|
|
|
def disappearing(root, **kwargs):
|
|
|
|
|
kwargs["onerror"](FileNotFoundError(2, "removed pip build directory", str(root / "pip-build")))
|
|
|
|
|
yield from original(root, **kwargs)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(task_storage.os, "walk", disappearing)
|
|
|
|
|
(workspace / "input").write_bytes(b"input")
|
|
|
|
|
assert task_storage.check(workspace) >= 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_guard_still_refuses_permission_errors(workspace, monkeypatch):
|
|
|
|
|
def unreadable(root, **kwargs):
|
|
|
|
|
kwargs["onerror"](PermissionError("permission denied"))
|
|
|
|
|
return iter(())
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(task_storage.os, "walk", unreadable)
|
|
|
|
|
with pytest.raises(PermissionError):
|
|
|
|
|
task_storage.check(workspace)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_child_environment_has_disk_home_and_owned_runtime_path(workspace):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "input").write_bytes(b"proof")
|
|
|
|
|
code = (
|
|
|
|
|
"import os; from pathlib import Path; print(Path('input').stat().st_size); "
|
|
|
|
|
"print(os.environ['PATH']); p=Path.home()/'probe'; p.write_text('ok'); print(p)"
|
|
|
|
|
)
|
|
|
|
|
result = executor.InProcessExecutor(repo, task_boundary=True).submit("run", {"command": [sys.executable, "-c", code]})
|
|
|
|
|
assert result["exit_code"] == 0
|
|
|
|
|
assert result["stdout"].startswith("5\n/venv/bin:")
|
|
|
|
|
assert (workspace / "home/probe").read_text() == "ok"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scratch_failure_preserves_exit_and_stderr(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
monkeypatch.setenv("OUSAST_SCRATCH_BYTES", "20000")
|
|
|
|
|
code = (
|
|
|
|
|
"import sys,time; from pathlib import Path; print('pip build detail',file=sys.stderr,flush=True); "
|
|
|
|
|
"Path('big').write_bytes(b'x'*30000); time.sleep(10)"
|
|
|
|
|
)
|
|
|
|
|
result = executor.InProcessExecutor(repo, task_boundary=True).submit("run", {"command": [sys.executable, "-c", code]})
|
|
|
|
|
assert result["phase"] == "scratch guard"
|
|
|
|
|
assert result["exit_code"] is not None
|
|
|
|
|
assert result["stderr"] == "pip build detail"
|
|
|
|
|
assert result["reason"].startswith("ScratchLimit:")
|
2026-10-05 20:40:13 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("project", [".", "subproject"])
|
|
|
|
|
def test_preparation_uses_project_gradle_wrapper(workspace, monkeypatch, project):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
root = repo / project
|
|
|
|
|
root.mkdir(exist_ok=True)
|
|
|
|
|
# A real local child proves wrapper execution without system Gradle or network.
|
|
|
|
|
wrapper = root / "gradlew"
|
|
|
|
|
wrapper.write_text(
|
|
|
|
|
'test "$1" = "--no-daemon" || exit 1\n'
|
|
|
|
|
'test "$2" = "--project-dir" || exit 2\n'
|
|
|
|
|
'test "$4" = "assemble" || exit 3\n'
|
|
|
|
|
'mkdir -p "$3/build"\n'
|
|
|
|
|
'printf compiled > "$3/build/product"\n'
|
|
|
|
|
)
|
|
|
|
|
wrapper.chmod(0o644)
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
|
|
|
|
blobs = []
|
2026-10-06 08:18:34 +02:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
executor.URLTransport, "put", lambda self, url, data, timeout: blobs.append(data.read() if hasattr(data, "read") else data)
|
|
|
|
|
)
|
2026-10-05 20:40:13 +02:00
|
|
|
request = spec()
|
|
|
|
|
request["demo"]["build"] = {"recipe": "gradle", "arguments": [project]}
|
|
|
|
|
result = app.submit("prepare_verification", request)
|
|
|
|
|
assert result["status"] == "ok", result
|
|
|
|
|
unpacked = workspace / "unpacked"
|
|
|
|
|
verify_task.extract_checkout(blobs[0], unpacked)
|
|
|
|
|
assert (unpacked / "checkout" / project / "build/product").read_text() == "compiled"
|
|
|
|
|
assert json.loads((unpacked / "spec.json").read_text())["demo"]["build"]["recipe"] == "none"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_preparation_with_many_host_processes(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "gradlew").write_text('mkdir -p "$3/build"\nprintf compiled > "$3/build/product"\n')
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
|
|
|
|
monkeypatch.setattr(executor.URLTransport, "put", lambda self, url, data, timeout: None)
|
|
|
|
|
request = spec()
|
|
|
|
|
request["demo"]["build"] = {"recipe": "gradle", "arguments": ["."]}
|
|
|
|
|
children = []
|
|
|
|
|
try:
|
|
|
|
|
for _ in range(300):
|
|
|
|
|
pid = os.fork()
|
|
|
|
|
if pid == 0:
|
|
|
|
|
time.sleep(60)
|
|
|
|
|
os._exit(0)
|
|
|
|
|
children.append(pid)
|
|
|
|
|
result = app.submit("prepare_verification", request)
|
|
|
|
|
assert result["status"] == "ok", result
|
|
|
|
|
assert (repo / "build/product").read_text() == "compiled"
|
|
|
|
|
finally:
|
|
|
|
|
for pid in children:
|
|
|
|
|
os.kill(pid, signal.SIGKILL)
|
|
|
|
|
for pid in children:
|
|
|
|
|
os.waitpid(pid, 0)
|
2026-10-05 21:07:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("memory_bytes", [None, 256 * 1024**2, 5 * 1024**3])
|
|
|
|
|
def test_prepare_build_virtual_memory(workspace, monkeypatch, memory_bytes):
|
|
|
|
|
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
source = "import mmap; region=mmap.mmap(-1,512*1024**2); print(len(region))"
|
|
|
|
|
(repo / "app.py").write_text(source)
|
|
|
|
|
print("input_bytes=", len((repo / "app.py").read_bytes()))
|
2026-10-05 21:37:56 +02:00
|
|
|
(repo / "package.json").write_text("{}")
|
|
|
|
|
fake_bin = workspace / "bin"
|
|
|
|
|
fake_bin.mkdir()
|
|
|
|
|
tool = fake_bin / "npm"
|
|
|
|
|
tool.write_text("#!" + sys.executable + "\n" + source)
|
|
|
|
|
tool.chmod(0o755)
|
|
|
|
|
monkeypatch.setitem(executor.SAFE_ENV, "PATH", str(fake_bin) + ":/usr/bin:/bin")
|
2026-10-05 21:07:58 +02:00
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
|
|
|
|
blobs = []
|
2026-10-06 08:18:34 +02:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
executor.URLTransport, "put", lambda self, url, data, timeout: blobs.append(data.read() if hasattr(data, "read") else data)
|
|
|
|
|
)
|
2026-10-05 21:07:58 +02:00
|
|
|
request = spec()
|
|
|
|
|
request["demo"]["build"] = {"recipe": "npm", "arguments": ["."]}
|
|
|
|
|
limits = {} if memory_bytes is None else {"memory_bytes": memory_bytes}
|
|
|
|
|
result = app.submit("prepare_verification", request, limits=limits)
|
|
|
|
|
if memory_bytes == 256 * 1024**2:
|
|
|
|
|
assert result["status"] == "could_not_build" and not blobs
|
|
|
|
|
else:
|
|
|
|
|
assert result["status"] == "ok" and blobs, result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_verifier_app_virtual_memory(workspace):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "app.py").write_text("import mmap; region=mmap.mmap(-1,512*1024**2); print(len(region))")
|
|
|
|
|
print("input_bytes=", len((repo / "app.py").read_bytes()))
|
|
|
|
|
demo = workspace / "demo.json"
|
|
|
|
|
demo.write_text(json.dumps(spec()["demo"]))
|
|
|
|
|
result = verify_side_task(Side(repo), demo, "path")
|
|
|
|
|
assert result.outcome == "observed", result
|
2026-10-05 21:37:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"recipe,manifest,extra,expected",
|
|
|
|
|
[
|
2026-10-06 11:15:09 +02:00
|
|
|
(
|
|
|
|
|
"pip",
|
|
|
|
|
"pyproject.toml",
|
|
|
|
|
{},
|
|
|
|
|
["-I", "-m", "pip", "install", "--ignore-installed", "--no-warn-script-location", "--target", "PACKAGES", "PROJECT"],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"pip",
|
|
|
|
|
"requirements.txt",
|
|
|
|
|
{},
|
|
|
|
|
["-I", "-m", "pip", "install", "--ignore-installed", "--no-warn-script-location", "--target", "PACKAGES", "-r", "MANIFEST"],
|
|
|
|
|
),
|
2026-10-05 21:37:56 +02:00
|
|
|
(
|
|
|
|
|
"pip",
|
|
|
|
|
"pyproject.toml",
|
|
|
|
|
{"requirements.txt": ""},
|
2026-10-06 11:15:09 +02:00
|
|
|
["-I", "-m", "pip", "install", "--ignore-installed", "--no-warn-script-location", "--target", "PACKAGES", "-r", "REQUIREMENTS"],
|
2026-10-05 21:37:56 +02:00
|
|
|
),
|
|
|
|
|
("npm", "package.json", {}, ["install", "--ignore-scripts"]),
|
|
|
|
|
("npm", "package.json", {"package-lock.json": "{}"}, ["ci", "--ignore-scripts"]),
|
|
|
|
|
("composer", "composer.json", {}, ["install", "--no-interaction", "--no-plugins", "--no-scripts"]),
|
|
|
|
|
("maven", "pom.xml", {}, ["-q", "-DskipTests", "package", "-f", "MANIFEST"]),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
@pytest.mark.parametrize("directory", [False, True])
|
|
|
|
|
def test_recipe_command_with_real_fake_tool(workspace, monkeypatch, recipe, manifest, extra, expected, directory):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
project = repo / "project"
|
|
|
|
|
project.mkdir(parents=True)
|
|
|
|
|
(repo / "app.py").write_text("print('ready')")
|
|
|
|
|
contents = '[build-system]\nbuild-backend="setuptools.build_meta"\n' if manifest == "pyproject.toml" and not extra else ""
|
|
|
|
|
(project / manifest).write_text(contents)
|
|
|
|
|
for name, content in extra.items():
|
|
|
|
|
(project / name).write_text(content)
|
|
|
|
|
tools = workspace / "bin"
|
|
|
|
|
tools.mkdir()
|
|
|
|
|
tool = tools / {"pip": "python3", "maven": "mvn"}.get(recipe, recipe)
|
|
|
|
|
tool.write_text(
|
|
|
|
|
"#!" + sys.executable + "\nimport json, os, sys\nfrom pathlib import Path\n"
|
|
|
|
|
"Path('invocation.json').write_text(json.dumps([sys.argv[1:], os.getcwd()]))\n"
|
|
|
|
|
)
|
|
|
|
|
tool.chmod(0o755)
|
|
|
|
|
monkeypatch.setitem(executor.SAFE_ENV, "PATH", str(tools) + ":/usr/bin:/bin")
|
2026-10-06 14:37:58 +02:00
|
|
|
if recipe == "pip":
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
from openultrasast.search import demo
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(demo, "sys", SimpleNamespace(executable=str(tool)))
|
2026-10-05 21:37:56 +02:00
|
|
|
monkeypatch.setattr(executor.URLTransport, "put", lambda *args: None)
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
|
|
|
|
request = spec()
|
|
|
|
|
request["demo"]["build"] = {"recipe": recipe, "arguments": ["project" if directory else "project/" + manifest]}
|
|
|
|
|
result = app.submit("prepare_verification", request)
|
|
|
|
|
assert result["status"] == "ok", result
|
|
|
|
|
argv, cwd = json.loads((project / "invocation.json").read_text())
|
|
|
|
|
assert cwd == str(project)
|
|
|
|
|
replacements = {"PROJECT": str(project), "MANIFEST": str(project / manifest), "REQUIREMENTS": str(project / "requirements.txt")}
|
|
|
|
|
if "--target" in argv:
|
|
|
|
|
packages = argv[argv.index("--target") + 1]
|
|
|
|
|
assert Path(packages).name == "packages" and Path(packages).is_relative_to(workspace)
|
|
|
|
|
replacements["PACKAGES"] = packages
|
|
|
|
|
assert argv == [replacements.get(arg, arg) for arg in expected]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_failed_build_preserves_sanitized_tail(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "gradlew").write_text(
|
|
|
|
|
"for i in $(seq 1 30); do echo line-$i >&2; done\necho 'https://example.test/?token=secret token=hidden' >&2\nexit 7\n"
|
|
|
|
|
)
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
|
|
|
|
request = spec()
|
|
|
|
|
request["demo"]["build"] = {"recipe": "gradle", "arguments": ["."]}
|
|
|
|
|
result = app.submit("prepare_verification", request)
|
|
|
|
|
assert result["status"] == "could_not_build" and result["exit_code"] == 7
|
|
|
|
|
assert result["phase"] == "build"
|
|
|
|
|
assert "line-30" in result["stderr"] and "line-1\n" not in result["stderr"]
|
|
|
|
|
assert "hidden" not in result["stderr"] and "https://" not in result["stderr"]
|
|
|
|
|
assert len(result["stderr"]) <= 1500 and len(result["stderr"].splitlines()) <= 20
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["cli", "http"])
|
|
|
|
|
def test_verifier_start_failure_has_diagnostics(workspace, mode):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "app.py").write_text(
|
|
|
|
|
"import sys\nprint('start failed token=hidden https://example.test/?secret=x', file=sys.stderr)\nsys.exit(9)"
|
|
|
|
|
)
|
|
|
|
|
demo = workspace / "demo.json"
|
|
|
|
|
value = spec()["demo"]
|
|
|
|
|
value["start"]["mode"] = mode
|
|
|
|
|
if mode == "http":
|
|
|
|
|
value["start"]["port"] = 19001
|
|
|
|
|
value["steps"] = [{"type": "http", "method": "GET", "path": "/"}]
|
|
|
|
|
demo.write_text(json.dumps(value))
|
|
|
|
|
result = verify_side_task(Side(repo), demo, "path")
|
|
|
|
|
assert result.outcome == "could_not_run", result
|
|
|
|
|
assert result.phase == "start" and result.exit_code == 9
|
|
|
|
|
assert "start failed" in result.stderr and "hidden" not in result.stderr and "https://" not in result.stderr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("secret", ['token="hidden"', '"password": "hidden"', "Bearer hidden", "ghp_hidden", "npm_hidden"])
|
|
|
|
|
def test_command_failure_sanitizes_tokens_and_caps_tail(secret):
|
|
|
|
|
failure = task_storage.CommandFailure("build", 1, "old\n" * 50 + "x" * 2000 + "\n" + secret)
|
|
|
|
|
assert "hidden" not in failure.stderr and "old" not in failure.stderr
|
|
|
|
|
assert len(failure.stderr) <= 1500
|
2026-10-06 00:24:26 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prepare_preserves_large_config_and_task_verifier_applies_it(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
source = (
|
|
|
|
|
"import os\nfrom pathlib import Path\n"
|
|
|
|
|
"assert os.environ['JWT_KEY'] == 'required-key'\n"
|
|
|
|
|
"assert Path(os.environ['JWT_FILE']).read_text() == 'x' * 65536\n"
|
|
|
|
|
"print('ready')\n"
|
|
|
|
|
)
|
|
|
|
|
(repo / "app.py").write_text(source)
|
|
|
|
|
request = spec()
|
|
|
|
|
request["demo"]["start"].update(
|
|
|
|
|
environment={"JWT_KEY": "required-key", "JWT_FILE": ".demo/jwt.pem"},
|
|
|
|
|
files={"jwt.pem": "x" * 65536, "other.json": "{}"},
|
|
|
|
|
)
|
|
|
|
|
blobs = []
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
2026-10-06 08:18:34 +02:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
executor.URLTransport, "put", lambda self, url, data, timeout: blobs.append(data.read() if hasattr(data, "read") else data)
|
|
|
|
|
)
|
2026-10-06 00:24:26 +02:00
|
|
|
result = app.submit("prepare_verification", request)
|
|
|
|
|
assert result["status"] == "ok", result
|
|
|
|
|
destination = workspace / "unpacked"
|
|
|
|
|
verify_task.extract_checkout(blobs[0], destination)
|
|
|
|
|
exported = json.loads((destination / "spec.json").read_text())
|
|
|
|
|
assert exported == request
|
|
|
|
|
assert not (destination / "checkout/.demo").exists()
|
|
|
|
|
demo = workspace / "demo"
|
|
|
|
|
demo.mkdir()
|
|
|
|
|
(demo / "demo.json").write_text(json.dumps(exported["demo"]))
|
|
|
|
|
observation = verify_side_task(Side(destination / "checkout", products=destination / "products"), demo, "path")
|
|
|
|
|
assert observation.outcome == "observed", observation
|
|
|
|
|
assert len(observation.runs) == 1
|
2026-10-06 08:18:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_large_export_spools_to_workspace_and_excludes_caches(workspace, monkeypatch):
|
|
|
|
|
import hashlib
|
|
|
|
|
import tarfile
|
|
|
|
|
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "app.py").write_text("print('safe')")
|
|
|
|
|
# Incompressible input exceeds the former 64 MiB archive cap.
|
|
|
|
|
with (repo / "large.bin").open("wb") as stream:
|
|
|
|
|
for _ in range(129):
|
|
|
|
|
stream.write(os.urandom(1024**2))
|
|
|
|
|
for directory in [".npm", ".pip", ".m2", ".composer", ".gradle", "__pycache__", ".git", "node_modules/.cache"]:
|
|
|
|
|
path = repo / "nested" / directory
|
|
|
|
|
path.mkdir(parents=True)
|
|
|
|
|
(path / "excluded").write_bytes(b"cache")
|
2026-10-06 11:15:09 +02:00
|
|
|
uploaded = {}
|
2026-10-06 08:18:34 +02:00
|
|
|
|
|
|
|
|
def upload(self, url, data, timeout):
|
2026-10-06 11:15:09 +02:00
|
|
|
assert isinstance(data, bytes)
|
|
|
|
|
assert len(data) <= verify_task.MAX_PART_BYTES
|
|
|
|
|
uploaded[url] = data
|
2026-10-06 08:18:34 +02:00
|
|
|
|
|
|
|
|
monkeypatch.setattr(executor.URLTransport, "put", upload)
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
2026-10-06 11:15:09 +02:00
|
|
|
app.prepared_part_urls = [f"https://files.example/part-{i}" for i in range(verify_task.MAX_PARTS)]
|
2026-10-06 08:18:34 +02:00
|
|
|
result = app.submit("prepare_verification", spec(), timeout_seconds=60)
|
|
|
|
|
assert result["status"] == "ok", result
|
2026-10-06 11:15:09 +02:00
|
|
|
manifest = uploaded[app.prepared_put_url]
|
|
|
|
|
assert result["sha256"] == hashlib.sha256(manifest).hexdigest()
|
|
|
|
|
assert len(uploaded) == 3 # two bounded parts and the manifest
|
|
|
|
|
assert json.loads(manifest)["size"] == result["archive_bytes"] > 128 * 1024**2
|
|
|
|
|
with verify_task.tempfile.TemporaryFile(dir=workspace) as archive:
|
|
|
|
|
assert str(workspace) in os.readlink(f"/proc/self/fd/{archive.fileno()}")
|
|
|
|
|
verify_task.assemble_parts(manifest, archive, lambda part: uploaded[app.prepared_part_urls[part["index"]]])
|
|
|
|
|
with tarfile.open(fileobj=archive, mode="r:gz") as contents:
|
|
|
|
|
names = contents.getnames()
|
|
|
|
|
assert "checkout/large.bin" in names
|
|
|
|
|
assert not any(name.endswith("excluded") for name in names)
|
2026-10-06 08:18:34 +02:00
|
|
|
assert verify_task.MAX_ARCHIVE_BYTES == 1024**3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_export_over_cap_reports_size_and_five_relative_directories(workspace, monkeypatch):
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "app.py").write_text("print('safe')")
|
|
|
|
|
for i in range(6):
|
|
|
|
|
directory = repo / f"dir{i}"
|
|
|
|
|
directory.mkdir()
|
|
|
|
|
(directory / "data").write_bytes(os.urandom(1000 * (i + 1)))
|
|
|
|
|
monkeypatch.setattr(verify_task, "MAX_ARCHIVE_BYTES", 1000)
|
|
|
|
|
monkeypatch.setattr(executor.URLTransport, "put", lambda *a: pytest.fail("oversized upload"))
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
|
|
|
|
result = app.submit("prepare_verification", spec())
|
|
|
|
|
assert result["status"] == "could_not_build"
|
|
|
|
|
assert str(result["archive_bytes"]) + " bytes > 1000 bytes" in result["reason"]
|
|
|
|
|
assert str(workspace) not in result["reason"]
|
|
|
|
|
assert "top 5 directories: checkout (" in result["reason"]
|
|
|
|
|
for i in range(2, 6):
|
|
|
|
|
assert f"checkout/dir{i} (" in result["reason"]
|
|
|
|
|
assert "dir0" not in result["reason"] and "dir1" not in result["reason"]
|
2026-10-06 11:15:09 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("location", ["products", "checkout"])
|
|
|
|
|
def test_node_resolves_bundled_module(workspace, location):
|
|
|
|
|
if not Path("/usr/bin/node").exists():
|
|
|
|
|
pytest.skip("node unavailable")
|
|
|
|
|
repo, products = workspace / "checkout", workspace / "products"
|
|
|
|
|
(repo / "bin").mkdir(parents=True)
|
|
|
|
|
products.mkdir()
|
|
|
|
|
module = (products if location == "products" else repo) / "node_modules/bash-color"
|
|
|
|
|
module.mkdir(parents=True)
|
|
|
|
|
(module / "index.js").write_text("module.exports = 'bundled dependency';")
|
|
|
|
|
if location == "checkout":
|
|
|
|
|
destination = workspace / "bundle-check"
|
|
|
|
|
verify_task.extract_checkout(verify_task.pack_checkout(repo), destination)
|
|
|
|
|
assert (destination / "node_modules/bash-color/index.js").read_text() == (module / "index.js").read_text()
|
|
|
|
|
(repo / "bin/glance.js").write_text("if(require('bash-color') !== 'bundled dependency') process.exit(7); console.log('ready');")
|
|
|
|
|
value = spec()["demo"]
|
|
|
|
|
value["start"].update(runtime="node", path="bin/glance.js")
|
|
|
|
|
demo = workspace / "demo.json"
|
|
|
|
|
demo.write_text(json.dumps(value))
|
|
|
|
|
result = verify_side_task(Side(repo, products=products), demo, "path")
|
|
|
|
|
assert result.outcome == "observed", result
|
|
|
|
|
assert result.exit_code is None or result.exit_code == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_java_classpath_includes_products(workspace, monkeypatch):
|
|
|
|
|
import importlib
|
|
|
|
|
import zipfile
|
|
|
|
|
|
|
|
|
|
from openultrasast.sandbox import SandboxResult
|
|
|
|
|
|
|
|
|
|
verifier = importlib.import_module("openultrasast.search.verify")
|
|
|
|
|
repo, products = workspace / "checkout", workspace / "products"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(products / "lib").mkdir(parents=True)
|
|
|
|
|
(products / "lib/dependency.jar").write_bytes(b"dependency")
|
|
|
|
|
with zipfile.ZipFile(repo / "app.jar", "w") as jar:
|
|
|
|
|
jar.writestr("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\nMain-Class: example.\n Main\n\n")
|
|
|
|
|
value = spec()["demo"]
|
|
|
|
|
value["start"].update(runtime="java", path="app.jar")
|
|
|
|
|
demo = workspace / "demo.json"
|
|
|
|
|
demo.write_text(json.dumps(value))
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def run(job, **kwargs):
|
|
|
|
|
calls.append(job.command)
|
|
|
|
|
assert job.command[:2] == ("/usr/bin/java", "-cp")
|
|
|
|
|
assert "/build/lib/dependency.jar" in job.command[2]
|
|
|
|
|
assert "/build/classes" in job.command[2]
|
|
|
|
|
assert job.command[3] == "example.Main"
|
|
|
|
|
assert (kwargs["mounts"]["/build"] / "lib/dependency.jar").read_bytes() == b"dependency"
|
|
|
|
|
return SandboxResult(0, "ready", "", False)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(verifier, "_run_task", run)
|
|
|
|
|
result = verify_side_task(Side(repo, products=products), demo, "path")
|
|
|
|
|
assert result.outcome == "observed", result
|
|
|
|
|
assert len(calls) == 2
|
2026-10-06 14:37:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_python_package_start_imports_checkout_root(workspace):
|
|
|
|
|
repo, products = workspace / "checkout", workspace / "products"
|
|
|
|
|
package = repo / "tiny_package"
|
|
|
|
|
package.mkdir(parents=True)
|
|
|
|
|
(products / "packages").mkdir(parents=True)
|
|
|
|
|
(package / "__init__.py").write_text("VALUE = 42\n")
|
|
|
|
|
(package / "__main__.py").write_text(
|
|
|
|
|
"from tiny_package import VALUE\n"
|
|
|
|
|
"import sys, dependency\n"
|
|
|
|
|
f"assert sys.executable == {sys.executable!r}\n"
|
|
|
|
|
"assert sys.flags.isolated == 1\n"
|
|
|
|
|
"assert VALUE == dependency.VALUE == 42\n"
|
|
|
|
|
"print('ready')\n"
|
|
|
|
|
)
|
|
|
|
|
# Products still take precedence over checkout modules.
|
|
|
|
|
(repo / "dependency.py").write_text("VALUE = 0\n")
|
|
|
|
|
(products / "packages/dependency.py").write_text("VALUE = 42\n")
|
|
|
|
|
print("input_bytes=", len((package / "__main__.py").read_bytes()))
|
|
|
|
|
value = spec()["demo"]
|
|
|
|
|
value["start"]["path"] = "tiny_package/__main__.py"
|
|
|
|
|
demo = workspace / "demo.json"
|
|
|
|
|
demo.write_text(json.dumps(value))
|
|
|
|
|
result = verify_side_task(Side(repo, products=products), demo, "path")
|
|
|
|
|
assert result.outcome == "observed", result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("mutate", [False, True])
|
|
|
|
|
def test_npm_project_install_export_and_runtime(workspace, monkeypatch, mutate):
|
|
|
|
|
if not Path("/usr/bin/node").exists():
|
|
|
|
|
pytest.skip("node unavailable")
|
|
|
|
|
repo = workspace / "checkout"
|
|
|
|
|
project = repo / "nested/project"
|
|
|
|
|
project.mkdir(parents=True)
|
|
|
|
|
(project / "package.json").write_text("{}")
|
|
|
|
|
(project / "package-lock.json").write_text("{}")
|
|
|
|
|
# A launcher outside the npm project requires NODE_PATH, not Node's
|
|
|
|
|
# ordinary resolution through ancestors of the entry script.
|
|
|
|
|
(repo / "app.js").write_text(
|
|
|
|
|
"const fs = require('fs');\n"
|
|
|
|
|
"if (require('dependency') !== 42) process.exit(7);\n"
|
|
|
|
|
"const paths = process.env.NODE_PATH.split(':');\n"
|
|
|
|
|
"if (!paths[0].endsWith('/checkout/nested/project/node_modules') || "
|
|
|
|
|
"!paths[1].endsWith('/products/node_modules')) process.exit(8);\n"
|
|
|
|
|
+ (
|
|
|
|
|
"if (!process.argv.includes('--ready')) fs.writeFileSync(require.resolve('dependency'), 'module.exports = 0;');\n"
|
|
|
|
|
if mutate
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
+ "console.log('ready');\n"
|
|
|
|
|
)
|
|
|
|
|
print("input_bytes=", len((repo / "app.js").read_bytes()))
|
|
|
|
|
fake_bin = workspace / "bin"
|
|
|
|
|
fake_bin.mkdir()
|
|
|
|
|
npm = fake_bin / "npm"
|
|
|
|
|
npm.write_text(
|
|
|
|
|
"#!" + sys.executable + "\nimport sys\nfrom pathlib import Path\n"
|
|
|
|
|
"assert sys.argv[1:] == ['ci', '--ignore-scripts']\n"
|
|
|
|
|
"assert Path('package.json').read_text() == '{}'\n"
|
|
|
|
|
"p = Path('node_modules/dependency'); p.mkdir(parents=True)\n"
|
|
|
|
|
"(p / 'index.js').write_text('module.exports = 42;')\n"
|
|
|
|
|
"cache = Path('node_modules/.cache'); cache.mkdir()\n"
|
|
|
|
|
"(cache / 'discard').write_text('cache')\n"
|
|
|
|
|
)
|
|
|
|
|
npm.chmod(0o755)
|
|
|
|
|
monkeypatch.setitem(executor.SAFE_ENV, "PATH", str(fake_bin) + ":/usr/bin:/bin")
|
|
|
|
|
blobs = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
executor.URLTransport, "put", lambda self, url, data, timeout: blobs.append(data.read() if hasattr(data, "read") else data)
|
|
|
|
|
)
|
|
|
|
|
app = executor.InProcessExecutor(repo, task_boundary=True)
|
|
|
|
|
app.prepared_put_url = "https://files.example/output"
|
|
|
|
|
request = spec()
|
|
|
|
|
request["demo"]["build"] = {"recipe": "npm", "arguments": ["nested/project/package.json"]}
|
|
|
|
|
request["demo"]["start"].update(runtime="node", path="app.js")
|
|
|
|
|
built = app.submit("prepare_verification", request)
|
|
|
|
|
assert built["status"] == "ok", built
|
|
|
|
|
destination = workspace / "unpacked"
|
|
|
|
|
verify_task.extract_checkout(blobs[0], destination)
|
|
|
|
|
module = destination / "checkout/nested/project/node_modules/dependency/index.js"
|
|
|
|
|
assert module.read_text() == "module.exports = 42;"
|
|
|
|
|
assert not (module.parent.parent / ".cache").exists()
|
|
|
|
|
# Product fallback must not shadow the selected project's dependency.
|
|
|
|
|
fallback = destination / "products/node_modules/dependency"
|
|
|
|
|
fallback.mkdir(parents=True)
|
|
|
|
|
(fallback / "index.js").write_text("module.exports = 0;")
|
|
|
|
|
exported = json.loads((destination / "spec.json").read_text())
|
|
|
|
|
assert exported["demo"]["build"] == {"recipe": "none", "arguments": []}
|
|
|
|
|
demo = workspace / "demo.json"
|
|
|
|
|
demo.write_text(json.dumps(exported["demo"]))
|
|
|
|
|
result = verify_side_task(Side(destination / "checkout", products=destination / "products"), demo, "path")
|
|
|
|
|
assert result.outcome == ("inconclusive" if mutate else "observed"), result
|
|
|
|
|
if mutate:
|
|
|
|
|
assert result.reason == "immutable input or dependency changed"
|
|
|
|
|
assert module.read_text() == "module.exports = 42;"
|