mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
harden runtime packaging and verification
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_RUNTIME_BUNDLE } from "../runtime/install.js";
|
||||
|
||||
const root = path.resolve(import.meta.dir, "..");
|
||||
const dockerfile = fs.readFileSync(path.join(root, ".devcontainer", "Dockerfile"), "utf8");
|
||||
const workflow = fs.readFileSync(path.join(root, ".github", "workflows", "gstack2-gate.yml"), "utf8");
|
||||
const devcontainerGate = fs.readFileSync(path.join(root, "scripts", "gstack2", "devcontainer-gate.sh"), "utf8");
|
||||
const smoke = fs.readFileSync(path.join(root, "scripts", "gstack2", "runtime-install-smoke.sh"), "utf8");
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
||||
const iosSources = [
|
||||
@@ -27,6 +30,95 @@ describe("GStack 2 CI supply-chain and browser smoke", () => {
|
||||
for (const reference of actionRefs) expect(reference).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
test("mounts the checkout read-only for every development-container run", () => {
|
||||
const workspaceMounts = [...workflow.matchAll(/--volume "\$\{\{ github\.workspace \}\}:([^"]+)"/g)]
|
||||
.map((match) => match[1]);
|
||||
expect(workspaceMounts).toEqual(["/source:ro", "/source:ro"]);
|
||||
expect(workflow).toContain("/source/scripts/gstack2/devcontainer-gate.sh /source");
|
||||
});
|
||||
|
||||
test("installs and tests in a disposable copy without git metadata or host dependencies", () => {
|
||||
expect(devcontainerGate).toContain("mktemp -d /tmp/gstack2-devcontainer-gate.XXXXXX");
|
||||
expect(devcontainerGate).toContain("--exclude='./.git'");
|
||||
expect(devcontainerGate).toContain("--exclude='./node_modules'");
|
||||
expect(devcontainerGate).toContain("trap cleanup EXIT");
|
||||
expect(devcontainerGate).toContain("printf 'gitdir: %s\\n'");
|
||||
expect(devcontainerGate).toContain('GSTACK_GATE_BASE_GIT:-$(command -v git)');
|
||||
expect(devcontainerGate).toContain('-c safe.directory="$SOURCE"');
|
||||
expect(devcontainerGate).toContain('-c safe.directory="$GSTACK_GATE_WORK_TREE"');
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gstack2-devcontainer-gate-test-"));
|
||||
const source = path.join(fixtureRoot, "source");
|
||||
const stubBin = path.join(fixtureRoot, "commands");
|
||||
const callLog = path.join(fixtureRoot, "bun-calls.log");
|
||||
fs.mkdirSync(path.join(source, "node_modules", "host-only"), { recursive: true });
|
||||
fs.mkdirSync(stubBin, { recursive: true });
|
||||
fs.writeFileSync(path.join(source, "package.json"), "{}\n");
|
||||
const gitInit = spawnSync("git", ["init", "--quiet", "--initial-branch=main", source], { encoding: "utf8" });
|
||||
expect(gitInit.status).toBe(0);
|
||||
const realGit = process.env.GSTACK_GATE_BASE_GIT ?? (process.env.PATH ?? "").split(path.delimiter)
|
||||
.map((directory) => path.join(directory, "git"))
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
expect(realGit).toBeDefined();
|
||||
fs.writeFileSync(path.join(source, ".git", "gstack-sentinel"), "host git metadata\n");
|
||||
fs.writeFileSync(path.join(source, "node_modules", "host-only", "sentinel"), "host dependency\n");
|
||||
fs.writeFileSync(path.join(stubBin, "bun"), `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\t%s\\n' "$PWD" "$*" >> "$GSTACK_TEST_CALL_LOG"
|
||||
test -e package.json
|
||||
test -f .git
|
||||
test ! -e .git/gstack-sentinel
|
||||
test ! -e node_modules/host-only
|
||||
if [[ "\${GSTACK_TEST_FAIL:-0}" == "1" ]]; then exit 23; fi
|
||||
if [[ "$*" == "run test:gstack2" ]]; then test "$(git rev-parse --show-toplevel)" == "$PWD"; fi
|
||||
mkdir -p node_modules
|
||||
touch node_modules/container-only
|
||||
`, { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(stubBin, "git"), "#!/usr/bin/env bash\nexit 97\n", { mode: 0o755 });
|
||||
|
||||
const runGate = (fail: boolean) => {
|
||||
fs.writeFileSync(callLog, "");
|
||||
const result = spawnSync("bash", [path.join(root, "scripts", "gstack2", "devcontainer-gate.sh"), source], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${stubBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
GSTACK_TEST_CALL_LOG: callLog,
|
||||
GSTACK_TEST_FAIL: fail ? "1" : "0",
|
||||
GSTACK_GATE_BASE_GIT: realGit,
|
||||
},
|
||||
});
|
||||
const calls = fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean)
|
||||
.map((line) => {
|
||||
const [cwd, args] = line.split("\t");
|
||||
return { cwd, args };
|
||||
});
|
||||
return { calls, result };
|
||||
};
|
||||
|
||||
try {
|
||||
const success = runGate(false);
|
||||
expect(success.result.stderr).toBe("");
|
||||
expect(success.result.status).toBe(0);
|
||||
expect(success.calls.map((call) => call.args)).toEqual(["install --frozen-lockfile", "run test:gstack2"]);
|
||||
expect(new Set(success.calls.map((call) => call.cwd)).size).toBe(1);
|
||||
expect(success.calls[0].cwd).not.toBe(source);
|
||||
expect(fs.existsSync(success.calls[0].cwd)).toBe(false);
|
||||
|
||||
const failure = runGate(true);
|
||||
expect(failure.result.status).toBe(23);
|
||||
expect(failure.calls).toHaveLength(1);
|
||||
expect(fs.existsSync(failure.calls[0].cwd)).toBe(false);
|
||||
|
||||
expect(fs.readFileSync(path.join(source, ".git", "gstack-sentinel"), "utf8")).toBe("host git metadata\n");
|
||||
expect(fs.readFileSync(path.join(source, "node_modules", "host-only", "sentinel"), "utf8")).toBe("host dependency\n");
|
||||
expect(fs.existsSync(path.join(source, "node_modules", "container-only"))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("drives a loopback page through the installed browser", () => {
|
||||
expect(smoke).not.toContain("bun install --frozen-lockfile");
|
||||
expect(smoke).toContain('test ! -e "$REPO/node_modules/@anthropic-ai/claude-agent-sdk"');
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
runExternalEffect,
|
||||
setupRuntime,
|
||||
updateProjectState,
|
||||
withLock,
|
||||
} from "../runtime/index.js";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
@@ -280,4 +281,24 @@ describe("gstack 2 host-neutral paths and state", () => {
|
||||
await fs.chmod(readOnly, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
test("lock cleanup preserves both the operation and release failures", async () => {
|
||||
const root = await temporaryRoot("gstack2 lock aggregate ");
|
||||
const lock = path.join(root, "state.lock");
|
||||
const operationError = null;
|
||||
let caught: unknown;
|
||||
try {
|
||||
await withLock(lock, async () => {
|
||||
await fs.writeFile(path.join(lock, "owner.json"), "{malformed\n");
|
||||
throw operationError;
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AggregateError);
|
||||
const failures = [...(caught as AggregateError).errors];
|
||||
expect(failures[0]).toBe(operationError);
|
||||
expect(failures[1]).toBeInstanceOf(SyntaxError);
|
||||
expect((caught as Error & { cause?: unknown }).cause).toBe(operationError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,17 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { main as runtimeMain } from "../runtime/cli.js";
|
||||
import { summarizeRuntimeBundle } from "../scripts/gstack2/audit-runtime-bundle";
|
||||
import {
|
||||
DEFAULT_CAPABILITY_LAUNCHERS,
|
||||
DEFAULT_RUNTIME_BUNDLE,
|
||||
DEFAULT_RUNTIME_HELPERS,
|
||||
defaultBunBuilder,
|
||||
installManagedRuntime,
|
||||
runtimeNativePackagePaths,
|
||||
uninstallManagedRuntime,
|
||||
runCommand,
|
||||
smokeRuntimeBundle,
|
||||
validateRuntimeBundle,
|
||||
} from "../runtime/install.js";
|
||||
|
||||
@@ -128,11 +131,12 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
const bundlePaths = new Set(DEFAULT_RUNTIME_BUNDLE.map((item) => item.path));
|
||||
for (const dependency of [
|
||||
"node_modules/sharp",
|
||||
"node_modules/@img",
|
||||
"node_modules/detect-libc",
|
||||
"node_modules/semver",
|
||||
"node_modules/@ngrok",
|
||||
...runtimeNativePackagePaths(),
|
||||
]) expect(bundlePaths.has(dependency)).toBe(true);
|
||||
expect(bundlePaths.has("node_modules/@img")).toBe(false);
|
||||
expect(bundlePaths.has("node_modules/@ngrok")).toBe(false);
|
||||
expect([...bundlePaths].some((item) => item.includes("@huggingface"))).toBe(false);
|
||||
for (const helper of contract.helpers) {
|
||||
expect(bundlePaths.has(helper.source_path)).toBe(true);
|
||||
@@ -180,6 +184,75 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
}, { createDefaultSource: false });
|
||||
}, 30_000);
|
||||
|
||||
test("selects one deterministic native dependency closure per supported host", () => {
|
||||
expect(runtimeNativePackagePaths({ platform: "darwin", arch: "arm64" })).toEqual([
|
||||
"node_modules/@img/colour",
|
||||
"node_modules/@img/sharp-darwin-arm64",
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64",
|
||||
"node_modules/@ngrok/ngrok-darwin-universal",
|
||||
"node_modules/@ngrok/ngrok",
|
||||
]);
|
||||
expect(runtimeNativePackagePaths({ platform: "darwin", arch: "x64" })).toContain(
|
||||
"node_modules/@img/sharp-libvips-darwin-x64",
|
||||
);
|
||||
expect(runtimeNativePackagePaths({ platform: "linux", arch: "x64", libc: "glibc" })).toEqual([
|
||||
"node_modules/@img/colour",
|
||||
"node_modules/@img/sharp-linux-x64",
|
||||
"node_modules/@img/sharp-libvips-linux-x64",
|
||||
"node_modules/@ngrok/ngrok-linux-x64-gnu",
|
||||
"node_modules/@ngrok/ngrok",
|
||||
]);
|
||||
expect(runtimeNativePackagePaths({ platform: "linux", arch: "arm64", libc: "glibc" })).toContain(
|
||||
"node_modules/@ngrok/ngrok-linux-arm64-gnu",
|
||||
);
|
||||
expect(runtimeNativePackagePaths({ platform: "linux", arch: "arm64", libc: "musl" })).toEqual([
|
||||
"node_modules/@img/colour",
|
||||
"node_modules/@img/sharp-linuxmusl-arm64",
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64",
|
||||
"node_modules/@ngrok/ngrok-linux-arm64-musl",
|
||||
"node_modules/@ngrok/ngrok",
|
||||
]);
|
||||
expect(runtimeNativePackagePaths({ platform: "linux", arch: "x64", libc: "musl" })).toContain(
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64",
|
||||
);
|
||||
expect(runtimeNativePackagePaths({ platform: "win32", arch: "x64" })).toEqual([
|
||||
"node_modules/@img/colour",
|
||||
"node_modules/@img/sharp-win32-x64",
|
||||
"node_modules/@ngrok/ngrok-win32-x64-msvc",
|
||||
"node_modules/@ngrok/ngrok",
|
||||
]);
|
||||
expect(runtimeNativePackagePaths({ platform: "win32", arch: "arm64" })).toContain(
|
||||
"node_modules/@ngrok/ngrok-win32-arm64-msvc",
|
||||
);
|
||||
expect(() => runtimeNativePackagePaths({ platform: "linux", arch: "x64", libc: "unknown" }))
|
||||
.toThrow("Unsupported managed-runtime libc");
|
||||
expect(() => runtimeNativePackagePaths({ platform: "freebsd", arch: "x64" }))
|
||||
.toThrow("Unsupported managed-runtime platform");
|
||||
});
|
||||
|
||||
test("summarizes a runtime bundle as deterministic, reproducible evidence", () => {
|
||||
const audit = summarizeRuntimeBundle({
|
||||
version: "fixture-version",
|
||||
components: ["runtime", ...runtimeNativePackagePaths()],
|
||||
files: [
|
||||
{ path: "runtime/index.js", size: 17, mode: 0o644, sha256: "a".repeat(64) },
|
||||
{ path: "runtime/cli.js", size: 23, mode: 0o755, sha256: "b".repeat(64) },
|
||||
],
|
||||
});
|
||||
expect(audit).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
sourceBundleVersion: "fixture-version",
|
||||
components: 1 + runtimeNativePackagePaths().length,
|
||||
files: 2,
|
||||
bytes: 40,
|
||||
forbiddenComponents: [],
|
||||
});
|
||||
expect(audit.nativeComponents).toEqual(runtimeNativePackagePaths());
|
||||
expect(typeof audit.sourceGitDirty).toBe("boolean");
|
||||
expect(audit.bundleManifestSha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(audit.reproductionCommand).toContain(`evals/runtime-bundle/${process.platform}-${process.arch}.json`);
|
||||
});
|
||||
|
||||
test("failed validation and failed smoke checks roll back activation", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
await installFixture(source, home, "1.0.0");
|
||||
@@ -193,6 +266,10 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
smokeTest: async () => { throw new Error("smoke failed"); },
|
||||
})).rejects.toMatchObject({ code: "UPGRADE_ROLLED_BACK" });
|
||||
expect(await activeVersion(home)).toBe("1.0.0");
|
||||
expect(await exists(path.join(home, "versions", "2.0.1"))).toBe(false);
|
||||
|
||||
const repaired = await installFixture(source, home, "2.0.1");
|
||||
expect(repaired.pointer.current).toBe("2.0.1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -440,6 +517,45 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("default runtime smoke rejects an unloadable native dependency closure", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack native smoke "));
|
||||
try {
|
||||
await fs.mkdir(path.join(root, "bin"), { recursive: true });
|
||||
await fs.mkdir(path.join(root, "node_modules", "sharp"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "bin", "gstack"), "fixture\n");
|
||||
await fs.writeFile(path.join(root, "node_modules", "sharp", "package.json"), '{"name":"sharp"}\n');
|
||||
const calls: string[][] = [];
|
||||
await expect(smokeRuntimeBundle(root, {
|
||||
run: async (_command: string, args: string[]) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "--version") return { code: 0, stdout: "v20.18.0\n", stderr: "" };
|
||||
if (args[0] === "--input-type=module") throw new Error("native binding unavailable");
|
||||
return { code: 0, stdout: "gstack fixture\n", stderr: "" };
|
||||
},
|
||||
})).rejects.toMatchObject({ code: "INSTALL_SMOKE_FAILED" });
|
||||
expect(calls.at(-1)?.[0]).toBe("--input-type=module");
|
||||
expect(calls.at(-1)?.at(-1)).toContain('import("sharp")');
|
||||
expect(calls.at(-1)?.at(-1)).not.toContain("@ngrok/ngrok");
|
||||
|
||||
await fs.rm(path.join(root, "node_modules", "sharp"), { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(root, "node_modules", "@ngrok", "ngrok"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "node_modules", "@ngrok", "ngrok", "package.json"), '{"name":"@ngrok/ngrok"}\n');
|
||||
calls.length = 0;
|
||||
await expect(smokeRuntimeBundle(root, {
|
||||
run: async (_command: string, args: string[]) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "--version") return { code: 0, stdout: "v20.18.0\n", stderr: "" };
|
||||
if (args[0] === "--input-type=module") throw new Error("native binding unavailable");
|
||||
return { code: 0, stdout: "gstack fixture\n", stderr: "" };
|
||||
},
|
||||
})).rejects.toMatchObject({ code: "INSTALL_SMOKE_FAILED" });
|
||||
expect(calls.at(-1)?.at(-1)).toContain('import("@ngrok/ngrok")');
|
||||
expect(calls.at(-1)?.at(-1)).not.toContain('import("sharp")');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("public upgrade reuses managed validation and rejects arbitrary or symlinked sources", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
await withFixture(async ({ root, source, home }) => {
|
||||
@@ -478,15 +594,18 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
const log = path.join(root, "bun.log");
|
||||
await fs.mkdir(fakeBin);
|
||||
await fs.mkdir(runtime);
|
||||
await fs.mkdir(path.join(root, "node_modules"));
|
||||
await fs.mkdir(path.join(root, "node_modules", "sharp"), { recursive: true });
|
||||
await fs.copyFile(path.join(REPO_ROOT, "setup"), path.join(root, "setup"));
|
||||
await fs.chmod(path.join(root, "setup"), 0o755);
|
||||
await fs.writeFile(path.join(root, "package.json"), '{"type":"module","dependencies":{"fixture-dependency":"1.0.0"},"devDependencies":{"test-only-sdk":"1.0.0"}}\n');
|
||||
await fs.writeFile(path.join(root, "package.json"), '{"type":"module","dependencies":{"sharp":"1.0.0"},"devDependencies":{"test-only-sdk":"1.0.0"}}\n');
|
||||
await fs.writeFile(path.join(root, "node_modules", "sharp", "package.json"), '{"name":"sharp","main":"index.js"}\n');
|
||||
await fs.writeFile(path.join(root, "node_modules", "sharp", "index.js"), 'module.exports = require("@img/sharp-fixture");\n');
|
||||
await fs.writeFile(path.join(runtime, "install.js"), 'console.log(`installer=${process.release.name}`);\n');
|
||||
await fs.writeFile(path.join(fakeBin, "bun"), `#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "$BUN_LOG"
|
||||
mkdir -p "$FIXTURE_ROOT/node_modules/fixture-dependency"
|
||||
printf '{"name":"fixture-dependency"}\\n' > "$FIXTURE_ROOT/node_modules/fixture-dependency/package.json"
|
||||
mkdir -p "$FIXTURE_ROOT/node_modules/@img/sharp-fixture"
|
||||
printf '{"name":"@img/sharp-fixture","main":"index.js"}\\n' > "$FIXTURE_ROOT/node_modules/@img/sharp-fixture/package.json"
|
||||
printf 'module.exports = {}\\n' > "$FIXTURE_ROOT/node_modules/@img/sharp-fixture/index.js"
|
||||
`, { mode: 0o755 });
|
||||
|
||||
const result = await runCommand(path.join(root, "setup"), [], {
|
||||
@@ -513,7 +632,10 @@ printf '{"name":"fixture-dependency"}\\n' > "$FIXTURE_ROOT/node_modules/fixture-
|
||||
},
|
||||
});
|
||||
const installs = (await fs.readFile(log, "utf8")).trim().split("\n");
|
||||
expect(installs).toEqual(["install --production --frozen-lockfile"]);
|
||||
expect(installs).toEqual([
|
||||
"install --production --frozen-lockfile",
|
||||
"install --production --frozen-lockfile",
|
||||
]);
|
||||
expect(await exists(path.join(root, "node_modules", "test-only-sdk"))).toBe(false);
|
||||
expect(second.stdout).toContain("installer=node");
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user