From c30dd9126abe10bac5f8f15ad9f4d258b7477b08 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:37:23 +0200 Subject: [PATCH] search-with-proof: reproducible npm builds for old lockless projects (--before commit date) A lockless npm project (e.g. glance, 2015) resolved an unpinned transitive range to the latest major (filed@0.1.0 -> mime@4, which dropped .lookup()), so the built app crashed serving files -- a build-reproducibility defect, not an app or agent fault. The executor now, for the npm-install (no-lock) case only and only when .git is present, resolves dependencies as of the checkout's HEAD commit date via `npm install --before `, giving era-appropriate, compatible versions. npm ci (lockfile present) is untouched, and any git/date failure falls back to plain install. demo.py also recognizes npm-shrinkwrap.json as a lock for the ci-vs-install choice. Tests: executor + smoke 75 passed, demo 107 passed; ruff clean; mypy clean on executor.py. Executor-side change -> image rebuild required. Co-Authored-By: Claude Opus 4.8 --- src/openultrasast/search/demo.py | 3 +- src/openultrasast/search/executor.py | 14 +++++++ tests/test_search_executor.py | 62 ++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/openultrasast/search/demo.py b/src/openultrasast/search/demo.py index 641ea0e..4381e0a 100644 --- a/src/openultrasast/search/demo.py +++ b/src/openultrasast/search/demo.py @@ -270,7 +270,8 @@ def preparation_command(recipe: str, path: Path, checkout: Path, products: Path) *(["-r", str(target)] if requirements else [str(target)]), ], project if recipe == "npm": - return ["npm", "ci" if (project / "package-lock.json").is_file() else "install", "--ignore-scripts"], project + locked = any((project / name).is_file() for name in ("package-lock.json", "npm-shrinkwrap.json")) + return ["npm", "ci" if locked else "install", "--ignore-scripts"], project if recipe == "composer": return ["composer", "install", "--no-interaction", "--no-plugins", "--no-scripts"], project if recipe == "maven": diff --git a/src/openultrasast/search/executor.py b/src/openultrasast/search/executor.py index d08f048..aceae48 100644 --- a/src/openultrasast/search/executor.py +++ b/src/openultrasast/search/executor.py @@ -412,6 +412,20 @@ class InProcessExecutor: if recipe["recipe"] != "none": path = self._path(recipe["arguments"][0]) argv, project = preparation_command(recipe["recipe"], path, self.repo, products) + if argv[:2] == ["npm", "install"] and (self.repo / ".git").exists(): + # Lockless old projects should resolve dependencies from their era. + # Metadata is optional; unavailable git must not prevent a build. + with suppress(OSError, subprocess.SubprocessError, UnicodeError): + commit_date = subprocess.run( + ["git", "-C", str(project), "show", "-s", "--format=%cI", "HEAD"], + check=True, + capture_output=True, + text=True, + env=clean_environment(), + timeout=min(5, self._remaining()), + ).stdout.strip() + if commit_date: + argv.extend(["--before", commit_date]) self.phase = "build" built = self._task_run(argv, self._remaining(), {"disk_bytes": task_storage.DEFAULT_SCRATCH_BYTES, **limits}, cwd=project) phases["build"] = time.monotonic() - before diff --git a/tests/test_search_executor.py b/tests/test_search_executor.py index d4a542e..d5e144b 100644 --- a/tests/test_search_executor.py +++ b/tests/test_search_executor.py @@ -1,12 +1,74 @@ """Offline executor boundary and mailbox protocol checks.""" import json +import subprocess import pytest from openultrasast.search.executor import MAX_RESULT_BYTES, Command, InProcessExecutor, ObjectStoreExecutor, clean_environment, serve +@pytest.mark.parametrize("lockfile", [None, "package-lock.json", "npm-shrinkwrap.json"]) +@pytest.mark.parametrize("git_date", ["ok", "no_git", "missing", "failed", "empty", "timeout"]) +def test_npm_preparation_commit_date(tmp_path, monkeypatch, lockfile, git_date): + from openultrasast.search import executor + + # A nested project must still use the checkout's HEAD, with its own build cwd. + project = tmp_path / "app" + project.mkdir() + (project / "package.json").write_text("{}") + (project / "index.js").write_text('console.log("ready")') + if lockfile: + (project / lockfile).write_text("{}") + if git_date != "no_git": + (tmp_path / ".git").mkdir() + date = "2015-06-03T12:34:56+02:00" + git_calls, builds = [], [] + + def git(argv, **kwargs): + git_calls.append(argv) + assert argv == ["git", "-C", str(project), "show", "-s", "--format=%cI", "HEAD"] + assert kwargs["check"] and 0 < kwargs["timeout"] <= 5 + assert kwargs["env"] == clean_environment() + if git_date == "missing": + raise FileNotFoundError("git") + if git_date == "failed": + raise subprocess.CalledProcessError(128, argv) + if git_date == "timeout": + raise subprocess.TimeoutExpired(argv, kwargs["timeout"]) + return subprocess.CompletedProcess(argv, 0, " \n" if git_date == "empty" else date + "\n", "") + + def build(argv, timeout, limits, *, cwd): + builds.append(argv) + assert cwd == project + return {"exit_code": 0, "timed_out": False, "stderr": ""} + + app = InProcessExecutor(tmp_path, task_boundary=True) + app.prepared_put_url = "https://files.example/output" + monkeypatch.setattr(executor.subprocess, "run", git) + monkeypatch.setattr(app, "_task_run", build) + monkeypatch.setattr(executor.URLTransport, "put", lambda *args: None) + result = app.submit( + "prepare_verification", + { + "demo": { + "oracle": "path", + "build": {"recipe": "npm", "arguments": ["app/package.json"]}, + "start": {"runtime": "node", "path": "app/index.js", "arguments": [], "mode": "cli"}, + "steps": [{"type": "cli", "arguments": []}], + }, + "family": "path", + "timeout_seconds": 2, + }, + ) + assert result["status"] == "ok", result + expected = ["npm", "ci" if lockfile else "install", "--ignore-scripts"] + if not lockfile and git_date == "ok": + expected += ["--before", date] + assert builds == [expected] + assert len(git_calls) == int(not lockfile and git_date != "no_git") + + def test_order_retry_stop(tmp_path): (tmp_path / "input").write_text("hello") executor = InProcessExecutor(tmp_path)