FE-GATE-020 proves a capability can be removed by rebuilding the whole project without it. The fixture it built could not get that far, and the failures all came from the fixture rather than from anything about removability. It was not a repository. The supply-chain inventory is defined as the tracked file set, so it asks `git ls-files` what the project contains; with no repository to ask, generation failed and took every provider suite down with it. It is now initialised on preparation and committed after the removal — not before, or the index would still list the files the removal deleted. It had no `.gitignore`, so once it did have a repository, every generated artifact and every linked module landed in the index and the inventory refused the fixture for tracked and generated paths colliding. It carries the ignore rules now, and therefore records the same tracked set as the repository it was copied from. Each removal script kept its own copy-target list and they had drifted: the reference-feature fixture omitted `playwright.capabilities.config.ts`, which the inventory requires. There is one list now. It also gained the install and workspace identity — `.npmrc`, the lockfile, the workspace file — without which the fixture is a different project, and the release evidence a candidate is assembled from, without which no candidate can be built at all. A tracked root the removal deletes is no longer required of the result: the optional-recipe fixture deletes `recipes/`, and the inventory policy demanded it back. Roots that are gone are pruned from the fixture's policy. Two smaller causes. A platform integration file asserted the reference feature's own route ids, so removing the feature left it importing a deleted module — typecheck, the test run, coverage and the residue scan all failed on that one misplaced assertion, which now lives in the feature's test tree. And the canonical exact-count authority was re-imposed on a contract the fixture deliberately reduces, failing the fixture for the reduction it exists to prove; `CI_CONTRACT_MODE` already marked those runs and is now honoured by default. The reference-feature fixture goes from failing before its first assertion to 1,612 passing with one failure, and that one is the live process-tree observation test already red on the main tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
9.7 KiB
TypeScript
250 lines
9.7 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
isReducedCiContractRun,
|
|
loadCiGateContract,
|
|
parseCiGateContract,
|
|
} from "../../scripts/contracts/ci-gates.ts";
|
|
import { generateCiWorkflow } from "../../scripts/generate-ci-workflow.ts";
|
|
import { validatePackageScriptGraph } from "../../scripts/lib/package-script-graph.ts";
|
|
|
|
const roots: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe("selective Task 3 contract closure", () => {
|
|
it("builds a private offline aggregate-cgroup provider launch without argv secrets", async () => {
|
|
const {
|
|
encodeProviderBwrapInput,
|
|
encodeProviderScopeFrame,
|
|
formatProviderCgroupUnitName,
|
|
systemctlKillProviderArguments,
|
|
systemdRunProviderArguments,
|
|
} = await import("../../scripts/lib/provider-cgroup.ts");
|
|
const unit = formatProviderCgroupUnitName(
|
|
"vulnerability",
|
|
42,
|
|
"0123456789abcdef01234567",
|
|
);
|
|
const command = "node provider.mjs --token command-secret";
|
|
const credential = "credential-secret";
|
|
const launch = systemdRunProviderArguments(
|
|
unit,
|
|
1_800_000,
|
|
1_200,
|
|
"/trusted/node",
|
|
"/workspace/scripts/lib/provider-scope-wrapper.ts",
|
|
"/exact/report.json",
|
|
12,
|
|
34,
|
|
);
|
|
|
|
expect(launch).toEqual(expect.arrayContaining([
|
|
"--scope",
|
|
"--property=MemoryMax=1073741824",
|
|
"--property=MemorySwapMax=0",
|
|
"--property=TasksMax=64",
|
|
"--property=CPUQuota=100%",
|
|
"--property=KillMode=control-group",
|
|
"/trusted/node",
|
|
"/workspace/scripts/lib/provider-scope-wrapper.ts",
|
|
]));
|
|
expect(launch.join("\0")).not.toContain(command);
|
|
expect(launch.join("\0")).not.toContain(credential);
|
|
expect(
|
|
encodeProviderBwrapInput(
|
|
["--unshare-net", "--bind", "/exact/report.json", "/exact/report.json"],
|
|
{ PROVIDER_COMMAND: command, PROVIDER_CREDENTIAL: credential },
|
|
),
|
|
).toEqual(expect.any(Buffer));
|
|
expect(systemctlKillProviderArguments(unit)).toEqual([
|
|
"--user",
|
|
"kill",
|
|
"--kill-whom=all",
|
|
"--signal=SIGKILL",
|
|
unit,
|
|
]);
|
|
// `bwrap --args FD` stops parsing at the first non-option and never hands
|
|
// the remainder back, so a command placed in the args file is dropped and
|
|
// bubblewrap exits with its usage text. Refusing `--` in the option stream
|
|
// is what keeps that silent no-sandbox launch from returning.
|
|
expect(() =>
|
|
encodeProviderBwrapInput(
|
|
["--unshare-net", "--", "/usr/bin/prlimit"],
|
|
{ PROVIDER_COMMAND: command },
|
|
),
|
|
).toThrow(/terminate the option stream/u);
|
|
const frame = encodeProviderScopeFrame({
|
|
bwrapInput: Buffer.from("private-bwrap-vector\0"),
|
|
bwrapCommand: ["/usr/bin/prlimit", "--nofile=64:64", "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"'],
|
|
reportPath: "/exact/report.json",
|
|
reportDev: 12,
|
|
reportIno: 34,
|
|
});
|
|
expect(frame.readUInt32BE(0)).toBe(frame.byteLength - 4);
|
|
expect(frame.subarray(4).toString("utf8")).toContain(
|
|
Buffer.from("private-bwrap-vector\0").toString("base64"),
|
|
);
|
|
expect(launch.join("\0")).not.toContain("private-bwrap-vector");
|
|
// The command vector rides on real argv, so it must never be able to carry
|
|
// the secret that the args file exists to hide.
|
|
expect(frame.subarray(4).toString("utf8")).not.toContain(credential);
|
|
expect(() =>
|
|
encodeProviderScopeFrame({
|
|
bwrapInput: Buffer.from("x\0"),
|
|
bwrapCommand: [],
|
|
reportPath: "/exact/report.json",
|
|
reportDev: 12,
|
|
reportIno: 34,
|
|
}),
|
|
).toThrow(/bwrap command is invalid/u);
|
|
expect(() =>
|
|
encodeProviderScopeFrame({
|
|
bwrapInput: Buffer.from("x\0"),
|
|
bwrapCommand: ["prlimit"],
|
|
reportPath: "/exact/report.json",
|
|
reportDev: 12,
|
|
reportIno: 34,
|
|
}),
|
|
).toThrow(/bwrap command is invalid/u);
|
|
});
|
|
|
|
it("removes only the pinned raw inode during parent-loss cleanup", async () => {
|
|
const { cleanupOwnedProviderReport } = await import(
|
|
"../../scripts/lib/provider-raw-cleanup.ts"
|
|
);
|
|
const root = await mkdtemp(path.join(tmpdir(), "provider-raw-cleanup-"));
|
|
roots.push(root);
|
|
const reportPath = path.join(root, "raw.json");
|
|
const originalPath = path.join(root, "original.json");
|
|
await writeFile(reportPath, "owned\n");
|
|
const identity = await lstat(reportPath);
|
|
await rename(reportPath, originalPath);
|
|
await writeFile(reportPath, "unrelated\n");
|
|
|
|
await expect(cleanupOwnedProviderReport({
|
|
reportPath,
|
|
reportDev: identity.dev,
|
|
reportIno: identity.ino,
|
|
})).resolves.toBe(false);
|
|
await expect(readFile(reportPath, "utf8")).resolves.toBe("unrelated\n");
|
|
|
|
await rm(reportPath);
|
|
await rename(originalPath, reportPath);
|
|
await expect(cleanupOwnedProviderReport({
|
|
reportPath,
|
|
reportDev: identity.dev,
|
|
reportIno: identity.ino,
|
|
})).resolves.toBe(true);
|
|
await expect(lstat(reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(readdir(root)).resolves.toEqual([]);
|
|
});
|
|
|
|
it("uses early liveness EOF to clean the pinned raw file without waiting for a command frame", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "provider-scope-eof-"));
|
|
roots.push(root);
|
|
const reportPath = path.join(root, "raw.json");
|
|
await writeFile(reportPath, "partial\n");
|
|
const identity = await lstat(reportPath);
|
|
const child = spawn(process.execPath, [
|
|
path.resolve("scripts/lib/provider-scope-wrapper.ts"),
|
|
"1",
|
|
reportPath,
|
|
String(identity.dev),
|
|
String(identity.ino),
|
|
], { stdio: ["pipe", "pipe", "pipe"] });
|
|
const completion = waitForChildResult(child);
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 75));
|
|
expect(child.exitCode).toBeNull();
|
|
await expect(readFile(reportPath, "utf8")).resolves.toBe("partial\n");
|
|
child.stdin.end();
|
|
const result = await within(completion, 1_000, "provider scope EOF close");
|
|
expect(result).toEqual({ code: 125, signal: null });
|
|
await expect(lstat(reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(readdir(root)).resolves.toEqual([]);
|
|
});
|
|
|
|
it("tracks npm run-script dependencies instead of bypassing the graph", () => {
|
|
expect(validatePackageScriptGraph({ root: "npm run-script missing" }, "root"))
|
|
.toContain("package script missing: root -> missing");
|
|
});
|
|
|
|
// A removal fixture runs against a pruned contract on purpose, so the
|
|
// canonical counts do not describe it. Asserting them there failed the
|
|
// fixture for the reduction it exists to demonstrate.
|
|
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
|
const canonical = await loadCiGateContract(process.cwd());
|
|
expect(canonical.gates).toHaveLength(27);
|
|
expect(canonical.commands).toHaveLength(82);
|
|
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
|
|
expect(canonical.artifacts).toHaveLength(107);
|
|
expect(canonical.stages).toHaveLength(5);
|
|
expect(canonical.retention.classes).toHaveLength(5);
|
|
|
|
const orphan = JSON.parse(JSON.stringify(canonical)) as Record<string, any>;
|
|
orphan.retention.classes.push({ id: "unused", policy: "never referenced" });
|
|
expect(() => parseCiGateContract(orphan)).toThrow(/five canonical retention|orphan retention/u);
|
|
});
|
|
|
|
it.skipIf(isReducedCiContractRun())("rejects the retired validate-candidate-archive grammar", async () => {
|
|
const canonical = JSON.parse(
|
|
JSON.stringify(await loadCiGateContract(process.cwd())),
|
|
) as Record<string, any>;
|
|
canonical.jobs.find((job: Record<string, any>) => job.id === "vulnerability_provider")
|
|
.steps.splice(4, 0, {
|
|
kind: "validate-candidate-archive",
|
|
archivePath: ".release/candidate/release-candidate.tar.gz",
|
|
});
|
|
expect(() => parseCiGateContract(canonical)).toThrow(
|
|
/invalid discriminator|forbidden|canonical job step sequence/iu,
|
|
);
|
|
});
|
|
|
|
it("publishes a generated workflow as exactly 0644 under a restrictive umask", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "ci-workflow-mode-"));
|
|
roots.push(root);
|
|
await mkdir(path.join(root, ".gitea/workflows"), { recursive: true });
|
|
const previous = process.umask(0o777);
|
|
try {
|
|
const contract = await loadCiGateContract(process.cwd());
|
|
await generateCiWorkflow({ root, contract, check: false });
|
|
} finally {
|
|
process.umask(previous);
|
|
}
|
|
const target = path.join(root, ".gitea/workflows/quality-gates.yml");
|
|
const metadata = await lstat(target);
|
|
expect(metadata.mode & 0o777).toBe(0o644);
|
|
expect((await readFile(target, "utf8")).startsWith("# GENERATED FILE")).toBe(true);
|
|
});
|
|
});
|
|
|
|
async function waitForChildResult(
|
|
child: ReturnType<typeof spawn>,
|
|
): Promise<Readonly<{ code: number | null; signal: NodeJS.Signals | null }>> {
|
|
return await new Promise((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
});
|
|
}
|
|
|
|
async function within<T>(operation: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
let timer: NodeJS.Timeout | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
operation,
|
|
new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|