The public site shipped a Releases page and a footer link to a release, and neither could ever have content: the read path existed, the write path did not. This adds the seven release operations to the contract contribution and the gateway, and a Studio screen that can actually write one. The editor is six markdown fields rather than one, because that is what the contract models and what a release note is — why, what, what a reader notices, what it leaves in the code, how it was verified, what is still open. Publishing is separate from saving: a draft saves in any state, but the public query keys on PUBLISHED alone, so publish is where completeness is demanded. No new CSS. The screen reuses the document editor's field classes and the working-copy list's row classes, so it inherits Studio's spacing and type instead of introducing a second look. The footer previously linked `/releases/0.1.0` — a version that did not exist, so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now points at the changelog index, which is the only place that knows what the latest release is and which reads correctly when there are none. Route inventory, navigation order, message catalog, manual accessibility evidence, artifact baseline, and the pinned gate-shape digest all move with the new route. The digest was recomputed by first reproducing the previous constant from the previous gates.json, so the computation is known to be the one it was pinned under.
258 lines
10 KiB
TypeScript
258 lines
10 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());
|
|
// Template merge. The template added one gate and one command to the
|
|
// product's 26/81; the artifact set is the product's 126 plus the
|
|
// template's 2. Task 11 added TECH_LOG_STUDIO_ASSETS's manual
|
|
// accessibility evidence, bringing the total to 129.
|
|
// Final fix wave item 1 added `check-tech-log-contract` to FE-GATE-010.
|
|
// Alignment follow-up item 2 added `test-tech-log` and its junit report to
|
|
// FE-GATE-007, bringing the totals to 84/96/130. The dev release manifest
|
|
// drift fix added `check-dev-release-manifest` to FE-GATE-010: 85/97/130.
|
|
expect(canonical.gates).toHaveLength(27);
|
|
expect(canonical.commands).toHaveLength(85);
|
|
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
|
|
expect(canonical.artifacts).toHaveLength(132);
|
|
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);
|
|
}
|
|
}
|