chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import type { SpawnSyncOptionsWithStringEncoding } from "node:child_process";
|
||||
|
||||
export const PNPM_SCRIPT_TIMEOUT_MS = 60_000;
|
||||
export const PNPM_SCRIPT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type BoundedChildInvocation = Readonly<{
|
||||
command: string;
|
||||
arguments: readonly string[];
|
||||
options: SpawnSyncOptionsWithStringEncoding;
|
||||
}>;
|
||||
|
||||
type PnpmScriptResult = Readonly<{
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error?: Error;
|
||||
}>;
|
||||
|
||||
export function createBoundedPnpmScriptInvocation(input: Readonly<{
|
||||
nodePath: string;
|
||||
pnpmCli: string;
|
||||
script: string;
|
||||
environment: NodeJS.ProcessEnv;
|
||||
}>): BoundedChildInvocation {
|
||||
return createBoundedChildInvocation({
|
||||
command: input.nodePath,
|
||||
arguments: [input.pnpmCli, "run", input.script],
|
||||
environment: input.environment,
|
||||
});
|
||||
}
|
||||
|
||||
export function createBoundedChildInvocation(input: Readonly<{
|
||||
command: string;
|
||||
arguments: readonly string[];
|
||||
environment: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
}>): BoundedChildInvocation {
|
||||
return Object.freeze({
|
||||
command: input.command,
|
||||
arguments: Object.freeze([...input.arguments]),
|
||||
options: Object.freeze({
|
||||
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
||||
encoding: "utf8",
|
||||
env: input.environment,
|
||||
killSignal: "SIGTERM",
|
||||
maxBuffer: PNPM_SCRIPT_MAX_OUTPUT_BYTES,
|
||||
timeout: PNPM_SCRIPT_TIMEOUT_MS,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPnpmScriptFailure(
|
||||
script: string,
|
||||
result: PnpmScriptResult,
|
||||
): string {
|
||||
const code =
|
||||
result.error && "code" in result.error &&
|
||||
typeof result.error.code === "string"
|
||||
? result.error.code
|
||||
: null;
|
||||
const message = result.error?.message.trim().replace(/\s+/g, " ") ?? null;
|
||||
const error =
|
||||
result.error === undefined
|
||||
? "none"
|
||||
: `${code ?? result.error.name}: ${message || "no message"}`;
|
||||
return `${script} failed: exit=${String(result.status)}, signal=${result.signal ?? "none"}, error=${error}`;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export const CI_BUILD_ENVIRONMENT_VARIABLES = Object.freeze([
|
||||
"VITE_BUILD_ID",
|
||||
"VITE_COMMIT_SHA",
|
||||
"RELEASE_ID",
|
||||
"CI_RUNNER_IMAGE",
|
||||
"SOURCE_DATE_EPOCH",
|
||||
]);
|
||||
|
||||
export function ciBuildEnvironmentFailures(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
if (environment.CI !== "true") return [];
|
||||
|
||||
const failures = CI_BUILD_ENVIRONMENT_VARIABLES.filter(
|
||||
(name) => !environment[name]?.trim(),
|
||||
).map((name) => `missing required CI build environment: ${name}`);
|
||||
|
||||
const commitSha = environment.VITE_COMMIT_SHA?.trim();
|
||||
if (commitSha && !isValidCommitSha(commitSha)) {
|
||||
failures.push(
|
||||
"VITE_COMMIT_SHA must be a full 40- or 64-character hexadecimal commit ID",
|
||||
);
|
||||
}
|
||||
|
||||
const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (sourceDateEpoch && !isValidSourceDateEpoch(sourceDateEpoch)) {
|
||||
failures.push("SOURCE_DATE_EPOCH must be non-negative epoch seconds");
|
||||
}
|
||||
|
||||
const runnerImage = environment.CI_RUNNER_IMAGE?.trim();
|
||||
if (
|
||||
runnerImage &&
|
||||
!/@sha256:[0-9a-f]{64}$/i.test(runnerImage)
|
||||
) {
|
||||
failures.push(
|
||||
"CI_RUNNER_IMAGE must end with an immutable @sha256 image digest",
|
||||
);
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function assertCiBuildEnvironment(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
const failures = ciBuildEnvironmentFailures(environment);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(failures.join("; "));
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidCommitSha(value: string) {
|
||||
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
|
||||
}
|
||||
|
||||
export function isValidSourceDateEpoch(value: string) {
|
||||
if (!/^\d+$/.test(value)) return false;
|
||||
const milliseconds = Number(value) * 1_000;
|
||||
return Number.isSafeInteger(milliseconds) && Number.isFinite(
|
||||
new Date(milliseconds).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function ciCheckoutIdentityFailures(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
checkout: { commitSha: string; sourceDateEpoch: string },
|
||||
) {
|
||||
if (environment.CI !== "true") return [];
|
||||
|
||||
const failures = [];
|
||||
const configuredCommitSha = environment.VITE_COMMIT_SHA?.trim();
|
||||
if (
|
||||
configuredCommitSha &&
|
||||
configuredCommitSha.toLowerCase() !== checkout.commitSha.toLowerCase()
|
||||
) {
|
||||
failures.push("VITE_COMMIT_SHA does not identify the checked-out commit");
|
||||
}
|
||||
const configuredEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (configuredEpoch && configuredEpoch !== checkout.sourceDateEpoch) {
|
||||
failures.push(
|
||||
"SOURCE_DATE_EPOCH does not match the checked-out commit timestamp",
|
||||
);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function buildDate(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (!sourceDateEpoch) return new Date();
|
||||
if (!isValidSourceDateEpoch(sourceDateEpoch)) {
|
||||
throw new Error("SOURCE_DATE_EPOCH must be non-negative epoch seconds");
|
||||
}
|
||||
return new Date(Number(sourceDateEpoch) * 1_000);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readFile, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { BuildManifestArtifact } from "../../src/contracts/release-artifacts.ts";
|
||||
import { moduleInventoryArtifactSchema } from "../contracts/release-artifacts.ts";
|
||||
|
||||
type VerifyBuildManifestOutputsDependencies = Readonly<{
|
||||
repositoryRoot?: string;
|
||||
readBytes?: (target: string) => Promise<Buffer>;
|
||||
realpathPath?: (target: string) => Promise<string>;
|
||||
assertRegularFile?: (target: string) => Promise<void>;
|
||||
assertDirectory?: (target: string) => Promise<void>;
|
||||
}>;
|
||||
|
||||
export const CANONICAL_VITE_MANIFEST_PATH = "dist/.vite/manifest.json";
|
||||
|
||||
function isSafeRelativePath(value: string): boolean {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
!path.posix.isAbsolute(value) &&
|
||||
!path.win32.isAbsolute(value) &&
|
||||
!value.includes("\\") &&
|
||||
!value.includes("\0") &&
|
||||
path.posix.normalize(value) === value &&
|
||||
value !== ".." &&
|
||||
!value.startsWith("../")
|
||||
);
|
||||
}
|
||||
|
||||
function isWithinRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function belongsToApprovedRoot(value: string, approvedRoot: string): boolean {
|
||||
return value.startsWith(`${approvedRoot}/`);
|
||||
}
|
||||
|
||||
async function defaultAssertRegularFile(target: string): Promise<void> {
|
||||
const metadata = await lstat(target);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||
throw new TypeError("not a regular file");
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultAssertDirectory(target: string): Promise<void> {
|
||||
const metadata = await lstat(target);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||
throw new TypeError("not a directory");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyBuildManifestOutputs(
|
||||
manifest: BuildManifestArtifact,
|
||||
dependencies: VerifyBuildManifestOutputsDependencies = {},
|
||||
): Promise<string[]> {
|
||||
const repositoryRoot = path.resolve(dependencies.repositoryRoot ?? process.cwd());
|
||||
const readBytes = dependencies.readBytes ?? readFile;
|
||||
const realpathPath = dependencies.realpathPath ?? realpath;
|
||||
const assertRegularFile = dependencies.assertRegularFile ?? defaultAssertRegularFile;
|
||||
const assertDirectory = dependencies.assertDirectory ?? defaultAssertDirectory;
|
||||
const mismatches: string[] = [];
|
||||
const resolvedRoot = await realpathPath(repositoryRoot);
|
||||
const approvedRoots = new Map<string, Promise<string | null>>();
|
||||
|
||||
function resolveApprovedRoot(relativeRoot: string): Promise<string | null> {
|
||||
const existing = approvedRoots.get(relativeRoot);
|
||||
if (existing) return existing;
|
||||
const pending = (async () => {
|
||||
const absoluteRoot = path.resolve(repositoryRoot, relativeRoot);
|
||||
try {
|
||||
await assertDirectory(absoluteRoot);
|
||||
const resolvedApprovedRoot = await realpathPath(absoluteRoot);
|
||||
return isWithinRoot(resolvedRoot, resolvedApprovedRoot)
|
||||
? resolvedApprovedRoot
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
approvedRoots.set(relativeRoot, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function confinedPath(
|
||||
label: string,
|
||||
relativePath: string,
|
||||
kind: "file" | "directory",
|
||||
approvedRoot?: string,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
!isSafeRelativePath(relativePath) ||
|
||||
(approvedRoot !== undefined &&
|
||||
!belongsToApprovedRoot(relativePath, approvedRoot))
|
||||
) {
|
||||
mismatches.push(`buildManifest:${label}:path`);
|
||||
return null;
|
||||
}
|
||||
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
||||
if (!isWithinRoot(repositoryRoot, absolutePath)) {
|
||||
mismatches.push(`buildManifest:${label}:path`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (kind === "file") await assertRegularFile(absolutePath);
|
||||
else await assertDirectory(absolutePath);
|
||||
const resolvedPath = await realpathPath(absolutePath);
|
||||
const resolvedApprovedRoot = approvedRoot
|
||||
? await resolveApprovedRoot(approvedRoot)
|
||||
: resolvedRoot;
|
||||
if (
|
||||
resolvedApprovedRoot === null ||
|
||||
!isWithinRoot(resolvedRoot, resolvedPath) ||
|
||||
!isWithinRoot(resolvedApprovedRoot, resolvedPath)
|
||||
) {
|
||||
mismatches.push(`buildManifest:${label}:path`);
|
||||
return null;
|
||||
}
|
||||
return absolutePath;
|
||||
} catch {
|
||||
mismatches.push(`buildManifest:${label}:missing`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.outputs.directory !== "dist") {
|
||||
mismatches.push("buildManifest:directory:path");
|
||||
} else {
|
||||
await confinedPath("directory", manifest.outputs.directory, "directory");
|
||||
}
|
||||
if (manifest.outputs.viteManifest !== CANONICAL_VITE_MANIFEST_PATH) {
|
||||
mismatches.push("buildManifest:viteManifest:path");
|
||||
} else {
|
||||
await confinedPath(
|
||||
"viteManifest",
|
||||
manifest.outputs.viteManifest,
|
||||
"file",
|
||||
"dist",
|
||||
);
|
||||
}
|
||||
await confinedPath(
|
||||
"runtimeConfigSchema",
|
||||
manifest.outputs.runtimeConfigSchema,
|
||||
"file",
|
||||
"dist",
|
||||
);
|
||||
for (const [chunkId, chunkPath] of Object.entries(manifest.outputs.routeChunks)) {
|
||||
if (!isSafeRelativePath(chunkPath)) {
|
||||
mismatches.push(`buildManifest:routeChunk:${chunkId}:path`);
|
||||
continue;
|
||||
}
|
||||
await confinedPath(
|
||||
`routeChunk:${chunkId}`,
|
||||
path.posix.join(manifest.outputs.directory, chunkPath),
|
||||
"file",
|
||||
"dist",
|
||||
);
|
||||
}
|
||||
const moduleInventoryPath = await confinedPath(
|
||||
"moduleInventory",
|
||||
manifest.outputs.moduleInventory,
|
||||
"file",
|
||||
"artifacts/quality",
|
||||
);
|
||||
if (moduleInventoryPath) {
|
||||
try {
|
||||
const bytes = await readBytes(moduleInventoryPath);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
if (digest !== manifest.moduleInventoryHash) {
|
||||
mismatches.push("buildManifest:moduleInventoryHash");
|
||||
}
|
||||
try {
|
||||
moduleInventoryArtifactSchema.parse(JSON.parse(bytes.toString("utf8")));
|
||||
} catch {
|
||||
mismatches.push("buildManifest:moduleInventory:invalid");
|
||||
}
|
||||
} catch {
|
||||
mismatches.push("buildManifest:moduleInventory:missing");
|
||||
}
|
||||
}
|
||||
return mismatches;
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { lstat, open, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { z, type ZodType } from "zod";
|
||||
|
||||
import type {
|
||||
CiGateArtifact,
|
||||
CiGateArtifactSchema,
|
||||
} from "../contracts/ci-gates.ts";
|
||||
import { ciContractReportSchema } from "./ci-contract-report.ts";
|
||||
import {
|
||||
architectureDependencyReportArtifactSchema,
|
||||
automatedA11yArtifactSchema,
|
||||
buildManifestArtifactSchema,
|
||||
bundlePerformanceArtifactSchema,
|
||||
compatibilityFixturesArtifactSchema,
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
designSystemReportArtifactSchema,
|
||||
diagnosticsReportArtifactSchema,
|
||||
documentationReviewArtifactSchema,
|
||||
fieldWebVitalsArtifactSchema,
|
||||
hostingHeadersArtifactSchema,
|
||||
i18nReportArtifactSchema,
|
||||
jsonSchemaDocumentArtifactSchema,
|
||||
labPerformanceArtifactSchema,
|
||||
licenseReportArtifactSchema,
|
||||
manualA11yReportArtifactSchema,
|
||||
moduleInventoryArtifactSchema,
|
||||
optionalRecipeFixturesArtifactSchema,
|
||||
optionalRecipesArtifactSchema,
|
||||
provenanceArtifactSchema,
|
||||
realtimeBoundariesArtifactSchema,
|
||||
registryGovernanceRunArtifactSchema,
|
||||
registryCompatibilityFixturesArtifactSchema,
|
||||
registrySnapshotArtifactSchema,
|
||||
releaseVerificationArtifactSchema,
|
||||
reproducibleBuildArtifactSchema,
|
||||
runbookRecordArtifactSchema,
|
||||
sbomArtifactSchema,
|
||||
supplyChainFixturesArtifactSchema,
|
||||
supplyChainProviderFixturesArtifactSchema,
|
||||
supplyChainVerificationArtifactSchema,
|
||||
vulnerabilityReportArtifactSchema,
|
||||
} from "../contracts/release-artifacts.ts";
|
||||
import { httpScenarioReceiptSchema } from "./http-scenario-evidence.ts";
|
||||
import { supplyChainCoherenceReportSchema } from "./local-release-evidence.ts";
|
||||
import {
|
||||
providerVerificationArtifactSchema,
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
} from "./provider-evidence.ts";
|
||||
import { releaseCandidateManifestSchema } from "./release-candidate.ts";
|
||||
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
|
||||
import { secretScanSarifSchema } from "./secret-scan-evaluator.ts";
|
||||
import { testEvidenceReportSchema } from "./test-evidence-artifact.ts";
|
||||
|
||||
const coverageCounterSchema = z
|
||||
.object({
|
||||
total: z.number().int().nonnegative(),
|
||||
covered: z.number().int().nonnegative(),
|
||||
skipped: z.number().int().nonnegative(),
|
||||
pct: z.number().min(0).max(100),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((counter, context) => {
|
||||
if (counter.covered + counter.skipped > counter.total) {
|
||||
context.addIssue({ code: "custom", message: "coverage counter exceeds total" });
|
||||
}
|
||||
const expected = counter.total === 0
|
||||
? 100
|
||||
: Math.floor((counter.covered / counter.total) * 10_000) / 100;
|
||||
if (counter.pct !== expected) {
|
||||
context.addIssue({ code: "custom", path: ["pct"], message: "coverage pct is not exact" });
|
||||
}
|
||||
});
|
||||
const coverageSummarySchema = z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
lines: coverageCounterSchema,
|
||||
statements: coverageCounterSchema,
|
||||
functions: coverageCounterSchema,
|
||||
branches: coverageCounterSchema,
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.refine((value) => "total" in value, "coverage summary lacks total");
|
||||
const riskCoverageArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(3),
|
||||
policy: z.string().min(1),
|
||||
summary: z.string().min(1),
|
||||
status: z.enum(["PASS", "FAIL"]),
|
||||
selectedTotal: z.number().int().nonnegative(),
|
||||
repositoryTotal: z.number().int().positive(),
|
||||
counterBearingTotal: z.number().int().nonnegative(),
|
||||
instrumentedCounterBearingTotal: z.number().int().nonnegative(),
|
||||
counterlessTotal: z.number().int().nonnegative(),
|
||||
counterlessModules: z.array(z.string()),
|
||||
preExclusionTotal: z.number().int().positive(),
|
||||
generatedExclusionCount: z.number().int().nonnegative(),
|
||||
generatedExclusions: z.array(z.string()),
|
||||
ownershipScope: z.literal("ALL_POLICY_HIGH_RISK"),
|
||||
ownedHighRiskPaths: z.array(z.string()),
|
||||
waivedHighRiskPaths: z.array(z.string()),
|
||||
uncoveredModules: z.array(z.string()),
|
||||
results: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
scope: z.string().min(1),
|
||||
metric: z.enum(["lines", "statements", "functions", "branches"]),
|
||||
threshold: z.number().min(0).max(100),
|
||||
received: z.number().min(0).max(100),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(4),
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const fail = (path: PropertyKey[], message: string) =>
|
||||
context.addIssue({ code: "custom", path, message });
|
||||
if (artifact.counterBearingTotal + artifact.counterlessTotal !== artifact.repositoryTotal) {
|
||||
fail(["counterBearingTotal"], "counter partition must equal repositoryTotal");
|
||||
}
|
||||
if (artifact.instrumentedCounterBearingTotal > artifact.counterBearingTotal) {
|
||||
fail(["instrumentedCounterBearingTotal"], "instrumented counters exceed counter-bearing total");
|
||||
}
|
||||
if (artifact.counterlessModules.length !== artifact.counterlessTotal) {
|
||||
fail(["counterlessModules"], "counterless list length drift");
|
||||
}
|
||||
if (artifact.generatedExclusions.length !== artifact.generatedExclusionCount) {
|
||||
fail(["generatedExclusions"], "generated exclusion list length drift");
|
||||
}
|
||||
if (
|
||||
artifact.preExclusionTotal !==
|
||||
artifact.repositoryTotal + artifact.generatedExclusionCount
|
||||
) {
|
||||
fail(["preExclusionTotal"], "pre-exclusion inventory total drift");
|
||||
}
|
||||
if (
|
||||
artifact.selectedTotal > artifact.repositoryTotal ||
|
||||
artifact.uncoveredModules.length !== artifact.repositoryTotal - artifact.selectedTotal
|
||||
) {
|
||||
fail(["selectedTotal"], "selected/uncovered repository totals drift");
|
||||
}
|
||||
if (
|
||||
(artifact.status === "PASS") !==
|
||||
(artifact.failures.length === 0 && artifact.results.every(({ passed }) => passed))
|
||||
) {
|
||||
fail(["status"], "status must agree with failures and threshold results");
|
||||
}
|
||||
artifact.results.forEach((result, index) => {
|
||||
if (result.passed !== (result.received >= result.threshold)) {
|
||||
fail(["results", index, "passed"], "threshold result is inconsistent");
|
||||
}
|
||||
});
|
||||
for (const [field, values] of [
|
||||
["counterlessModules", artifact.counterlessModules],
|
||||
["generatedExclusions", artifact.generatedExclusions],
|
||||
["ownedHighRiskPaths", artifact.ownedHighRiskPaths],
|
||||
["waivedHighRiskPaths", artifact.waivedHighRiskPaths],
|
||||
["uncoveredModules", artifact.uncoveredModules],
|
||||
] as const) {
|
||||
if (new Set(values).size !== values.length) fail([field], "path list contains duplicates");
|
||||
}
|
||||
const owned = new Set(artifact.ownedHighRiskPaths);
|
||||
if (artifact.waivedHighRiskPaths.some((modulePath) => owned.has(modulePath))) {
|
||||
fail(["waivedHighRiskPaths"], "owned and waived high-risk paths overlap");
|
||||
}
|
||||
const resultsByScope = new Map<string, Set<string>>();
|
||||
artifact.results.forEach(({ scope, metric }, index) => {
|
||||
const metrics = resultsByScope.get(scope) ?? new Set<string>();
|
||||
if (metrics.has(metric)) {
|
||||
fail(["results", index, "metric"], "threshold metric is duplicated within scope");
|
||||
}
|
||||
metrics.add(metric);
|
||||
resultsByScope.set(scope, metrics);
|
||||
});
|
||||
for (const [scope, metrics] of resultsByScope) {
|
||||
if (metrics.size !== 4) {
|
||||
fail(["results"], `threshold scope must contain all four metrics: ${scope}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type ExecutableJsonSchemaId = Extract<
|
||||
CiGateArtifactSchema,
|
||||
Readonly<{ kind: "json" }>
|
||||
>["executableSchemaId"];
|
||||
|
||||
const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> = Object.freeze({
|
||||
"automated-a11y": automatedA11yArtifactSchema,
|
||||
"manual-a11y": manualA11yReportArtifactSchema,
|
||||
"architecture-dependency-report": architectureDependencyReportArtifactSchema,
|
||||
"design-system-contract": designSystemReportArtifactSchema,
|
||||
"i18n-contract": i18nReportArtifactSchema,
|
||||
"diagnostics-contract": diagnosticsReportArtifactSchema,
|
||||
"realtime-boundaries": realtimeBoundariesArtifactSchema,
|
||||
"optional-recipes": optionalRecipesArtifactSchema,
|
||||
"optional-recipe-fixtures": optionalRecipeFixturesArtifactSchema,
|
||||
"registry-compatibility-fixtures": registryCompatibilityFixturesArtifactSchema,
|
||||
"reproducible-build": reproducibleBuildArtifactSchema,
|
||||
"supply-chain-fixtures": supplyChainFixturesArtifactSchema,
|
||||
"supply-chain-provider-fixtures": supplyChainProviderFixturesArtifactSchema,
|
||||
"compatibility-fixtures": compatibilityFixturesArtifactSchema,
|
||||
"documentation-review": documentationReviewArtifactSchema,
|
||||
"hosting-headers": hostingHeadersArtifactSchema,
|
||||
"coverage-summary-v8": coverageSummarySchema,
|
||||
"risk-coverage-v3": riskCoverageArtifactSchema,
|
||||
"build-manifest": buildManifestArtifactSchema,
|
||||
"module-inventory": moduleInventoryArtifactSchema,
|
||||
"dependency-inventory": dependencyInventoryArtifactSchema,
|
||||
"registry-snapshot": registrySnapshotArtifactSchema,
|
||||
"registry-governance-run": registryGovernanceRunArtifactSchema,
|
||||
"bundle-performance": bundlePerformanceArtifactSchema,
|
||||
sbom: sbomArtifactSchema,
|
||||
provenance: provenanceArtifactSchema,
|
||||
"dependency-diff": dependencyDiffArtifactSchema,
|
||||
"license-report": licenseReportArtifactSchema,
|
||||
"vulnerability-report": vulnerabilityReportArtifactSchema,
|
||||
"field-web-vitals": fieldWebVitalsArtifactSchema,
|
||||
"lab-performance": labPerformanceArtifactSchema,
|
||||
"release-verification": releaseVerificationArtifactSchema,
|
||||
"runbook-record": runbookRecordArtifactSchema,
|
||||
"supply-chain-verification": supplyChainVerificationArtifactSchema,
|
||||
"release-candidate": releaseCandidateManifestSchema,
|
||||
"supply-chain-coherence": supplyChainCoherenceReportSchema,
|
||||
"http-scenario-receipt": httpScenarioReceiptSchema,
|
||||
"test-evidence-report": testEvidenceReportSchema,
|
||||
"provider-vulnerability": vulnerabilityProviderReportSchema,
|
||||
"provider-provenance": provenanceProviderAttestationSchema,
|
||||
"provider-verification": providerVerificationArtifactSchema,
|
||||
"ci-contract-report": ciContractReportSchema,
|
||||
});
|
||||
|
||||
export function hasCiArtifactSemanticValidator(
|
||||
schema: CiGateArtifactSchema,
|
||||
): boolean {
|
||||
return schema.kind !== "json" || schema.executableSchemaId in executableJsonSchemas;
|
||||
}
|
||||
|
||||
type ReadHandle = Readonly<{
|
||||
stat(): Promise<Stats>;
|
||||
read(
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
): Promise<Readonly<{ bytesRead: number }>>;
|
||||
close(): Promise<unknown>;
|
||||
}>;
|
||||
type ValidatorDependencies = Readonly<{
|
||||
lstatPath?: typeof lstat;
|
||||
realpathPath?: typeof realpath;
|
||||
openFile?: (target: string, flags: number) => Promise<ReadHandle>;
|
||||
}>;
|
||||
|
||||
export async function validateCiArtifact(
|
||||
input: Readonly<{
|
||||
root: string;
|
||||
artifact: CiGateArtifact;
|
||||
schema: CiGateArtifactSchema;
|
||||
}>,
|
||||
dependencies: ValidatorDependencies = {},
|
||||
): Promise<void> {
|
||||
const relative = normalizeRepositoryRelativePath(input.artifact.path, "CI artifact path");
|
||||
assertExtensionCoherence(relative, input.schema.kind);
|
||||
const bytes = await readBoundedRegularFile(
|
||||
{ root: input.root, relativePath: relative, maxBytes: input.schema.maxBytes },
|
||||
dependencies,
|
||||
);
|
||||
if (input.schema.kind === "candidate-archive") return;
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
if (!text.trim()) throw new TypeError(`CI artifact is empty: ${relative}`);
|
||||
switch (input.schema.kind) {
|
||||
case "text":
|
||||
return;
|
||||
case "markdown":
|
||||
if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`);
|
||||
return;
|
||||
case "html":
|
||||
assertWellFormedHtml(text, relative);
|
||||
return;
|
||||
case "junit":
|
||||
assertWellFormedJUnitXml(text, relative);
|
||||
return;
|
||||
case "sarif":
|
||||
secretScanSarifSchema.parse(JSON.parse(text) as unknown);
|
||||
return;
|
||||
case "json-schema":
|
||||
jsonSchemaDocumentArtifactSchema.parse(JSON.parse(text) as unknown);
|
||||
return;
|
||||
case "json": {
|
||||
const schema = executableJsonSchemas[input.schema.executableSchemaId];
|
||||
if (!schema) throw new TypeError(`unknown executable artifact schema: ${input.schema.executableSchemaId}`);
|
||||
schema.parse(JSON.parse(text) as unknown);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBoundedRegularFile(
|
||||
input: Readonly<{ root: string; relativePath: string; maxBytes: number }>,
|
||||
dependencies: ValidatorDependencies = {},
|
||||
): Promise<Buffer> {
|
||||
const root = path.resolve(input.root);
|
||||
const relative = normalizeRepositoryRelativePath(input.relativePath, "bounded file path");
|
||||
const maxBytes = input.maxBytes;
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 268_435_456) {
|
||||
throw new RangeError("bounded file maximum must be within 1..268435456");
|
||||
}
|
||||
const lstatPath = dependencies.lstatPath ?? lstat;
|
||||
const realpathPath = dependencies.realpathPath ?? realpath;
|
||||
const openFile = dependencies.openFile ?? (async (target, flags) => open(target, flags));
|
||||
const rootMetadata = await lstatPath(root);
|
||||
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
||||
throw new TypeError("bounded file root is unsafe");
|
||||
}
|
||||
const rootRealpath = await realpathPath(root);
|
||||
let ancestor = root;
|
||||
const segments = relative.split("/");
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
ancestor = path.join(ancestor, segment);
|
||||
const metadata = await lstatPath(ancestor);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new TypeError(`CI artifact ancestor is unsafe: ${relative}`);
|
||||
}
|
||||
}
|
||||
const absolute = path.join(root, relative);
|
||||
const before = await lstatPath(absolute);
|
||||
if (before.isSymbolicLink() || !before.isFile()) {
|
||||
throw new TypeError(`CI artifact is not a regular file: ${relative}`);
|
||||
}
|
||||
if (before.size <= 0 || before.size > maxBytes) {
|
||||
throw new RangeError(`CI artifact size is outside 1..${maxBytes}: ${relative}`);
|
||||
}
|
||||
const resolved = await realpathPath(absolute);
|
||||
const outside = path.relative(rootRealpath, resolved);
|
||||
if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) {
|
||||
throw new TypeError(`CI artifact escapes repository: ${relative}`);
|
||||
}
|
||||
const handle = await openFile(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
assertSameIdentity(before, opened, relative);
|
||||
const bytes = await readHandleBounded(handle, before.size, maxBytes, relative);
|
||||
const after = await handle.stat();
|
||||
assertSameIdentity(opened, after, relative);
|
||||
if (bytes.byteLength <= 0 || bytes.byteLength > maxBytes || after.size !== bytes.byteLength) {
|
||||
throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`);
|
||||
}
|
||||
return bytes;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readHandleBounded(
|
||||
handle: ReadHandle,
|
||||
expectedSize: number,
|
||||
maxBytes: number,
|
||||
relative: string,
|
||||
): Promise<Buffer> {
|
||||
const captured = Buffer.allocUnsafe(Math.min(maxBytes + 1, expectedSize + 1));
|
||||
let offset = 0;
|
||||
while (offset < captured.byteLength) {
|
||||
const { bytesRead } = await handle.read(
|
||||
captured,
|
||||
offset,
|
||||
captured.byteLength - offset,
|
||||
offset,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
}
|
||||
if (offset !== expectedSize) {
|
||||
throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`);
|
||||
}
|
||||
return captured.subarray(0, offset);
|
||||
}
|
||||
|
||||
const MAX_DOCUMENT_DEPTH = 256;
|
||||
const MAX_DOCUMENT_UNITS = 100_000;
|
||||
|
||||
function assertWellFormedJUnitXml(source: string, relative: string): void {
|
||||
const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`);
|
||||
if (/<!DOCTYPE\b|<!ENTITY\b/iu.test(source)) throw invalid();
|
||||
if (!hasOnlyXmlCharacters(source)) throw invalid();
|
||||
const stack: string[] = [];
|
||||
let root: string | undefined;
|
||||
let rootClosed = false;
|
||||
let declarationSeen = false;
|
||||
let units = 0;
|
||||
let cursor = 0;
|
||||
while (cursor < source.length) {
|
||||
const open = source.indexOf("<", cursor);
|
||||
const text = source.slice(cursor, open < 0 ? source.length : open);
|
||||
if (stack.length === 0 && text.trim()) throw invalid();
|
||||
if ((text.includes("]]>") || !hasValidXmlEntities(text)) && text.length > 0) throw invalid();
|
||||
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
if (open < 0) break;
|
||||
if (source.startsWith("<!--", open)) {
|
||||
const close = source.indexOf("-->", open + 4);
|
||||
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 3;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("<![CDATA[", open)) {
|
||||
const close = source.indexOf("]]>", open + 9);
|
||||
if (stack.length === 0 || close < 0) throw invalid();
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 3;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("<?", open)) {
|
||||
const close = source.indexOf("?>", open + 2);
|
||||
if (root || close < 0) throw invalid();
|
||||
const processingInstruction = source.slice(open, close + 2);
|
||||
const match = /^<\?([A-Za-z_][\w:.-]*)(?:\s+[\s\S]*?)?\?>$/u.exec(
|
||||
processingInstruction,
|
||||
);
|
||||
if (!match) throw invalid();
|
||||
if (match[1]!.toLowerCase() === "xml") {
|
||||
if (
|
||||
declarationSeen ||
|
||||
source.slice(0, open).trim() ||
|
||||
!/^<\?xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?\s*\?>$/u.test(
|
||||
processingInstruction,
|
||||
)
|
||||
) {
|
||||
throw invalid();
|
||||
}
|
||||
declarationSeen = true;
|
||||
}
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 2;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("<!", open)) throw invalid();
|
||||
const close = markupEnd(source, open + 1);
|
||||
if (close < 0) throw invalid();
|
||||
const tag = source.slice(open, close + 1);
|
||||
const closing = /^<\/([A-Za-z_][\w:.-]*)\s*>$/u.exec(tag);
|
||||
if (closing) {
|
||||
if (stack.pop() !== closing[1]) throw invalid();
|
||||
if (stack.length === 0) rootClosed = true;
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
|
||||
if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid();
|
||||
root ??= opening[1];
|
||||
if (opening[3] !== "/") {
|
||||
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
|
||||
stack.push(opening[1]!);
|
||||
}
|
||||
else if (stack.length === 0) rootClosed = true;
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 1;
|
||||
}
|
||||
if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function hasValidXmlAttributes(source: string): boolean {
|
||||
let remaining = source;
|
||||
const names = new Set<string>();
|
||||
while (remaining.length > 0) {
|
||||
if (!remaining.trim()) return true;
|
||||
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/u.exec(remaining);
|
||||
if (!match || names.has(match[1]!)) return false;
|
||||
if (!hasValidXmlEntities(match[2] ?? match[3] ?? "")) return false;
|
||||
names.add(match[1]!);
|
||||
remaining = remaining.slice(match[0].length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasOnlyXmlCharacters(source: string): boolean {
|
||||
for (const character of source) {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
if (
|
||||
codePoint !== 0x09 &&
|
||||
codePoint !== 0x0a &&
|
||||
codePoint !== 0x0d &&
|
||||
(codePoint < 0x20 ||
|
||||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
|
||||
(codePoint > 0xfffd && codePoint < 0x10000) ||
|
||||
codePoint > 0x10ffff)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasValidXmlEntities(source: string): boolean {
|
||||
let cursor = 0;
|
||||
while (cursor < source.length) {
|
||||
const ampersand = source.indexOf("&", cursor);
|
||||
if (ampersand < 0) return true;
|
||||
const semicolon = source.indexOf(";", ampersand + 1);
|
||||
if (semicolon < 0) return false;
|
||||
const entity = source.slice(ampersand + 1, semicolon);
|
||||
if (!["amp", "lt", "gt", "apos", "quot"].includes(entity)) {
|
||||
const decimal = /^#([0-9]+)$/u.exec(entity);
|
||||
const hexadecimal = /^#x([a-fA-F0-9]+)$/u.exec(entity);
|
||||
if (!decimal && !hexadecimal) return false;
|
||||
const codePoint = Number.parseInt((decimal ?? hexadecimal)![1]!, decimal ? 10 : 16);
|
||||
if (
|
||||
!Number.isSafeInteger(codePoint) ||
|
||||
(codePoint !== 0x09 &&
|
||||
codePoint !== 0x0a &&
|
||||
codePoint !== 0x0d &&
|
||||
(codePoint < 0x20 ||
|
||||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
|
||||
(codePoint > 0xfffd && codePoint < 0x10000) ||
|
||||
codePoint > 0x10ffff))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
cursor = semicolon + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const HTML_VOID_ELEMENTS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
const HTML_RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
|
||||
|
||||
function assertWellFormedHtml(source: string, relative: string): void {
|
||||
const invalid = () => new TypeError(`invalid HTML artifact: ${relative}`);
|
||||
if (/<!ENTITY\b|<!DOCTYPE\s+html\s+[^>]*\[/iu.test(source)) throw invalid();
|
||||
const stack: string[] = [];
|
||||
let cursor = 0;
|
||||
let units = 0;
|
||||
let doctypeSeen = false;
|
||||
let rootSeen = false;
|
||||
let rootClosed = false;
|
||||
let playwrightPayloadSeen = false;
|
||||
while (cursor < source.length) {
|
||||
const rawElement = stack.at(-1);
|
||||
if (rawElement && HTML_RAW_TEXT_ELEMENTS.has(rawElement)) {
|
||||
const closingStart = source.toLowerCase().indexOf(`</${rawElement}`, cursor);
|
||||
if (closingStart < 0) throw invalid();
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = closingStart;
|
||||
}
|
||||
const open = source.indexOf("<", cursor);
|
||||
const text = source.slice(cursor, open < 0 ? source.length : open);
|
||||
if (stack.length === 0 && text.trim()) throw invalid();
|
||||
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
if (open < 0) break;
|
||||
if (source.startsWith("<!--", open)) {
|
||||
const close = source.indexOf("-->", open + 4);
|
||||
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 3;
|
||||
continue;
|
||||
}
|
||||
const declarationEnd = source.indexOf(">", open + 2);
|
||||
if (source.slice(open, open + 9).toLowerCase() === "<!doctype") {
|
||||
if (
|
||||
declarationEnd < 0 ||
|
||||
doctypeSeen ||
|
||||
rootSeen ||
|
||||
source.slice(open, declarationEnd + 1).toLowerCase() !== "<!doctype html>"
|
||||
) {
|
||||
throw invalid();
|
||||
}
|
||||
doctypeSeen = true;
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = declarationEnd + 1;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("<!", open) || source.startsWith("<?", open)) throw invalid();
|
||||
const close = markupEnd(source, open + 1);
|
||||
if (close < 0) throw invalid();
|
||||
const tag = source.slice(open, close + 1);
|
||||
const closing = /^<\/([A-Za-z][A-Za-z0-9:-]*)\s*>$/u.exec(tag);
|
||||
if (closing) {
|
||||
const name = closing[1]!.toLowerCase();
|
||||
if (stack.pop() !== name) throw invalid();
|
||||
if (stack.length === 0) {
|
||||
if (name === "html") rootClosed = true;
|
||||
else if (name === "template" && playwrightPayloadSeen) {
|
||||
// Playwright emits its base64 report template after </html>; HTML5
|
||||
// reparents this token into the document body. It is the sole
|
||||
// permitted generated-report sidecar and does not create a new root.
|
||||
} else {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
const opening = /^<([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
|
||||
if (!opening) throw invalid();
|
||||
const name = opening[1]!.toLowerCase();
|
||||
const attributes = parseHtmlAttributes(opening[2] ?? "");
|
||||
if (!attributes || name.includes(":")) throw invalid();
|
||||
if (!rootSeen) {
|
||||
if (name !== "html" || opening[3] === "/") throw invalid();
|
||||
rootSeen = true;
|
||||
} else if (name === "html") {
|
||||
throw invalid();
|
||||
}
|
||||
if (rootClosed && stack.length === 0) {
|
||||
if (
|
||||
playwrightPayloadSeen ||
|
||||
name !== "template" ||
|
||||
attributes.get("id") !== "playwrightReportBase64" ||
|
||||
opening[3] === "/"
|
||||
) {
|
||||
throw invalid();
|
||||
}
|
||||
playwrightPayloadSeen = true;
|
||||
}
|
||||
if (!HTML_VOID_ELEMENTS.has(name) && opening[3] !== "/") {
|
||||
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
|
||||
stack.push(name);
|
||||
} else if (stack.length === 0 && name === "html") {
|
||||
rootClosed = true;
|
||||
}
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 1;
|
||||
}
|
||||
if (stack.length > 0 || !rootSeen || !rootClosed) throw invalid();
|
||||
}
|
||||
|
||||
function markupEnd(source: string, start: number): number {
|
||||
let quote: "\"" | "'" | undefined;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (quote) {
|
||||
if (character === quote) quote = undefined;
|
||||
} else if (character === "\"" || character === "'") {
|
||||
quote = character;
|
||||
} else if (character === ">") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseHtmlAttributes(source: string): ReadonlyMap<string, string> | null {
|
||||
const attributes = new Map<string, string>();
|
||||
let cursor = 0;
|
||||
while (cursor < source.length) {
|
||||
const whitespace = /^\s+/u.exec(source.slice(cursor));
|
||||
if (!whitespace) return source.slice(cursor).trim() ? null : attributes;
|
||||
cursor += whitespace[0].length;
|
||||
if (cursor >= source.length) return attributes;
|
||||
const nameMatch = /^[A-Za-z_:][A-Za-z0-9:._-]*/u.exec(source.slice(cursor));
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[0].toLowerCase();
|
||||
if (attributes.has(name) || name.includes(":")) return null;
|
||||
cursor += nameMatch[0].length;
|
||||
const spacing = /^\s*/u.exec(source.slice(cursor))![0];
|
||||
cursor += spacing.length;
|
||||
let value = "";
|
||||
if (source[cursor] === "=") {
|
||||
cursor += 1;
|
||||
cursor += /^\s*/u.exec(source.slice(cursor))![0].length;
|
||||
const quote = source[cursor];
|
||||
if (quote === "\"" || quote === "'") {
|
||||
const end = source.indexOf(quote, cursor + 1);
|
||||
if (end < 0) return null;
|
||||
value = source.slice(cursor + 1, end);
|
||||
if (value.includes("<")) return null;
|
||||
cursor = end + 1;
|
||||
} else {
|
||||
const unquoted = /^[^\s"'`=<>]+/u.exec(source.slice(cursor));
|
||||
if (!unquoted) return null;
|
||||
value = unquoted[0];
|
||||
cursor += value.length;
|
||||
}
|
||||
}
|
||||
attributes.set(name, value);
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function assertSameIdentity(before: Stats, after: Stats, relative: string): void {
|
||||
if (
|
||||
!Number.isSafeInteger(before.dev) ||
|
||||
!Number.isSafeInteger(before.ino) ||
|
||||
before.dev <= 0 ||
|
||||
before.ino <= 0 ||
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
!after.isFile()
|
||||
) {
|
||||
throw new TypeError(`CI artifact file identity changed: ${relative}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExtensionCoherence(relative: string, kind: CiGateArtifactSchema["kind"]): void {
|
||||
const valid =
|
||||
kind === "json" || kind === "json-schema"
|
||||
? relative.endsWith(".json")
|
||||
: kind === "sarif"
|
||||
? relative.endsWith(".sarif")
|
||||
: kind === "junit"
|
||||
? relative.endsWith(".xml")
|
||||
: kind === "html"
|
||||
? relative.endsWith(".html")
|
||||
: kind === "markdown"
|
||||
? relative.endsWith(".md")
|
||||
: kind === "candidate-archive"
|
||||
? relative.endsWith(".tar.gz")
|
||||
: !/\.(?:json|sarif|xml|html|md|tar\.gz)$/u.test(relative);
|
||||
if (!valid) throw new TypeError(`CI artifact extension/kind mismatch: ${relative} (${kind})`);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const CANDIDATE_ARCHIVE_USAGE =
|
||||
"Usage: verify-ci-candidate-archive --archive <path> [--extract-to <path>] [--github-output <path>]\n";
|
||||
|
||||
export function parseCandidateArchiveArguments(arguments_: readonly string[]): Readonly<{
|
||||
archivePath: string;
|
||||
extractTo?: string;
|
||||
githubOutput?: string;
|
||||
}> | null {
|
||||
const allowed = new Set(["--archive", "--extract-to", "--github-output"]);
|
||||
const values = new Map<string, string>();
|
||||
for (let index = 0; index < arguments_.length; index += 2) {
|
||||
const flag = arguments_[index];
|
||||
const value = arguments_[index + 1];
|
||||
if (!flag || !allowed.has(flag) || values.has(flag) || !value || value.startsWith("--")) {
|
||||
return null;
|
||||
}
|
||||
values.set(flag, value);
|
||||
}
|
||||
const archivePath = values.get("--archive");
|
||||
if (!archivePath) return null;
|
||||
return Object.freeze({
|
||||
archivePath,
|
||||
...(values.has("--extract-to") ? { extractTo: values.get("--extract-to")! } : {}),
|
||||
...(values.has("--github-output") ? { githubOutput: values.get("--github-output")! } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
distSha256,
|
||||
releaseCandidateManifestSchema,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "./release-candidate.ts";
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
import {
|
||||
assertSafePublishLeaf,
|
||||
ensureSafePublishDirectory,
|
||||
} from "./ci-gate-log.ts";
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 268_435_456;
|
||||
const MAX_CANDIDATE_FILES = 4_096;
|
||||
const MAX_ARCHIVE_MEMBERS = 8_192;
|
||||
const MAX_MEMBER_PATH_BYTES = 1_024;
|
||||
const TAR_EXECUTABLE = "/usr/bin/tar";
|
||||
const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" });
|
||||
|
||||
export type CapturedCandidateArchive = Readonly<{
|
||||
bytes: Buffer;
|
||||
archiveSha256: string;
|
||||
}>;
|
||||
|
||||
export async function captureCiCandidateArchive(input: Readonly<{
|
||||
archivePath: string;
|
||||
expectedSha256: string;
|
||||
}>): Promise<CapturedCandidateArchive> {
|
||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
|
||||
throw new TypeError("expected candidate archive SHA-256 is invalid");
|
||||
}
|
||||
const absolute = path.resolve(input.archivePath);
|
||||
const before = await lstat(absolute);
|
||||
if (!before.isFile() || before.isSymbolicLink()) {
|
||||
throw new TypeError("candidate archive must be a regular non-symlink file");
|
||||
}
|
||||
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
|
||||
}
|
||||
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
bytes = await readCapturedArchive(handle, before.size);
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const archiveSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (archiveSha256 !== input.expectedSha256) {
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
return Object.freeze({ bytes, archiveSha256 });
|
||||
}
|
||||
|
||||
export async function withVerifiedCapturedCandidate<T>(input: Readonly<{
|
||||
captured: CapturedCandidateArchive;
|
||||
verify: (view: Readonly<{
|
||||
extractionRoot: string;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
}>) => Promise<T>;
|
||||
}>): Promise<T> {
|
||||
let result: T | undefined;
|
||||
await verifyCapturedCiCandidateArchive(
|
||||
input.captured.bytes,
|
||||
input.captured.archiveSha256,
|
||||
{
|
||||
verifyExtracted: async (extractionRoot, manifest) => {
|
||||
result = await input.verify({ extractionRoot, manifest });
|
||||
},
|
||||
},
|
||||
);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export async function verifyCiCandidateArchive(
|
||||
input: Readonly<{
|
||||
archivePath: string;
|
||||
expectedSha256?: string;
|
||||
extractTo?: string;
|
||||
repositoryRoot?: string;
|
||||
}>,
|
||||
dependencies: Readonly<{ afterArchiveRead?: () => Promise<void> }> = {},
|
||||
): Promise<Readonly<{
|
||||
archiveSha256: string;
|
||||
memberCount: number;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
}>> {
|
||||
if (input.expectedSha256 && !/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
|
||||
throw new TypeError("expected candidate archive SHA-256 is invalid");
|
||||
}
|
||||
const absolute = path.resolve(input.archivePath);
|
||||
const before = await lstat(absolute);
|
||||
if (!before.isFile() || before.isSymbolicLink()) {
|
||||
throw new TypeError("candidate archive must be a regular non-symlink file");
|
||||
}
|
||||
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
|
||||
}
|
||||
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let archive: Buffer;
|
||||
try {
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
archive = await readCapturedArchive(handle, before.size);
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
if (archive.byteLength !== before.size) {
|
||||
throw new Error("candidate archive changed size during capture");
|
||||
}
|
||||
await dependencies.afterArchiveRead?.();
|
||||
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
|
||||
if (input.expectedSha256 && archiveSha256 !== input.expectedSha256) {
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
|
||||
const extractionTarget = input.extractTo ? path.resolve(input.extractTo) : undefined;
|
||||
let extractionRoot: string;
|
||||
let extractionParentIdentity: Awaited<ReturnType<typeof ensureSafePublishDirectory>> | undefined;
|
||||
if (extractionTarget) {
|
||||
if (!input.repositoryRoot) {
|
||||
throw new TypeError("repositoryRoot is required when publishing an extracted candidate");
|
||||
}
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot);
|
||||
extractionParentIdentity = await ensureSafePublishDirectory(
|
||||
repositoryRoot,
|
||||
path.dirname(extractionTarget),
|
||||
);
|
||||
await assertSafePublishLeaf(extractionTarget, input.extractTo);
|
||||
extractionRoot = await mkdtemp(
|
||||
path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`),
|
||||
);
|
||||
} else {
|
||||
extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-"));
|
||||
}
|
||||
let published = false;
|
||||
try {
|
||||
const captured = await materializeCapturedArchive(archive);
|
||||
try {
|
||||
const preflightManifest = preflightArchiveHandle(captured.handle);
|
||||
extractArchiveHandle(captured.handle, extractionRoot);
|
||||
const verified = await verifyExtractedTree(extractionRoot, preflightManifest);
|
||||
if (extractionTarget) {
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot!);
|
||||
const currentParentIdentity = await ensureSafePublishDirectory(
|
||||
repositoryRoot,
|
||||
path.dirname(extractionTarget),
|
||||
);
|
||||
if (
|
||||
!extractionParentIdentity ||
|
||||
extractionParentIdentity.dev <= 0 ||
|
||||
extractionParentIdentity.ino <= 0 ||
|
||||
currentParentIdentity.dev !== extractionParentIdentity.dev ||
|
||||
currentParentIdentity.ino !== extractionParentIdentity.ino
|
||||
) {
|
||||
throw new Error("verified extraction parent identity changed");
|
||||
}
|
||||
await assertSafePublishLeaf(extractionTarget, input.extractTo);
|
||||
if (await pathExists(extractionTarget)) {
|
||||
throw new Error(`verified extraction target already exists: ${input.extractTo}`);
|
||||
}
|
||||
await rename(extractionRoot, extractionTarget);
|
||||
published = true;
|
||||
}
|
||||
return Object.freeze({
|
||||
archiveSha256,
|
||||
memberCount: verified.memberCount,
|
||||
manifest: verified.manifest,
|
||||
});
|
||||
} finally {
|
||||
await captured.handle.close();
|
||||
await rm(captured.root, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
if (!published) await rm(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyCapturedCiCandidateArchive(
|
||||
archive: Buffer,
|
||||
expectedSha256: string,
|
||||
dependencies: Readonly<{
|
||||
verifyExtracted?: (
|
||||
extractionRoot: string,
|
||||
manifest: ReleaseCandidateManifest,
|
||||
) => Promise<void>;
|
||||
}> = {},
|
||||
): Promise<Readonly<{
|
||||
archiveSha256: string;
|
||||
memberCount: number;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
}>> {
|
||||
if (archive.byteLength <= 0 || archive.byteLength > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) {
|
||||
throw new TypeError("expected candidate archive SHA-256 is invalid");
|
||||
}
|
||||
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
|
||||
if (archiveSha256 !== expectedSha256) {
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
const captured = await materializeCapturedArchive(archive);
|
||||
const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-"));
|
||||
try {
|
||||
const manifest = preflightArchiveHandle(captured.handle);
|
||||
extractArchiveHandle(captured.handle, extractionRoot);
|
||||
const verified = await verifyExtractedTree(extractionRoot, manifest);
|
||||
await dependencies.verifyExtracted?.(extractionRoot, verified.manifest);
|
||||
return Object.freeze({
|
||||
archiveSha256,
|
||||
memberCount: verified.memberCount,
|
||||
manifest: verified.manifest,
|
||||
});
|
||||
} finally {
|
||||
await rm(extractionRoot, { recursive: true, force: true });
|
||||
await captured.handle.close();
|
||||
await rm(captured.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateManifest {
|
||||
const listed = spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
["--list", "--verbose", "--numeric-owner", "--full-time", "--gzip", "--file", "/proc/self/fd/3"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16_777_216,
|
||||
timeout: 10_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
);
|
||||
if (listed.status !== 0 || listed.signal || listed.error) {
|
||||
throw new Error(
|
||||
`candidate archive listing failed: ${listed.stderr || listed.error?.message || listed.signal}`,
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const regularMembers = new Set<string>();
|
||||
const directoryMembers = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
const lines = listed.stdout.split(/\r?\n/u).filter(Boolean);
|
||||
if (lines.length === 0 || lines.length > MAX_ARCHIVE_MEMBERS) {
|
||||
throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const match = /^(?<mode>.{10})\s+\d+\/\d+\s+(?<bytes>\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\s+[+-]\d{4})?\s+(?<path>.+)$/u.exec(line);
|
||||
if (!match?.groups) throw new Error(`candidate archive listing is unparseable: ${line}`);
|
||||
const member = match.groups.path!.endsWith("/")
|
||||
? match.groups.path!.slice(0, -1)
|
||||
: match.groups.path!;
|
||||
assertSafeMemberPath(member);
|
||||
if (seen.has(member)) throw new Error(`candidate archive duplicate member: ${member}`);
|
||||
seen.add(member);
|
||||
const mode = match.groups.mode!;
|
||||
if (!mode.startsWith("-") && !mode.startsWith("d")) {
|
||||
throw new Error(`candidate archive contains non-regular member: ${member}`);
|
||||
}
|
||||
if (mode.startsWith("-")) {
|
||||
const memberBytes = Number(match.groups.bytes);
|
||||
if (
|
||||
member === RELEASE_CANDIDATE_MANIFEST_PATH &&
|
||||
memberBytes > 8_388_608
|
||||
) {
|
||||
throw new RangeError("candidate manifest exceeds 8388608 bytes");
|
||||
}
|
||||
totalBytes += memberBytes;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError("candidate archive expanded bytes exceed the bound");
|
||||
}
|
||||
regularMembers.add(member);
|
||||
} else {
|
||||
directoryMembers.add(member);
|
||||
}
|
||||
}
|
||||
const manifest = readManifestFromArchive(archiveHandle);
|
||||
validateManifestSemantics(manifest);
|
||||
const expectedFiles = new Set([
|
||||
...manifest.files.map(({ path: member }) => member),
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
]);
|
||||
for (const member of expectedFiles) assertSafeMemberPath(member);
|
||||
const expectedDirectories = new Set(
|
||||
directoryAncestors([...expectedFiles]).filter(
|
||||
(member) => member === "dist" || member.startsWith("dist/"),
|
||||
),
|
||||
);
|
||||
if (
|
||||
JSON.stringify([...regularMembers].sort(asciiCompare)) !==
|
||||
JSON.stringify([...expectedFiles].sort(asciiCompare)) ||
|
||||
JSON.stringify([...directoryMembers].sort(asciiCompare)) !==
|
||||
JSON.stringify([...expectedDirectories].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("candidate archive exact member set drift before extraction");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void {
|
||||
const extracted = spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
[
|
||||
"--extract",
|
||||
"--gzip",
|
||||
"--file",
|
||||
"/proc/self/fd/3",
|
||||
"--directory",
|
||||
extractionRoot,
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1_048_576,
|
||||
timeout: 30_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
);
|
||||
if (extracted.status !== 0 || extracted.signal || extracted.error) {
|
||||
throw new Error(
|
||||
`candidate archive isolated extraction failed: ${extracted.stderr || extracted.error?.message || extracted.signal}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifestSemantics(manifest: ReleaseCandidateManifest): void {
|
||||
if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) {
|
||||
throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`);
|
||||
}
|
||||
const canonicalFiles = [...manifest.files].sort((left, right) =>
|
||||
asciiCompare(left.path, right.path),
|
||||
);
|
||||
if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) {
|
||||
throw new Error("candidate manifest files are not in canonical ASCII order");
|
||||
}
|
||||
const expectedFiles = new Map<string, Readonly<{ bytes: number; sha256: string }>>();
|
||||
let declaredBytes = 0;
|
||||
for (const file of manifest.files) {
|
||||
assertSafeMemberPath(file.path);
|
||||
if (expectedFiles.has(file.path)) {
|
||||
throw new Error(`candidate manifest duplicate file: ${file.path}`);
|
||||
}
|
||||
declaredBytes += file.bytes;
|
||||
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError("candidate manifest declared bytes exceed the archive bound");
|
||||
}
|
||||
expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 });
|
||||
}
|
||||
const evidencePaths = [...expectedFiles.keys()]
|
||||
.filter((member) => !member.startsWith("dist/"))
|
||||
.sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(evidencePaths) !==
|
||||
JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("candidate manifest evidence member set drift");
|
||||
}
|
||||
const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/"));
|
||||
if (distFiles.length === 0) throw new Error("candidate manifest has no dist files");
|
||||
const lockfile = expectedFiles.get("pnpm-lock.yaml");
|
||||
if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) {
|
||||
throw new Error("candidate manifest lockfile digest summary mismatch");
|
||||
}
|
||||
if (
|
||||
distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !==
|
||||
manifest.distSha256
|
||||
) {
|
||||
throw new Error("candidate manifest dist digest summary mismatch");
|
||||
}
|
||||
if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) {
|
||||
throw new Error("candidate manifest bundle digest summary mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyExtractedTree(
|
||||
extractionRoot: string,
|
||||
preflightManifest: ReleaseCandidateManifest,
|
||||
): Promise<Readonly<{ memberCount: number; manifest: ReleaseCandidateManifest }>> {
|
||||
const entries = await walkExtractedTree(extractionRoot);
|
||||
if (entries.length === 0 || entries.length > MAX_ARCHIVE_MEMBERS) {
|
||||
throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`);
|
||||
}
|
||||
const manifest = releaseCandidateManifestSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(path.join(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
|
||||
) as unknown,
|
||||
);
|
||||
if (JSON.stringify(manifest) !== JSON.stringify(preflightManifest)) {
|
||||
throw new Error("candidate manifest changed between preflight and extraction");
|
||||
}
|
||||
if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) {
|
||||
throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`);
|
||||
}
|
||||
const canonicalFiles = [...manifest.files].sort((left, right) =>
|
||||
asciiCompare(left.path, right.path),
|
||||
);
|
||||
if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) {
|
||||
throw new Error("candidate manifest files are not in canonical ASCII order");
|
||||
}
|
||||
const expectedFiles = new Map<string, Readonly<{ bytes: number; sha256: string }>>();
|
||||
let declaredBytes = 0;
|
||||
for (const file of manifest.files) {
|
||||
assertSafeMemberPath(file.path);
|
||||
if (expectedFiles.has(file.path)) throw new Error(`candidate manifest duplicate file: ${file.path}`);
|
||||
declaredBytes += file.bytes;
|
||||
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError("candidate manifest declared bytes exceed the archive bound");
|
||||
}
|
||||
expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 });
|
||||
}
|
||||
const evidencePaths = [...expectedFiles.keys()]
|
||||
.filter((member) => !member.startsWith("dist/"))
|
||||
.sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(evidencePaths) !==
|
||||
JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("candidate manifest evidence member set drift");
|
||||
}
|
||||
const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/"));
|
||||
if (distFiles.length === 0) throw new Error("candidate manifest has no dist files");
|
||||
const lockfile = expectedFiles.get("pnpm-lock.yaml");
|
||||
if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) {
|
||||
throw new Error("candidate manifest lockfile digest summary mismatch");
|
||||
}
|
||||
if (
|
||||
distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !==
|
||||
manifest.distSha256
|
||||
) {
|
||||
throw new Error("candidate manifest dist digest summary mismatch");
|
||||
}
|
||||
if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) {
|
||||
throw new Error("candidate manifest bundle digest summary mismatch");
|
||||
}
|
||||
|
||||
const expectedFilePaths = new Set([
|
||||
...expectedFiles.keys(),
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
]);
|
||||
const expectedDirectories = new Set(directoryAncestors([...expectedFilePaths]));
|
||||
for (const entry of entries) {
|
||||
assertSafeMemberPath(entry.path);
|
||||
if (entry.type === "directory") {
|
||||
if (!expectedDirectories.has(entry.path)) {
|
||||
throw new Error(`candidate archive contains unexpected directory: ${entry.path}`);
|
||||
}
|
||||
} else if (!expectedFilePaths.has(entry.path)) {
|
||||
throw new Error(`candidate archive contains unexpected file: ${entry.path}`);
|
||||
}
|
||||
}
|
||||
const actualFiles = new Set(
|
||||
entries.filter(({ type }) => type === "file").map(({ path: member }) => member),
|
||||
);
|
||||
for (const expected of expectedFilePaths) {
|
||||
if (!actualFiles.has(expected)) throw new Error(`candidate archive is missing file: ${expected}`);
|
||||
}
|
||||
for (const [member, expected] of expectedFiles) {
|
||||
const bytes = await readFile(path.join(extractionRoot, member));
|
||||
if (bytes.byteLength !== expected.bytes) {
|
||||
throw new Error(`candidate archive member size mismatch: ${member}`);
|
||||
}
|
||||
if (createHash("sha256").update(bytes).digest("hex") !== expected.sha256) {
|
||||
throw new Error(`candidate archive member digest mismatch: ${member}`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ memberCount: entries.length, manifest });
|
||||
}
|
||||
|
||||
async function walkExtractedTree(
|
||||
root: string,
|
||||
relativeDirectory = "",
|
||||
): Promise<ReadonlyArray<Readonly<{ path: string; type: "file" | "directory" }>>> {
|
||||
const children = await readdir(path.join(root, relativeDirectory), {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const entries: Array<Readonly<{ path: string; type: "file" | "directory" }>> = [];
|
||||
for (const child of children.sort((left, right) => asciiCompare(left.name, right.name))) {
|
||||
const relative = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name;
|
||||
assertSafeMemberPath(relative);
|
||||
const metadata = await lstat(path.join(root, relative));
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new Error(`candidate archive contains non-regular member: ${relative}`);
|
||||
}
|
||||
if (metadata.isDirectory() && child.isDirectory()) {
|
||||
entries.push(Object.freeze({ path: relative, type: "directory" }));
|
||||
entries.push(...(await walkExtractedTree(root, relative)));
|
||||
} else if (metadata.isFile() && child.isFile()) {
|
||||
if (metadata.nlink !== 1) {
|
||||
throw new Error(`candidate archive contains hard-linked member: ${relative}`);
|
||||
}
|
||||
entries.push(Object.freeze({ path: relative, type: "file" }));
|
||||
} else {
|
||||
throw new Error(`candidate archive contains non-regular member: ${relative}`);
|
||||
}
|
||||
if (entries.length > MAX_ARCHIVE_MEMBERS) {
|
||||
throw new RangeError(`candidate archive exceeds ${MAX_ARCHIVE_MEMBERS} members`);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function assertSameIdentity(
|
||||
before: Awaited<ReturnType<typeof lstat>>,
|
||||
after: Awaited<ReturnType<typeof lstat>>,
|
||||
): void {
|
||||
if (
|
||||
!after.isFile() ||
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.size !== after.size
|
||||
) {
|
||||
throw new Error("candidate archive file identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
async function readCapturedArchive(
|
||||
handle: FileHandle,
|
||||
expectedSize: number,
|
||||
): Promise<Buffer> {
|
||||
const captured = Buffer.allocUnsafe(expectedSize + 1);
|
||||
let offset = 0;
|
||||
while (offset < captured.byteLength) {
|
||||
const { bytesRead } = await handle.read(
|
||||
captured,
|
||||
offset,
|
||||
captured.byteLength - offset,
|
||||
offset,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
}
|
||||
if (offset !== expectedSize) {
|
||||
throw new Error("candidate archive changed size during bounded capture");
|
||||
}
|
||||
return captured.subarray(0, offset);
|
||||
}
|
||||
|
||||
function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateManifest {
|
||||
const extracted = spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
[
|
||||
"--extract",
|
||||
"--gzip",
|
||||
"--to-stdout",
|
||||
"--file",
|
||||
"/proc/self/fd/3",
|
||||
"--",
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
],
|
||||
{
|
||||
maxBuffer: 8_388_609,
|
||||
timeout: 10_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
);
|
||||
if (extracted.status !== 0 || extracted.signal || extracted.error) {
|
||||
throw new Error(
|
||||
`candidate manifest preflight failed: ${String(extracted.stderr) || extracted.error?.message || extracted.signal}`,
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.from(extracted.stdout);
|
||||
if (bytes.byteLength === 0 || bytes.byteLength > 8_388_608) {
|
||||
throw new RangeError("candidate manifest preflight size is outside 1..8388608");
|
||||
}
|
||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
return releaseCandidateManifestSchema.parse(JSON.parse(source) as unknown);
|
||||
}
|
||||
|
||||
async function materializeCapturedArchive(
|
||||
archive: Buffer,
|
||||
): Promise<Readonly<{ root: string; handle: FileHandle }>> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-"));
|
||||
const file = path.join(root, "candidate.tar.gz");
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await open(
|
||||
file,
|
||||
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
await handle.writeFile(archive);
|
||||
await handle.sync();
|
||||
await unlink(file);
|
||||
return Object.freeze({ root, handle });
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeMemberPath(member: string): void {
|
||||
if (
|
||||
!member ||
|
||||
member.startsWith("-") ||
|
||||
Buffer.byteLength(member, "utf8") > MAX_MEMBER_PATH_BYTES ||
|
||||
member.includes("\\") ||
|
||||
[...member].some((character) => {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
}) ||
|
||||
path.posix.isAbsolute(member) ||
|
||||
path.posix.normalize(member) !== member ||
|
||||
member === ".." ||
|
||||
member.startsWith("../") ||
|
||||
member.includes("/../")
|
||||
) {
|
||||
throw new TypeError(`candidate archive contains unsafe member path: ${member}`);
|
||||
}
|
||||
}
|
||||
|
||||
function directoryAncestors(files: readonly string[]): string[] {
|
||||
const directories = new Set<string>();
|
||||
for (const file of files) {
|
||||
let directory = path.posix.dirname(file);
|
||||
while (directory !== ".") {
|
||||
directories.add(directory);
|
||||
directory = path.posix.dirname(directory);
|
||||
}
|
||||
}
|
||||
return [...directories];
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
|
||||
async function pathExists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ciContractReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u),
|
||||
gateCount: z.literal(26),
|
||||
commandDefinitionCount: z.number().int().positive(),
|
||||
commandReferenceCount: z.number().int().positive(),
|
||||
artifactCount: z.number().int().positive(),
|
||||
jobCount: z.literal(9),
|
||||
workflowSha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
durationStatus: z.string().min(1),
|
||||
negativeFixtures: z.array(
|
||||
z
|
||||
.object({
|
||||
readiness: z.enum([
|
||||
"MERGE_READY",
|
||||
"RELEASE_READY",
|
||||
"PROD_PROMOTION_READY",
|
||||
"FIELD_SLO_READY",
|
||||
"DOCUMENTATION_READY",
|
||||
]),
|
||||
failedGate: z.string().regex(/^FE-GATE-\d{3}$/u),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
failures: z.array(z.string()),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((report, context) => {
|
||||
const fail = (path: PropertyKey[], message: string) =>
|
||||
context.addIssue({ code: "custom", path, message });
|
||||
if ((report.passed === true) !== (report.failures.length === 0)) {
|
||||
fail(["passed"], "passed must agree with failures");
|
||||
}
|
||||
if (
|
||||
report.negativeFixtures.length !== 5 ||
|
||||
report.negativeFixtures.some((fixture) => !fixture.passed)
|
||||
) {
|
||||
fail(["negativeFixtures"], "every readiness negative fixture must pass");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
|
||||
|
||||
export async function writeCiGateLogAtomic(input: Readonly<{
|
||||
root: string;
|
||||
relativePath: string;
|
||||
content: string;
|
||||
maxBytes?: number;
|
||||
}>): Promise<void> {
|
||||
const root = path.resolve(input.root);
|
||||
const relative = normalizeRepositoryRelativePath(input.relativePath, "CI gate log path");
|
||||
const target = path.join(root, relative);
|
||||
const maxBytes = input.maxBytes ?? 67_108_864;
|
||||
const contentBytes = Buffer.byteLength(input.content, "utf8");
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || contentBytes < 1 || contentBytes > maxBytes) {
|
||||
throw new RangeError(`CI gate log size is outside 1..${maxBytes}: ${relative}`);
|
||||
}
|
||||
const parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target));
|
||||
await assertSafePublishLeaf(target, relative);
|
||||
const temporary = path.join(
|
||||
path.dirname(target),
|
||||
`.${path.basename(target)}.${randomUUID()}.tmp`,
|
||||
);
|
||||
let ownsTemporary = false;
|
||||
try {
|
||||
const handle = await open(
|
||||
temporary,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o644,
|
||||
);
|
||||
ownsTemporary = true;
|
||||
let failure: unknown;
|
||||
try {
|
||||
await handle.writeFile(input.content, "utf8");
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
failure ??= error;
|
||||
}
|
||||
if (failure) throw failure;
|
||||
await assertDirectoryIdentity(path.dirname(target), parentIdentity, relative);
|
||||
await assertSafePublishLeaf(target, relative);
|
||||
await rename(temporary, target);
|
||||
ownsTemporary = false;
|
||||
const directory = await open(path.dirname(target), constants.O_RDONLY);
|
||||
try {
|
||||
try {
|
||||
await directory.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (ownsTemporary) {
|
||||
try {
|
||||
await rm(temporary, { force: true });
|
||||
} catch {
|
||||
// Preserve the publication failure and clean only the owned sibling temp.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSafePublishDirectory(
|
||||
rootInput: string,
|
||||
directoryInput: string,
|
||||
): Promise<Stats> {
|
||||
const root = path.resolve(rootInput);
|
||||
const directory = path.resolve(directoryInput);
|
||||
const relativeDirectory = path.relative(root, directory);
|
||||
if (
|
||||
relativeDirectory === ".." ||
|
||||
relativeDirectory.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativeDirectory)
|
||||
) {
|
||||
throw new TypeError("CI publish directory escapes root");
|
||||
}
|
||||
const rootMetadata = await lstat(root);
|
||||
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
||||
throw new TypeError("CI gate log root is unsafe");
|
||||
}
|
||||
let ancestor = root;
|
||||
for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) {
|
||||
ancestor = path.join(ancestor, segment);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await lstat(ancestor);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
try {
|
||||
await mkdir(ancestor);
|
||||
} catch (mkdirError) {
|
||||
if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError;
|
||||
}
|
||||
metadata = await lstat(ancestor);
|
||||
}
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new TypeError(`CI publish ancestor is unsafe: ${relativeDirectory}`);
|
||||
}
|
||||
}
|
||||
return lstat(directory);
|
||||
}
|
||||
|
||||
export async function assertSafePublishLeaf(
|
||||
target: string,
|
||||
label = target,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(target);
|
||||
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
||||
throw new TypeError(`CI publish leaf is unsafe: ${label}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSafeExistingPublishPath(
|
||||
rootInput: string,
|
||||
targetInput: string,
|
||||
): Promise<boolean> {
|
||||
const root = path.resolve(rootInput);
|
||||
const target = path.resolve(targetInput);
|
||||
const relative = path.relative(root, target);
|
||||
if (
|
||||
relative === "" ||
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError("CI publish target escapes root");
|
||||
}
|
||||
const rootMetadata = await lstat(root);
|
||||
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
||||
throw new TypeError("CI publish root is unsafe");
|
||||
}
|
||||
const segments = relative.split(path.sep).filter(Boolean);
|
||||
let current = root;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
current = path.join(current, segment);
|
||||
let metadata: Stats;
|
||||
try {
|
||||
metadata = await lstat(current);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
}
|
||||
const leaf = index === segments.length - 1;
|
||||
if (metadata.isSymbolicLink() || (leaf ? !metadata.isFile() : !metadata.isDirectory())) {
|
||||
throw new TypeError(`CI publish path is unsafe: ${relative}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function assertDirectoryIdentity(
|
||||
directory: string,
|
||||
expected: Stats,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const actual = await lstat(directory);
|
||||
if (
|
||||
actual.isSymbolicLink() ||
|
||||
!actual.isDirectory() ||
|
||||
expected.dev <= 0 ||
|
||||
expected.ino <= 0 ||
|
||||
actual.dev !== expected.dev ||
|
||||
actual.ino !== expected.ino
|
||||
) {
|
||||
throw new TypeError(`CI publish directory identity changed: ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export type GateStepExpectation =
|
||||
| Readonly<{ kind: "pass" }>
|
||||
| Readonly<{
|
||||
kind: "fail";
|
||||
expectedExitCode: number;
|
||||
expectedDiagnosticId: string;
|
||||
}>;
|
||||
|
||||
export type GateProcessResult = Readonly<{
|
||||
status: number | null;
|
||||
signal: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: Readonly<{ code?: string }>;
|
||||
}>;
|
||||
|
||||
export type GateStepClassification =
|
||||
| Readonly<{
|
||||
kind: "EXPECTED_PASS" | "EXPECTED_FAILURE";
|
||||
expectationMet: true;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "UNEXPECTED_EXIT";
|
||||
expectationMet: false;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "UNEXPECTED_DIAGNOSTIC";
|
||||
expectationMet: false;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "INFRASTRUCTURE_FAILURE";
|
||||
expectationMet: false;
|
||||
detail: string;
|
||||
}>;
|
||||
|
||||
/** A negative fixture passes only with its registered exit and diagnostic. */
|
||||
export function classifyGateStepResult(
|
||||
expectation: GateStepExpectation,
|
||||
result: GateProcessResult,
|
||||
): GateStepClassification {
|
||||
const errorCode = result.error?.code;
|
||||
const spawnFailed = result.error !== undefined;
|
||||
if (spawnFailed || result.signal || result.status === null) {
|
||||
return Object.freeze({
|
||||
kind: "INFRASTRUCTURE_FAILURE" as const,
|
||||
expectationMet: false as const,
|
||||
detail:
|
||||
errorCode ??
|
||||
result.signal ??
|
||||
(spawnFailed ? "SPAWN_ERROR" : "NO_EXIT_STATUS"),
|
||||
});
|
||||
}
|
||||
if (expectation.kind === "pass" && result.status === 0) {
|
||||
return Object.freeze({
|
||||
kind: "EXPECTED_PASS" as const,
|
||||
expectationMet: true as const,
|
||||
});
|
||||
}
|
||||
if (expectation.kind === "fail") {
|
||||
if (result.status !== expectation.expectedExitCode) {
|
||||
return Object.freeze({
|
||||
kind: "UNEXPECTED_EXIT" as const,
|
||||
expectationMet: false as const,
|
||||
});
|
||||
}
|
||||
const diagnosticOutput = `${result.stdout}\n${result.stderr}`;
|
||||
if (!diagnosticOutput.includes(expectation.expectedDiagnosticId)) {
|
||||
return Object.freeze({
|
||||
kind: "UNEXPECTED_DIAGNOSTIC" as const,
|
||||
expectationMet: false as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "EXPECTED_FAILURE" as const,
|
||||
expectationMet: true as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "UNEXPECTED_EXIT" as const,
|
||||
expectationMet: false as const,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
type ViteManifestEntry = Readonly<{
|
||||
file: string;
|
||||
isEntry?: boolean;
|
||||
imports?: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Static imports of an entry are part of initial JavaScript. Every remaining
|
||||
* JavaScript output is governed by the lazy-chunk budget.
|
||||
*
|
||||
*/
|
||||
export function classifyViteJavascript(
|
||||
manifest: Readonly<Record<string, ViteManifestEntry>>,
|
||||
) {
|
||||
const initialFiles = new Set<string>();
|
||||
const visitedKeys = new Set<string>();
|
||||
const pendingKeys = Object.entries(manifest)
|
||||
.filter(([, entry]) => entry.isEntry)
|
||||
.map(([key]) => key);
|
||||
const missingImports: string[] = [];
|
||||
|
||||
while (pendingKeys.length > 0) {
|
||||
const key = pendingKeys.pop();
|
||||
if (key === undefined) break;
|
||||
if (visitedKeys.has(key)) continue;
|
||||
visitedKeys.add(key);
|
||||
const entry = manifest[key];
|
||||
if (!entry) {
|
||||
missingImports.push(key);
|
||||
continue;
|
||||
}
|
||||
if (entry.file.endsWith(".js")) initialFiles.add(entry.file);
|
||||
pendingKeys.push(...(entry.imports ?? []));
|
||||
}
|
||||
|
||||
const allJavaScript = new Set(
|
||||
Object.values(manifest)
|
||||
.map((entry) => entry.file)
|
||||
.filter((file) => file.endsWith(".js")),
|
||||
);
|
||||
const lazyFiles = [...allJavaScript].filter(
|
||||
(file) => !initialFiles.has(file),
|
||||
);
|
||||
return Object.freeze({
|
||||
initialFiles: Object.freeze([...initialFiles].sort()),
|
||||
lazyFiles: Object.freeze(lazyFiles.sort()),
|
||||
missingImports: Object.freeze(missingImports.sort()),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
PROMOTED_FILE_NAMES,
|
||||
type PromotedFileName,
|
||||
} from "../contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
PROMOTION_VERIFIER_ID,
|
||||
PROMOTION_VERIFIER_VERSION,
|
||||
assertDistinctProviderTrust,
|
||||
evaluatePromotionEvidence,
|
||||
providerVerificationArtifactSchema,
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
trustPolicySha256,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
|
||||
import { LOCAL_EVIDENCE_ASSESSMENT_PATH } from "./release-candidate.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
|
||||
export type ExactPromotionBundle = Readonly<
|
||||
Partial<Record<PromotedFileName, Buffer>>
|
||||
>;
|
||||
|
||||
export type ExactPromotionExpectedContext = Readonly<{
|
||||
run: Readonly<{ id: string; attempt: number }>;
|
||||
sourceRevision: string;
|
||||
archiveSha256: string;
|
||||
sourceSetSha256?: string;
|
||||
bundleSha256?: string;
|
||||
distSha256?: string;
|
||||
lockfileSha256?: string;
|
||||
}>;
|
||||
|
||||
export async function verifyExactPromotionBundle(
|
||||
files: ExactPromotionBundle,
|
||||
options: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
expected: ExactPromotionExpectedContext;
|
||||
nowEpochMs?: () => number;
|
||||
}>,
|
||||
): Promise<Readonly<{ status: "PASS" }>> {
|
||||
assertDistinctProviderTrust(options);
|
||||
assertExternalExpectedContext(options.expected);
|
||||
const names = Object.keys(files).sort(asciiCompare);
|
||||
const expectedNames = [...PROMOTED_FILE_NAMES].sort(asciiCompare);
|
||||
if (JSON.stringify(names) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error("promotion bundle must contain the exact five canonical files");
|
||||
}
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
if (!Buffer.isBuffer(files[name])) {
|
||||
throw new TypeError(`promotion bundle file is missing or not captured bytes: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const archiveBytes = files["release-candidate.tar.gz"]!;
|
||||
const vulnerabilityBytes = files["vulnerability-report.json"]!;
|
||||
const provenanceBytes = files["provenance-attestation.json"]!;
|
||||
const providerBytes = files["provider-verification.json"]!;
|
||||
const promotionBytes = files["promotion-verification.json"]!;
|
||||
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(
|
||||
vulnerabilityBytes,
|
||||
"vulnerability report",
|
||||
));
|
||||
const provenance = provenanceProviderAttestationSchema.parse(parseJson(
|
||||
provenanceBytes,
|
||||
"provenance attestation",
|
||||
));
|
||||
const provider = providerVerificationArtifactSchema.parse(parseJson(
|
||||
providerBytes,
|
||||
"provider verification",
|
||||
));
|
||||
const promotion = providerVerificationArtifactSchema.parse(parseJson(
|
||||
promotionBytes,
|
||||
"promotion verification",
|
||||
));
|
||||
|
||||
if (
|
||||
provider.artifactType !== "provider-verification" ||
|
||||
promotion.artifactType !== "promotion-verification"
|
||||
) {
|
||||
throw new Error("promotion verification artifact role mismatch");
|
||||
}
|
||||
for (const [label, record] of [
|
||||
["provider", provider],
|
||||
["promotion", promotion],
|
||||
] as const) {
|
||||
if (
|
||||
record.verifier.id !== PROMOTION_VERIFIER_ID ||
|
||||
record.verifier.version !== PROMOTION_VERIFIER_VERSION
|
||||
) {
|
||||
throw new Error(`${label} verification literal verifier identity mismatch`);
|
||||
}
|
||||
if (record.status !== "PASS" || record.failures.length !== 0) {
|
||||
throw new Error(`${label} verification must be PASS without failures`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider.vulnerabilityStatus !== "PASS" ||
|
||||
provider.provenanceAttestationStatus !== "PASS"
|
||||
) {
|
||||
throw new Error("provider verification subordinate statuses must both be PASS");
|
||||
}
|
||||
if (promotion.localEvidenceStatus !== "PASS") {
|
||||
throw new Error("promotion local evidence subordinate status must be PASS");
|
||||
}
|
||||
|
||||
assertEqual("shared verifiedAt", provider.verifiedAt, promotion.verifiedAt);
|
||||
assertEqual("shared run", provider.run, promotion.run);
|
||||
assertEqual("shared source", provider.source, promotion.source);
|
||||
assertEqual("shared candidate", provider.candidate, promotion.candidate);
|
||||
assertEqual(
|
||||
"shared provider evidence",
|
||||
provider.providerEvidence,
|
||||
promotion.providerEvidence,
|
||||
);
|
||||
assertEqual(
|
||||
"shared trust policy",
|
||||
provider.trustPolicySha256,
|
||||
promotion.trustPolicySha256,
|
||||
);
|
||||
assertEqual("external expected run", provider.run, options.expected.run);
|
||||
assertEqual(
|
||||
"external expected source revision",
|
||||
provider.source.revision,
|
||||
options.expected.sourceRevision,
|
||||
);
|
||||
assertEqual(
|
||||
"external expected archive digest",
|
||||
provider.candidate.archiveSha256,
|
||||
options.expected.archiveSha256,
|
||||
);
|
||||
for (const [label, actual, expected] of [
|
||||
["source set", provider.source.sourceSetSha256, options.expected.sourceSetSha256],
|
||||
["bundle", provider.candidate.bundleSha256, options.expected.bundleSha256],
|
||||
["dist", provider.candidate.distSha256, options.expected.distSha256],
|
||||
["lockfile", provider.candidate.lockfileSha256, options.expected.lockfileSha256],
|
||||
] as const) {
|
||||
if (expected !== undefined) {
|
||||
assertEqual(`external expected ${label} digest`, actual, expected);
|
||||
}
|
||||
}
|
||||
const anchoredTrustPolicySha256 = trustPolicySha256(options);
|
||||
if (provider.trustPolicySha256 !== anchoredTrustPolicySha256) {
|
||||
throw new Error("verification trust policy does not match anchored provider keys");
|
||||
}
|
||||
|
||||
if (promotion.providerVerificationSha256 !== sha256(providerBytes)) {
|
||||
throw new Error("promotion provider verification byte hash mismatch");
|
||||
}
|
||||
if (
|
||||
provider.candidate.archiveSha256 !== sha256(archiveBytes) ||
|
||||
provider.providerEvidence.vulnerabilityReportSha256 !== sha256(vulnerabilityBytes) ||
|
||||
provider.providerEvidence.provenanceAttestationSha256 !== sha256(provenanceBytes)
|
||||
) {
|
||||
if (provider.candidate.archiveSha256 !== sha256(archiveBytes)) {
|
||||
throw new Error("candidate archive actual digest mismatch");
|
||||
}
|
||||
if (
|
||||
provider.providerEvidence.vulnerabilityReportSha256 !==
|
||||
sha256(vulnerabilityBytes)
|
||||
) {
|
||||
throw new Error("vulnerability report actual digest mismatch");
|
||||
}
|
||||
throw new Error("provenance attestation actual digest mismatch");
|
||||
}
|
||||
|
||||
for (const [label, evidence, nonce, keyId, fingerprint] of [
|
||||
[
|
||||
"vulnerability",
|
||||
vulnerability,
|
||||
provider.providerEvidence.vulnerabilityInvocationNonce,
|
||||
provider.providerEvidence.vulnerabilityKeyId,
|
||||
provider.providerEvidence.vulnerabilityKeyFingerprint,
|
||||
],
|
||||
[
|
||||
"provenance",
|
||||
provenance,
|
||||
provider.providerEvidence.provenanceInvocationNonce,
|
||||
provider.providerEvidence.provenanceKeyId,
|
||||
provider.providerEvidence.provenanceKeyFingerprint,
|
||||
],
|
||||
] as const) {
|
||||
assertEqual(`${label} run`, { id: evidence.run.id, attempt: evidence.run.attempt }, provider.run);
|
||||
assertEqual(`${label} source`, evidence.source, provider.source);
|
||||
assertEqual(`${label} candidate`, evidence.candidate, provider.candidate);
|
||||
if (
|
||||
evidence.run.invocationNonce !== nonce ||
|
||||
evidence.signature.keyId !== keyId ||
|
||||
evidence.signature.publicKeyFingerprint !== fingerprint
|
||||
) {
|
||||
throw new Error(`${label} provider evidence nonce or trust role mismatch`);
|
||||
}
|
||||
}
|
||||
if (provenance.subject.digest.sha256 !== provider.candidate.distSha256) {
|
||||
throw new Error("provenance subject dist digest mismatch");
|
||||
}
|
||||
if (vulnerability.findings.length !== 0) {
|
||||
throw new Error("vulnerability report is not PASS");
|
||||
}
|
||||
assertEqual(
|
||||
"signed secret scan attestation",
|
||||
vulnerability.secretScanAttestation,
|
||||
provider.providerEvidence.secretScanAttestation,
|
||||
);
|
||||
|
||||
let assessmentSha256: string | null = null;
|
||||
const localIdentityHolder: {
|
||||
current: null | Readonly<{
|
||||
sourceRevision: string;
|
||||
sourceSetSha256: string;
|
||||
assessmentSha256: string;
|
||||
secretScan: Readonly<{
|
||||
policySha256: string;
|
||||
sarifSha256: string;
|
||||
scanInputSha256: string;
|
||||
}>;
|
||||
}>;
|
||||
} = { current: null };
|
||||
await verifyCapturedCiCandidateArchive(
|
||||
archiveBytes,
|
||||
provider.candidate.archiveSha256,
|
||||
{
|
||||
verifyExtracted: async (extractionRoot, manifest) => {
|
||||
assertEqual("archive candidate", {
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
}, {
|
||||
bundleSha256: provider.candidate.bundleSha256,
|
||||
distSha256: provider.candidate.distSha256,
|
||||
lockfileSha256: provider.candidate.lockfileSha256,
|
||||
});
|
||||
assessmentSha256 = sha256(
|
||||
await readFile(path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
|
||||
);
|
||||
const local = await verifyArchivedLocalEvidence({
|
||||
extractionRoot,
|
||||
expectedManifest: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || !local.identity) {
|
||||
throw new Error(
|
||||
`exact-five archived local verification is not PASS: ${local.failures.join(", ")}`,
|
||||
);
|
||||
}
|
||||
localIdentityHolder.current = local.identity;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (assessmentSha256 !== promotion.localEvidenceAssessmentSha256) {
|
||||
throw new Error("promotion local evidence assessment actual digest mismatch");
|
||||
}
|
||||
if (
|
||||
!localIdentityHolder.current ||
|
||||
localIdentityHolder.current.sourceRevision !== provider.source.revision ||
|
||||
localIdentityHolder.current.sourceSetSha256 !== provider.source.sourceSetSha256 ||
|
||||
localIdentityHolder.current.assessmentSha256 !== promotion.localEvidenceAssessmentSha256
|
||||
) {
|
||||
throw new Error("exact-five archived local identity mismatch");
|
||||
}
|
||||
assertEqual("archived secret scan attestation", {
|
||||
status: "PASS",
|
||||
localEvidenceAssessmentSha256: localIdentityHolder.current.assessmentSha256,
|
||||
sourceSetSha256: localIdentityHolder.current.sourceSetSha256,
|
||||
policySha256: localIdentityHolder.current.secretScan.policySha256,
|
||||
sarifSha256: localIdentityHolder.current.secretScan.sarifSha256,
|
||||
scanInputSha256: localIdentityHolder.current.secretScan.scanInputSha256,
|
||||
}, vulnerability.secretScanAttestation);
|
||||
const reevaluated = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
run: provider.run,
|
||||
source: provider.source,
|
||||
candidate: provider.candidate,
|
||||
vulnerabilityInvocationNonce:
|
||||
provider.providerEvidence.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce:
|
||||
provider.providerEvidence.provenanceInvocationNonce,
|
||||
secretScanAttestation: provider.providerEvidence.secretScanAttestation,
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: vulnerability,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: options.vulnerabilityTrust,
|
||||
provenanceTrust: options.provenanceTrust,
|
||||
nowEpochMs: options.nowEpochMs,
|
||||
});
|
||||
if (
|
||||
reevaluated.status !== "PASS" ||
|
||||
reevaluated.vulnerabilityStatus !== "PASS" ||
|
||||
reevaluated.provenanceAttestationStatus !== "PASS"
|
||||
) {
|
||||
throw new Error(
|
||||
`exact-five provider signature/freshness revalidation is not PASS: ${reevaluated.failures.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({ status: "PASS" as const });
|
||||
}
|
||||
|
||||
function assertExternalExpectedContext(
|
||||
expected: ExactPromotionExpectedContext,
|
||||
): void {
|
||||
if (
|
||||
!expected ||
|
||||
typeof expected.run?.id !== "string" ||
|
||||
expected.run.id.length === 0 ||
|
||||
!Number.isSafeInteger(expected.run.attempt) ||
|
||||
expected.run.attempt < 1 ||
|
||||
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(expected.sourceRevision) ||
|
||||
!isSha256(expected.archiveSha256)
|
||||
) {
|
||||
throw new TypeError("external expected promotion context is invalid or incomplete");
|
||||
}
|
||||
for (const digest of [
|
||||
expected.sourceSetSha256,
|
||||
expected.bundleSha256,
|
||||
expected.distSha256,
|
||||
expected.lockfileSha256,
|
||||
]) {
|
||||
if (digest !== undefined && !isSha256(digest)) {
|
||||
throw new TypeError("external optional expected promotion digest is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSha256(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
} catch {
|
||||
throw new TypeError(`${label} is not strict UTF-8 JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual(label: string, left: unknown, right: unknown): void {
|
||||
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
||||
throw new Error(`${label} mismatch`);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const WINDOW_MILLISECONDS = 28 * 24 * 60 * 60 * 1000;
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
const timestamp = nonEmptyString.refine(
|
||||
(value) => Number.isFinite(Date.parse(value)),
|
||||
"must be an RFC 3339 timestamp",
|
||||
);
|
||||
const sampleSchema = z
|
||||
.object({
|
||||
timestamp,
|
||||
consent: z.boolean(),
|
||||
releaseId: nonEmptyString,
|
||||
routeId: nonEmptyString.regex(/^[A-Z][A-Z0-9_]*$/),
|
||||
lcpMs: z.number().finite().nonnegative(),
|
||||
cls: z.number().finite().nonnegative(),
|
||||
inpMs: z.number().finite().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const fieldEvidenceInputSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
environment: z.literal("production"),
|
||||
releaseId: nonEmptyString.refine(
|
||||
(value) => value !== "local-release",
|
||||
"must identify an immutable production release",
|
||||
),
|
||||
source: z
|
||||
.object({
|
||||
system: nonEmptyString,
|
||||
exportId: nonEmptyString,
|
||||
})
|
||||
.strict(),
|
||||
privacy: z
|
||||
.object({
|
||||
approved: z.literal(true),
|
||||
approvalRef: nonEmptyString,
|
||||
})
|
||||
.strict(),
|
||||
window: z
|
||||
.object({
|
||||
start: timestamp,
|
||||
end: timestamp,
|
||||
})
|
||||
.strict(),
|
||||
thresholdDecision: z
|
||||
.object({
|
||||
status: z.literal("approved"),
|
||||
minimumEligibleSamples: z.number().int().positive(),
|
||||
owner: nonEmptyString,
|
||||
reviewedAt: timestamp,
|
||||
evidenceRef: nonEmptyString,
|
||||
})
|
||||
.strict(),
|
||||
samples: z.array(sampleSchema),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((input, context) => {
|
||||
const start = Date.parse(input.window.start);
|
||||
const end = Date.parse(input.window.end);
|
||||
if (end - start !== WINDOW_MILLISECONDS) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["window"],
|
||||
message: "must cover exactly 28 days",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function validateFieldEvidenceInput(
|
||||
input: unknown,
|
||||
configuredMinimum: string | undefined,
|
||||
now: Date = new Date(),
|
||||
) {
|
||||
const parsed = fieldEvidenceInputSchema.safeParse(input);
|
||||
const failures = parsed.success
|
||||
? []
|
||||
: parsed.error.issues.map(
|
||||
(issue) => `${issue.path.join(".") || "input"}: ${issue.message}`,
|
||||
);
|
||||
const minimumEligibleSamples = Number(configuredMinimum);
|
||||
if (
|
||||
configuredMinimum === undefined ||
|
||||
!Number.isInteger(minimumEligibleSamples) ||
|
||||
minimumEligibleSamples <= 0
|
||||
) {
|
||||
failures.push("MIN_ELIGIBLE_SAMPLES: must be a positive integer");
|
||||
}
|
||||
|
||||
if (parsed.success) {
|
||||
if (
|
||||
parsed.data.thresholdDecision.minimumEligibleSamples !==
|
||||
minimumEligibleSamples
|
||||
) {
|
||||
failures.push(
|
||||
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
|
||||
);
|
||||
}
|
||||
if (Date.parse(parsed.data.window.end) > now.getTime()) {
|
||||
failures.push("window.end: must not be in the future");
|
||||
}
|
||||
if (Date.parse(parsed.data.thresholdDecision.reviewedAt) > now.getTime()) {
|
||||
failures.push("thresholdDecision.reviewedAt: must not be in the future");
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
data: parsed.success ? parsed.data : null,
|
||||
failures: Object.freeze(failures),
|
||||
minimumEligibleSamples:
|
||||
Number.isInteger(minimumEligibleSamples) && minimumEligibleSamples > 0
|
||||
? minimumEligibleSamples
|
||||
: null,
|
||||
passed: parsed.success && failures.length === 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/;
|
||||
|
||||
/**
|
||||
* A release gate must not promote a local preview server as live hosting
|
||||
* evidence.
|
||||
*
|
||||
*/
|
||||
export function classifyLiveHostingBaseUrl(value: string):
|
||||
| {
|
||||
passed: true;
|
||||
reason: null;
|
||||
url: URL;
|
||||
observedOrigin: string;
|
||||
}
|
||||
| {
|
||||
passed: false;
|
||||
reason: string;
|
||||
url: URL | null;
|
||||
observedOrigin: string | null;
|
||||
} {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return {
|
||||
passed: false,
|
||||
reason: "HOSTING_BASE_URL must be an absolute URL",
|
||||
url: null,
|
||||
observedOrigin: null,
|
||||
};
|
||||
}
|
||||
|
||||
const observedOrigin = url.origin;
|
||||
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (url.protocol !== "https:") {
|
||||
return {
|
||||
passed: false,
|
||||
reason: "live hosting evidence requires HTTPS",
|
||||
url,
|
||||
observedOrigin,
|
||||
};
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: "HOSTING_BASE_URL must not contain credentials",
|
||||
url,
|
||||
observedOrigin,
|
||||
};
|
||||
}
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname === "::1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
LOOPBACK_IPV4.test(hostname)
|
||||
) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: "local or loopback hosts are not live deployment evidence",
|
||||
url,
|
||||
observedOrigin,
|
||||
};
|
||||
}
|
||||
if (url.pathname !== "/" || url.search || url.hash) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: "HOSTING_BASE_URL must be the canonical root URL",
|
||||
url,
|
||||
observedOrigin,
|
||||
};
|
||||
}
|
||||
return { passed: true, reason: null, url, observedOrigin };
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export type AttemptStatus = number | "NETWORK_REJECTION" | "PENDING_ABORT";
|
||||
export type RetryReason = "NETWORK_FAILURE" | "HTTP_429" | "HTTP_503";
|
||||
export type BodyDisposition =
|
||||
| "FULLY_READ_WITHIN_BOUND"
|
||||
| "CANCELLED_WITHOUT_READ"
|
||||
| "REJECTED_LIMIT"
|
||||
| "NO_RESPONSE";
|
||||
|
||||
const attemptStatusSchema = z.union([
|
||||
z.number().int().min(100).max(599),
|
||||
z.literal("NETWORK_REJECTION"),
|
||||
z.literal("PENDING_ABORT"),
|
||||
]);
|
||||
|
||||
export const httpScenarioAssertionGroupsSchema = z
|
||||
.object({
|
||||
status: z
|
||||
.object({
|
||||
attempts: z.array(attemptStatusSchema).min(1),
|
||||
final: attemptStatusSchema,
|
||||
})
|
||||
.strict(),
|
||||
outcome: z.object({ kind: z.string().min(1), detail: z.string().nullable() }).strict(),
|
||||
effect: z.object({ outcome: z.string().min(1), observer: z.string().min(1) }).strict(),
|
||||
retry: z
|
||||
.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
reasons: z.array(z.enum(["NETWORK_FAILURE", "HTTP_429", "HTTP_503"])),
|
||||
})
|
||||
.strict(),
|
||||
fetch: z
|
||||
.object({
|
||||
count: z.number().int().positive(),
|
||||
observerAttempts: z.number().int().positive(),
|
||||
agrees: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
media: z.object({ attempts: z.array(z.string().nullable()).min(1), final: z.string().nullable() }).strict(),
|
||||
body: z
|
||||
.object({
|
||||
attempts: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
disposition: z.enum([
|
||||
"FULLY_READ_WITHIN_BOUND",
|
||||
"CANCELLED_WITHOUT_READ",
|
||||
"REJECTED_LIMIT",
|
||||
"NO_RESPONSE",
|
||||
]),
|
||||
pulledBytes: z.number().int().nonnegative(),
|
||||
ceiling: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
.strict(),
|
||||
scope: z
|
||||
.object({
|
||||
start: z.literal("CURRENT"),
|
||||
end: z.enum(["CURRENT", "STALE"]),
|
||||
signal: z.enum(["ACTIVE", "ABORTED"]),
|
||||
cancellationOwner: z.enum(["NONE", "CALLER", "SCOPE_FENCE", "DEADLINE"]),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((groups, context) => {
|
||||
const fail = (path: (string | number)[], message: string) => {
|
||||
context.addIssue({ code: "custom", path, message });
|
||||
};
|
||||
if (groups.status.final !== groups.status.attempts.at(-1)) {
|
||||
fail(["status", "final"], "must equal the final attempt status");
|
||||
}
|
||||
if (groups.retry.count !== groups.retry.reasons.length) {
|
||||
fail(["retry", "count"], "must equal retry reasons length");
|
||||
}
|
||||
if (groups.retry.count !== groups.fetch.count - 1) {
|
||||
fail(["retry", "count"], "must equal the non-final fetch attempt count");
|
||||
}
|
||||
for (const [index, reason] of groups.retry.reasons.entries()) {
|
||||
const status = groups.status.attempts[index];
|
||||
const expectedReason =
|
||||
status === "NETWORK_REJECTION"
|
||||
? "NETWORK_FAILURE"
|
||||
: status === 429
|
||||
? "HTTP_429"
|
||||
: status === 503
|
||||
? "HTTP_503"
|
||||
: null;
|
||||
if (reason !== expectedReason) {
|
||||
fail(
|
||||
["retry", "reasons", index],
|
||||
"must match the corresponding non-final attempt status",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (groups.fetch.count !== groups.status.attempts.length) {
|
||||
fail(["fetch", "count"], "must equal status attempts length");
|
||||
}
|
||||
if (groups.fetch.count !== groups.media.attempts.length) {
|
||||
fail(["media", "attempts"], "must equal fetch count");
|
||||
}
|
||||
if (groups.fetch.count !== groups.body.attempts.length) {
|
||||
fail(["body", "attempts"], "must equal fetch count");
|
||||
}
|
||||
if (groups.fetch.observerAttempts !== groups.fetch.count) {
|
||||
fail(["fetch", "observerAttempts"], "must equal physical fetch count");
|
||||
}
|
||||
if (!groups.fetch.agrees) {
|
||||
fail(["fetch", "agrees"], "must prove physical/observer agreement");
|
||||
}
|
||||
if (groups.media.final !== groups.media.attempts.at(-1)) {
|
||||
fail(["media", "final"], "must equal the final attempt media essence");
|
||||
}
|
||||
});
|
||||
|
||||
export type HttpScenarioAssertionGroups = Readonly<{
|
||||
status: Readonly<{ attempts: readonly AttemptStatus[]; final: AttemptStatus }>;
|
||||
outcome: Readonly<{ kind: string; detail: string | null }>;
|
||||
effect: Readonly<{ outcome: string; observer: string }>;
|
||||
retry: Readonly<{ count: number; reasons: readonly RetryReason[] }>;
|
||||
fetch: Readonly<{
|
||||
count: number;
|
||||
observerAttempts: number;
|
||||
agrees: boolean;
|
||||
}>;
|
||||
media: Readonly<{
|
||||
attempts: readonly (string | null)[];
|
||||
final: string | null;
|
||||
}>;
|
||||
body: Readonly<{
|
||||
attempts: readonly Readonly<{
|
||||
disposition: BodyDisposition;
|
||||
pulledBytes: number;
|
||||
ceiling: number;
|
||||
}>[];
|
||||
}>;
|
||||
scope: Readonly<{
|
||||
start: "CURRENT";
|
||||
end: "CURRENT" | "STALE";
|
||||
signal: "ACTIVE" | "ABORTED";
|
||||
cancellationOwner: "NONE" | "CALLER" | "SCOPE_FENCE" | "DEADLINE";
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export const httpScenarioExpectationSchema = z
|
||||
.object({
|
||||
executionId: z.string().min(1),
|
||||
operationId: z.string().min(1),
|
||||
scenarioId: z.string().min(1),
|
||||
expected: httpScenarioAssertionGroupsSchema,
|
||||
testDeadlineOverrideMs: z.number().int().positive().nullable(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((entry, context) => {
|
||||
if (entry.executionId !== `${entry.operationId}::${entry.scenarioId}`) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["executionId"],
|
||||
message: "must equal operationId::scenarioId",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type HttpScenarioExpectation = Readonly<{
|
||||
executionId: string;
|
||||
operationId: string;
|
||||
scenarioId: string;
|
||||
expected: HttpScenarioAssertionGroups;
|
||||
testDeadlineOverrideMs: number | null;
|
||||
}>;
|
||||
|
||||
export const httpScenarioReceiptRowSchema = z
|
||||
.object({
|
||||
executionId: z.string().min(1),
|
||||
expected: httpScenarioAssertionGroupsSchema,
|
||||
observed: httpScenarioAssertionGroupsSchema,
|
||||
testDeadlineOverrideMs: z.number().int().positive().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const httpScenarioReceiptSchema = z
|
||||
.object({
|
||||
schemaVersion: z.number().int().positive(),
|
||||
catalogDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
||||
catalogTotal: z.number().int().nonnegative(),
|
||||
executedIds: z.array(z.string().min(1)),
|
||||
rows: z.array(httpScenarioReceiptRowSchema),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((receipt, context) => {
|
||||
const rowIds = receipt.rows.map((row) => row.executionId);
|
||||
const sortedIds = [...receipt.executedIds].sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
const sortedRowIds = [...rowIds].sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
if (!sameScenarioJson(receipt.executedIds, sortedIds)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["executedIds"],
|
||||
message: "must be sorted by execution ID",
|
||||
});
|
||||
}
|
||||
if (!sameScenarioJson(rowIds, sortedRowIds)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["rows"],
|
||||
message: "must be sorted by execution ID",
|
||||
});
|
||||
}
|
||||
if (!sameScenarioJson(receipt.executedIds, rowIds)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["rows"],
|
||||
message: "row IDs must exactly equal executed IDs",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type HttpScenarioReceipt = Readonly<{
|
||||
schemaVersion: number;
|
||||
catalogDigest: string;
|
||||
catalogTotal: number;
|
||||
executedIds: readonly string[];
|
||||
rows: readonly Readonly<{
|
||||
executionId: string;
|
||||
expected: HttpScenarioAssertionGroups;
|
||||
observed: HttpScenarioAssertionGroups;
|
||||
testDeadlineOverrideMs: number | null;
|
||||
}>[];
|
||||
}>;
|
||||
|
||||
export function stableScenarioJson(value: unknown): string {
|
||||
const normalize = (candidate: unknown): unknown => {
|
||||
if (Array.isArray(candidate)) return candidate.map(normalize);
|
||||
if (candidate && typeof candidate === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(candidate as Readonly<Record<string, unknown>>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, nested]) => [key, normalize(nested)]),
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
return JSON.stringify(normalize(value));
|
||||
}
|
||||
|
||||
export function sameScenarioJson(left: unknown, right: unknown): boolean {
|
||||
return stableScenarioJson(left) === stableScenarioJson(right);
|
||||
}
|
||||
|
||||
export function computeHttpScenarioCatalogDigest(
|
||||
schemaVersion: number,
|
||||
expectations: readonly HttpScenarioExpectation[],
|
||||
): `sha256:${string}` {
|
||||
const tuples = [...expectations]
|
||||
.sort((left, right) => left.executionId.localeCompare(right.executionId))
|
||||
.map((entry) => [
|
||||
entry.executionId,
|
||||
entry.expected.status,
|
||||
entry.expected.outcome,
|
||||
entry.expected.effect,
|
||||
entry.expected.retry,
|
||||
entry.expected.fetch,
|
||||
entry.expected.media,
|
||||
entry.expected.body,
|
||||
entry.expected.scope,
|
||||
entry.testDeadlineOverrideMs,
|
||||
]);
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(stableScenarioJson([schemaVersion, ...tuples]))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Compile a checked-in JSON Schema and assert a concrete artifact against it.
|
||||
* Conversion failures are fatal too: an unsupported or malformed schema must
|
||||
* not silently turn a release schema into documentation-only metadata.
|
||||
*/
|
||||
export function assertMatchesJsonSchema(
|
||||
schemaDocument: unknown,
|
||||
value: unknown,
|
||||
label: string,
|
||||
): void {
|
||||
try {
|
||||
const schema = z.fromJSONSchema(schemaDocument as never);
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new TypeError(z.prettifyError(result.error));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TypeError(`${label} does not satisfy its checked-in JSON Schema.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
licenseReportArtifactSchema,
|
||||
supplyChainVerificationArtifactSchema,
|
||||
vulnerabilityReportArtifactSchema,
|
||||
} from "../contracts/release-artifacts.ts";
|
||||
import type { DistOutput } from "./release-candidate.ts";
|
||||
import {
|
||||
diffDependencyInventories,
|
||||
supplyChainDigest,
|
||||
validateDependencyReview,
|
||||
validateLicensePolicy,
|
||||
} from "./supply-chain.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
export const LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS = Object.freeze({
|
||||
promotionStatus: "FAIL_UNVERIFIED" as const,
|
||||
vulnerabilityStatus: "FAIL_UNVERIFIED" as const,
|
||||
provenanceAttestationStatus: "FAIL_UNVERIFIED" as const,
|
||||
});
|
||||
|
||||
export function verifyLocalSupplyChainDefaults(stored: unknown): string[] {
|
||||
const parsed = supplyChainVerificationArtifactSchema.safeParse(stored);
|
||||
if (
|
||||
!parsed.success ||
|
||||
parsed.data.promotionStatus !==
|
||||
LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.promotionStatus ||
|
||||
parsed.data.vulnerabilityStatus !==
|
||||
LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.vulnerabilityStatus ||
|
||||
parsed.data.provenanceAttestationStatus !==
|
||||
LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.provenanceAttestationStatus
|
||||
) {
|
||||
return [
|
||||
"supply-chain verification provider defaults are not local FAIL_UNVERIFIED",
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function createLocalVulnerabilityReport(lockfileSha256: string) {
|
||||
return vulnerabilityReportArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
provider: "UNCONFIGURED",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
status: "FAIL_UNVERIFIED",
|
||||
findings: [],
|
||||
exceptionsApplied: [],
|
||||
failures: ["external vulnerability provider report is missing"],
|
||||
blocking: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function compareStoredLocalVulnerabilityReport(
|
||||
lockfileSha256: string,
|
||||
stored: unknown,
|
||||
): string[] {
|
||||
const parsed = vulnerabilityReportArtifactSchema.safeParse(stored);
|
||||
if (
|
||||
!parsed.success ||
|
||||
supplyChainDigest(parsed.data) !==
|
||||
supplyChainDigest(createLocalVulnerabilityReport(lockfileSha256))
|
||||
) {
|
||||
return [
|
||||
"local vulnerability report does not match exact unconfigured defaults",
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function recomputeDependencyEvidence(input: Readonly<{
|
||||
inventory: unknown;
|
||||
baseline: unknown;
|
||||
baselineApproval: unknown;
|
||||
dependencyChangeEvidence: unknown;
|
||||
skipBaseline?: boolean;
|
||||
}>) {
|
||||
const inventory = dependencyInventoryArtifactSchema.parse(input.inventory);
|
||||
const baseline = asDocument(input.baseline);
|
||||
const approval = asDocument(input.baselineApproval);
|
||||
const dependencyChangeEvidence = asDocument(input.dependencyChangeEvidence);
|
||||
const failures: string[] = [];
|
||||
const skipBaseline = input.skipBaseline === true;
|
||||
let diff: ReturnType<typeof diffDependencyInventories> = Object.freeze({
|
||||
added: Object.freeze([]),
|
||||
removed: Object.freeze([]),
|
||||
changed: Object.freeze([]),
|
||||
upgrades: Object.freeze([]),
|
||||
});
|
||||
let review: ReturnType<typeof validateDependencyReview> = Object.freeze({
|
||||
passed: skipBaseline,
|
||||
highRisk: Object.freeze([]),
|
||||
failures: Object.freeze(
|
||||
skipBaseline ? [] : ["dependency baseline unavailable"],
|
||||
),
|
||||
});
|
||||
const baselineDigest = baseline ? supplyChainDigest(baseline) : null;
|
||||
|
||||
if (baseline && approval) {
|
||||
diff = diffDependencyInventories(baseline, inventory);
|
||||
review = validateDependencyReview(
|
||||
diff,
|
||||
inventory,
|
||||
dependencyChangeEvidence ?? {},
|
||||
);
|
||||
if (
|
||||
approval.schemaVersion !== 1 ||
|
||||
approval.snapshotDigest !== baselineDigest ||
|
||||
typeof approval.owner !== "string" ||
|
||||
approval.owner.trim().length === 0
|
||||
) {
|
||||
failures.push("dependency baseline approval digest mismatch");
|
||||
}
|
||||
} else if (!skipBaseline) {
|
||||
failures.push("dependency baseline and approval are required");
|
||||
}
|
||||
failures.push(...review.failures);
|
||||
|
||||
const report = dependencyDiffArtifactSchema.parse({
|
||||
schemaVersion: 2,
|
||||
baselineDigest,
|
||||
currentDigest: supplyChainDigest(inventory),
|
||||
...diff,
|
||||
highRisk: review.highRisk,
|
||||
reviewFailures: review.failures,
|
||||
});
|
||||
return Object.freeze({
|
||||
report,
|
||||
dependencyDiff: diff,
|
||||
highRisk: review.highRisk,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
export function compareStoredDependencyEvidence(
|
||||
recomputed: ReturnType<typeof recomputeDependencyEvidence>,
|
||||
stored: unknown,
|
||||
): string[] {
|
||||
const parsed = dependencyDiffArtifactSchema.safeParse(stored);
|
||||
if (
|
||||
!parsed.success ||
|
||||
supplyChainDigest(parsed.data) !== supplyChainDigest(recomputed.report)
|
||||
) {
|
||||
return ["stored dependency diff does not match recomputed policy evidence"];
|
||||
}
|
||||
return [...recomputed.failures];
|
||||
}
|
||||
|
||||
export function recomputeLicenseEvidence(input: Readonly<{
|
||||
inventory: unknown;
|
||||
policy: unknown;
|
||||
}>) {
|
||||
const inventory = dependencyInventoryArtifactSchema.parse(input.inventory);
|
||||
const policy = asDocument(input.policy) ?? {};
|
||||
const result = validateLicensePolicy(inventory, policy);
|
||||
const report = licenseReportArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
status: result.passed ? "PASS" : "FAIL",
|
||||
dependencyCount: inventory.dependencyCount,
|
||||
results: result.results,
|
||||
failures: result.failures,
|
||||
});
|
||||
return Object.freeze({
|
||||
report,
|
||||
failures: result.failures,
|
||||
});
|
||||
}
|
||||
|
||||
export function compareStoredLicenseEvidence(
|
||||
recomputed: ReturnType<typeof recomputeLicenseEvidence>,
|
||||
stored: unknown,
|
||||
): string[] {
|
||||
const parsed = licenseReportArtifactSchema.safeParse(stored);
|
||||
if (
|
||||
!parsed.success ||
|
||||
supplyChainDigest(parsed.data) !== supplyChainDigest(recomputed.report)
|
||||
) {
|
||||
return ["stored license report does not match recomputed policy evidence"];
|
||||
}
|
||||
return [...recomputed.failures];
|
||||
}
|
||||
|
||||
export function distChecksumsText(outputs: readonly DistOutput[]): string {
|
||||
return `${[...outputs]
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
.map((output) => `${output.sha256} ${output.path}`)
|
||||
.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function verifyStoredDistChecksums(
|
||||
outputs: readonly DistOutput[],
|
||||
stored: string,
|
||||
): string[] {
|
||||
return stored === distChecksumsText(outputs)
|
||||
? []
|
||||
: ["stored dist checksums do not match current outputs"];
|
||||
}
|
||||
|
||||
function asDocument(value: unknown): Document | null {
|
||||
return value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value)
|
||||
? (value as Document)
|
||||
: null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze(
|
||||
Object.values(ROUTE_REGISTRY).map((route) => route.routeId),
|
||||
);
|
||||
|
||||
const REVIEW_FIELDS = Object.freeze([
|
||||
"M1 Keyboard",
|
||||
"M2 Visible focus",
|
||||
"M3 Route focus",
|
||||
"M4 Modal focus",
|
||||
"M5 Error association",
|
||||
"M6 Color signal",
|
||||
"M7 Reduced motion",
|
||||
"Screen reader",
|
||||
]);
|
||||
|
||||
export function validateManualA11yEvidence(content: string) {
|
||||
const fields = Object.fromEntries(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => /^([^:]+):\s*(.*)$/.exec(line))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => [
|
||||
match[1].trim(),
|
||||
match[2].trim(),
|
||||
]),
|
||||
);
|
||||
const failures: string[] = [];
|
||||
if (fields.Status !== "reviewed") failures.push("Status");
|
||||
if (!fields["Route ID"]) failures.push("Route ID");
|
||||
if (!fields["Release ID"]) failures.push("Release ID");
|
||||
if (!fields.Reviewer) failures.push("Reviewer");
|
||||
if (!fields.Signature) failures.push("Signature");
|
||||
if (fields.Attestation !== "accepted") failures.push("Attestation");
|
||||
if (
|
||||
!fields["Reviewed at"] ||
|
||||
!Number.isFinite(Date.parse(fields["Reviewed at"]))
|
||||
) {
|
||||
failures.push("Reviewed at");
|
||||
}
|
||||
|
||||
for (const field of REVIEW_FIELDS) {
|
||||
const result = fields[field];
|
||||
if (
|
||||
result !== "pass" &&
|
||||
!/^not-applicable \(.+\)$/.test(result ?? "")
|
||||
) {
|
||||
failures.push(field);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
fields: Object.freeze(fields),
|
||||
failures: Object.freeze(failures),
|
||||
passed: failures.length === 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readdir, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import {
|
||||
build,
|
||||
normalizePath,
|
||||
type Plugin,
|
||||
version as viteVersion,
|
||||
} from "vite";
|
||||
|
||||
const RECIPE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
||||
const DECLARATION_FILE = /\.d\.[cm]?ts$/;
|
||||
|
||||
type EmittedOutput =
|
||||
| Readonly<{
|
||||
type: "chunk";
|
||||
fileName: string;
|
||||
code: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
type: "asset";
|
||||
fileName: string;
|
||||
source: string | Uint8Array;
|
||||
}>;
|
||||
|
||||
export type OptionalRecipeBundleOutput = Readonly<{
|
||||
fileName: string;
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export type OptionalRecipeBundleMeasurement = Readonly<{
|
||||
recipeId: string;
|
||||
sourceRoots: readonly string[];
|
||||
sourceFileCount: number;
|
||||
toolchain: Readonly<{
|
||||
bundler: "vite";
|
||||
viteVersion: string;
|
||||
mode: "production";
|
||||
target: "es2022";
|
||||
format: "es";
|
||||
minifier: "esbuild";
|
||||
treeshake: false;
|
||||
compression: "node-zlib-gzip";
|
||||
}>;
|
||||
outputs: readonly OptionalRecipeBundleOutput[];
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
bundleBudgetGzipBytes: number;
|
||||
remainingGzipBytes: number;
|
||||
sha256: string;
|
||||
passed: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Builds an uncomposed reference runtime as a synthetic production consumer.
|
||||
* Every catalog-owned source module is exposed as an entry namespace and
|
||||
* tree-shaking is disabled so internal fail-closed paths remain in the budget.
|
||||
*/
|
||||
export async function measureOptionalRecipeBundle(input: Readonly<{
|
||||
recipeId: string;
|
||||
sourceRoots: readonly string[];
|
||||
bundleBudgetGzipBytes: number;
|
||||
workspaceRoot?: string;
|
||||
}>): Promise<OptionalRecipeBundleMeasurement> {
|
||||
const recipeId = validateRecipeId(input.recipeId);
|
||||
const bundleBudgetGzipBytes = positiveSafeInteger(
|
||||
input.bundleBudgetGzipBytes,
|
||||
"Optional recipe bundle budget",
|
||||
);
|
||||
const workspaceRoot = await realpath(
|
||||
path.resolve(input.workspaceRoot ?? process.cwd()),
|
||||
);
|
||||
const sourceRoots = validateSourceRoots(input.sourceRoots);
|
||||
const sourceFiles = await resolveSourceFiles(
|
||||
workspaceRoot,
|
||||
sourceRoots,
|
||||
);
|
||||
const virtualEntry =
|
||||
`virtual:optional-reference-runtime-entry/${recipeId}`;
|
||||
const resolvedVirtualEntry = `\0${virtualEntry}`;
|
||||
const entrySource = sourceFiles
|
||||
.map(
|
||||
(sourceFile, index) =>
|
||||
`export * as source${index} from ${JSON.stringify(
|
||||
viteSourceSpecifier(sourceFile),
|
||||
)};`,
|
||||
)
|
||||
.join("\n")
|
||||
.concat("\n");
|
||||
const preservePublicEntryPlugin = {
|
||||
name: "optional-reference-runtime-entry",
|
||||
enforce: "pre",
|
||||
resolveId(id) {
|
||||
return id === virtualEntry ? resolvedVirtualEntry : null;
|
||||
},
|
||||
load(id) {
|
||||
return id === resolvedVirtualEntry ? entrySource : null;
|
||||
},
|
||||
options(options) {
|
||||
return {
|
||||
...options,
|
||||
preserveEntrySignatures: "strict",
|
||||
};
|
||||
},
|
||||
} satisfies Plugin;
|
||||
const buildResult = await build({
|
||||
root: workspaceRoot,
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
mode: "production",
|
||||
publicDir: false,
|
||||
clearScreen: false,
|
||||
logLevel: "silent",
|
||||
plugins: [preservePublicEntryPlugin],
|
||||
build: {
|
||||
target: "es2022",
|
||||
minify: "esbuild",
|
||||
sourcemap: false,
|
||||
write: false,
|
||||
emptyOutDir: false,
|
||||
copyPublicDir: false,
|
||||
cssCodeSplit: false,
|
||||
reportCompressedSize: false,
|
||||
rollupOptions: {
|
||||
input: virtualEntry,
|
||||
// Budget the complete selected runtime, including internal fail-closed
|
||||
// guards that a synthetic consumer cannot predict it will exercise.
|
||||
treeshake: false,
|
||||
output: {
|
||||
format: "es",
|
||||
entryFileNames: `${recipeId}.js`,
|
||||
chunkFileNames: `${recipeId}-chunk-[hash].js`,
|
||||
assetFileNames: `${recipeId}-asset-[name]-[hash][extname]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const emitted = emittedOutputs(buildResult);
|
||||
if (emitted.length === 0) {
|
||||
throw new TypeError("Optional recipe bundle emitted no output.");
|
||||
}
|
||||
const outputs = emitted
|
||||
.map((output) => {
|
||||
const bytes = outputBytes(output);
|
||||
return Object.freeze({
|
||||
fileName: output.fileName,
|
||||
bytes: bytes.byteLength,
|
||||
gzipBytes: gzipSync(bytes).byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
})
|
||||
.sort((left, right) => compareText(left.fileName, right.fileName));
|
||||
const aggregateHash = createHash("sha256");
|
||||
for (const output of outputs) {
|
||||
aggregateHash.update(output.fileName);
|
||||
aggregateHash.update("\0");
|
||||
aggregateHash.update(output.sha256);
|
||||
aggregateHash.update("\0");
|
||||
}
|
||||
const bytes = outputs.reduce(
|
||||
(total, output) => total + output.bytes,
|
||||
0,
|
||||
);
|
||||
const gzipBytes = outputs.reduce(
|
||||
(total, output) => total + output.gzipBytes,
|
||||
0,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
recipeId,
|
||||
sourceRoots: Object.freeze([...sourceRoots]),
|
||||
sourceFileCount: sourceFiles.length,
|
||||
toolchain: Object.freeze({
|
||||
bundler: "vite" as const,
|
||||
viteVersion,
|
||||
mode: "production" as const,
|
||||
target: "es2022" as const,
|
||||
format: "es" as const,
|
||||
minifier: "esbuild" as const,
|
||||
treeshake: false as const,
|
||||
compression: "node-zlib-gzip" as const,
|
||||
}),
|
||||
outputs: Object.freeze(outputs),
|
||||
bytes,
|
||||
gzipBytes,
|
||||
bundleBudgetGzipBytes,
|
||||
remainingGzipBytes: bundleBudgetGzipBytes - gzipBytes,
|
||||
sha256: aggregateHash.digest("hex"),
|
||||
passed: gzipBytes <= bundleBudgetGzipBytes,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSourceFiles(
|
||||
workspaceRoot: string,
|
||||
sourceRoots: readonly string[],
|
||||
): Promise<readonly string[]> {
|
||||
const sourceBoundary = await realpath(path.join(workspaceRoot, "src"));
|
||||
const discovered: string[] = [];
|
||||
for (const sourceRoot of sourceRoots) {
|
||||
const target = path.resolve(workspaceRoot, sourceRoot);
|
||||
assertInsideSourceBoundary(target, sourceBoundary);
|
||||
const rootMetadata = await lstat(target);
|
||||
if (rootMetadata.isSymbolicLink()) {
|
||||
throw new TypeError("Optional recipe source root cannot be a symlink.");
|
||||
}
|
||||
assertInsideSourceBoundary(await realpath(target), sourceBoundary);
|
||||
if (
|
||||
rootMetadata.isFile() &&
|
||||
(!SOURCE_EXTENSION.test(target) || DECLARATION_FILE.test(target))
|
||||
) {
|
||||
throw new TypeError("Optional recipe source root is not executable source.");
|
||||
}
|
||||
discovered.push(
|
||||
...(await collectExecutableSources(target, sourceBoundary)),
|
||||
);
|
||||
}
|
||||
const unique = [...new Set(discovered)].sort((left, right) =>
|
||||
compareText(
|
||||
normalizePath(path.relative(workspaceRoot, left)),
|
||||
normalizePath(path.relative(workspaceRoot, right)),
|
||||
),
|
||||
);
|
||||
if (unique.length === 0) {
|
||||
throw new TypeError("Optional recipe source roots contain no executable source.");
|
||||
}
|
||||
return Object.freeze(unique);
|
||||
}
|
||||
|
||||
async function collectExecutableSources(
|
||||
target: string,
|
||||
sourceBoundary: string,
|
||||
): Promise<string[]> {
|
||||
const metadata = await lstat(target);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError("Optional recipe source cannot be a symlink.");
|
||||
}
|
||||
assertInsideSourceBoundary(target, sourceBoundary);
|
||||
if (metadata.isFile()) {
|
||||
return SOURCE_EXTENSION.test(target) && !DECLARATION_FILE.test(target)
|
||||
? [target]
|
||||
: [];
|
||||
}
|
||||
if (!metadata.isDirectory()) return [];
|
||||
const entries = (await readdir(target, { withFileTypes: true })).sort(
|
||||
(left, right) => compareText(left.name, right.name),
|
||||
);
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) =>
|
||||
collectExecutableSources(
|
||||
path.join(target, entry.name),
|
||||
sourceBoundary,
|
||||
),
|
||||
),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
function emittedOutputs(value: unknown): readonly EmittedOutput[] {
|
||||
const buildOutputs = Array.isArray(value) ? value : [value];
|
||||
const emitted: EmittedOutput[] = [];
|
||||
for (const buildOutput of buildOutputs) {
|
||||
if (!isRecord(buildOutput) || !Array.isArray(buildOutput.output)) {
|
||||
throw new TypeError("Optional recipe bundle output is invalid.");
|
||||
}
|
||||
for (const output of buildOutput.output) {
|
||||
if (!isRecord(output)) {
|
||||
throw new TypeError("Optional recipe emitted output is invalid.");
|
||||
}
|
||||
if (
|
||||
output.type === "chunk" &&
|
||||
typeof output.fileName === "string" &&
|
||||
typeof output.code === "string"
|
||||
) {
|
||||
emitted.push({
|
||||
type: "chunk",
|
||||
fileName: output.fileName,
|
||||
code: output.code,
|
||||
});
|
||||
} else if (
|
||||
output.type === "asset" &&
|
||||
typeof output.fileName === "string" &&
|
||||
(typeof output.source === "string" ||
|
||||
output.source instanceof Uint8Array)
|
||||
) {
|
||||
emitted.push({
|
||||
type: "asset",
|
||||
fileName: output.fileName,
|
||||
source: output.source,
|
||||
});
|
||||
} else {
|
||||
throw new TypeError("Optional recipe emitted output shape is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
function outputBytes(output: EmittedOutput): Buffer {
|
||||
if (output.type === "chunk") {
|
||||
return Buffer.from(output.code, "utf8");
|
||||
}
|
||||
return Buffer.from(output.source);
|
||||
}
|
||||
|
||||
function validateRecipeId(value: unknown): string {
|
||||
if (typeof value !== "string" || !RECIPE_ID.test(value)) {
|
||||
throw new TypeError("Optional recipe ID is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSourceRoots(value: unknown): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
value.length > 32 ||
|
||||
value.some(
|
||||
(sourceRoot) =>
|
||||
typeof sourceRoot !== "string" ||
|
||||
!sourceRoot.startsWith("src/") ||
|
||||
sourceRoot.includes("\\") ||
|
||||
sourceRoot
|
||||
.split("/")
|
||||
.some(
|
||||
(segment) =>
|
||||
segment.length === 0 || segment === "." || segment === "..",
|
||||
),
|
||||
) ||
|
||||
new Set(value).size !== value.length
|
||||
) {
|
||||
throw new TypeError("Optional recipe source roots are invalid.");
|
||||
}
|
||||
return Object.freeze([...value].sort(compareText));
|
||||
}
|
||||
|
||||
function assertInsideSourceBoundary(
|
||||
target: string,
|
||||
sourceBoundary: string,
|
||||
): void {
|
||||
const relative = path.relative(sourceBoundary, target);
|
||||
if (
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError("Optional recipe source escaped the source boundary.");
|
||||
}
|
||||
}
|
||||
|
||||
function viteSourceSpecifier(sourceFile: string): string {
|
||||
return pathToFileURL(sourceFile).href;
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: unknown, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
throw new TypeError(`${name} is invalid.`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const REQUIRED_RECIPE_IDS = Object.freeze([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"feature-flag",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"large-data-ui",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
] as const);
|
||||
|
||||
const lifecycleRecipes: ReadonlySet<string> = new Set([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
]);
|
||||
|
||||
type Document = Readonly<Record<string, unknown>>;
|
||||
export type OptionalRecipeSourceViolation = Readonly<{
|
||||
ruleId: string;
|
||||
path: string;
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function recordRows(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function nonEmptyStrings(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function packageVersions(value: unknown): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(recordValue(value)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function validateRecipeCatalog(
|
||||
input: unknown,
|
||||
packageDocument: Document,
|
||||
): string[] {
|
||||
const document = recordValue(input);
|
||||
const violations: string[] = [];
|
||||
if (document.schemaVersion !== 1) violations.push("CATALOG_SCHEMA_VERSION");
|
||||
if (document.decisionId !== "VD-10") violations.push("CATALOG_DECISION");
|
||||
if (document.defaultStatus !== "NOT_INSTALLED") {
|
||||
violations.push("CATALOG_DEFAULT_MUST_BE_NOT_INSTALLED");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(document.productionRuntimeDependencies) ||
|
||||
document.productionRuntimeDependencies.length > 0
|
||||
) {
|
||||
violations.push("UNSELECTED_RUNTIME_DEPENDENCY");
|
||||
}
|
||||
if (!nonEmptyStrings(document.vendorPackagePatterns)) {
|
||||
violations.push("VENDOR_PATTERN_CATALOG");
|
||||
}
|
||||
if (!Array.isArray(document.recipes)) {
|
||||
return [...violations, "RECIPE_CATALOG_MISSING"];
|
||||
}
|
||||
|
||||
const recipes = recordRows(document.recipes);
|
||||
const actualIds = recipes.map((recipe) => String(recipe.id ?? "")).sort();
|
||||
if (JSON.stringify(actualIds) !== JSON.stringify(REQUIRED_RECIPE_IDS)) {
|
||||
violations.push("RECIPE_ID_SET");
|
||||
}
|
||||
if (new Set(actualIds).size !== actualIds.length) {
|
||||
violations.push("RECIPE_ID_DUPLICATE");
|
||||
}
|
||||
|
||||
for (const recipe of recipes) {
|
||||
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
|
||||
if (recipe.status !== "RECIPE_AVAILABLE") {
|
||||
violations.push(`${id}:STATUS_MUST_NOT_CLAIM_INSTALLED`);
|
||||
}
|
||||
for (const field of [
|
||||
"trigger",
|
||||
"boundary",
|
||||
"port",
|
||||
"fake",
|
||||
"owner",
|
||||
"fallback",
|
||||
"serverStatePolicy",
|
||||
] as const) {
|
||||
const value = recipe[field];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
for (const field of [
|
||||
"forbiddenWhen",
|
||||
"failureKinds",
|
||||
"securityPrivacy",
|
||||
"removal",
|
||||
] as const) {
|
||||
if (!nonEmptyStrings(recipe[field])) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof recipe.bundleBudgetGzipBytes !== "number" ||
|
||||
!Number.isInteger(recipe.bundleBudgetGzipBytes) ||
|
||||
recipe.bundleBudgetGzipBytes < 1
|
||||
) {
|
||||
violations.push(`${id}:INVALID_BUNDLE_BUDGET`);
|
||||
}
|
||||
if (recipe.owner === "frontend-platform") {
|
||||
violations.push(`${id}:PROJECT_OWNER_NOT_ASSIGNED`);
|
||||
}
|
||||
if (lifecycleRecipes.has(id) && !nonEmptyStrings(recipe.lifecycleMethods)) {
|
||||
violations.push(`${id}:CLEANUP_CONTRACT_MISSING`);
|
||||
}
|
||||
if (
|
||||
id === "client-workflow" &&
|
||||
recipe.serverStatePolicy !== "reference-only"
|
||||
) {
|
||||
violations.push(`${id}:SERVER_STATE_DUPLICATION_POLICY`);
|
||||
}
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
...packageVersions(packageDocument.dependencies),
|
||||
...packageVersions(packageDocument.devDependencies),
|
||||
};
|
||||
const packageScripts = packageVersions(packageDocument.scripts);
|
||||
for (const recipe of recipes) {
|
||||
if (recipe.referenceRuntime === undefined) continue;
|
||||
const runtime = recordValue(recipe.referenceRuntime);
|
||||
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
|
||||
if (
|
||||
runtime.status !== "AVAILABLE_NOT_COMPOSED" ||
|
||||
runtime.productionComposition !== false
|
||||
) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_COMPOSITION`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.sourceRoots)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_SOURCE_ROOTS`);
|
||||
} else if (
|
||||
runtime.sourceRoots.some(
|
||||
(sourceRoot) =>
|
||||
!sourceRoot.startsWith("src/") ||
|
||||
sourceRoot.includes("\\") ||
|
||||
sourceRoot.split("/").includes(".."),
|
||||
)
|
||||
) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_SOURCE_BOUNDARY`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.coveredCapabilities)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_CAPABILITIES`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.conformanceScripts)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_CONFORMANCE`);
|
||||
} else {
|
||||
for (const script of runtime.conformanceScripts) {
|
||||
if (!(script in packageScripts)) {
|
||||
violations.push(`${id}:UNKNOWN_CONFORMANCE_SCRIPT:${script}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const vendorPatterns = Array.isArray(document.vendorPackagePatterns)
|
||||
? document.vendorPackagePatterns.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
)
|
||||
: [];
|
||||
for (const pattern of vendorPatterns) {
|
||||
const wildcard = pattern.endsWith("*");
|
||||
const prefix = pattern.replace(/\/?\*$/, "");
|
||||
if (
|
||||
Object.keys(dependencies).some(
|
||||
(dependency) =>
|
||||
dependency === prefix ||
|
||||
dependency.startsWith(`${prefix}/`) ||
|
||||
(wildcard && dependency.startsWith(prefix)),
|
||||
)
|
||||
) {
|
||||
violations.push(`UNSELECTED_VENDOR_INSTALLED:${prefix}`);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export async function sourceFiles(directory: string): Promise<string[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isRecord(error) &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const groups: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(target)
|
||||
: /\.(?:[cm]?[jt]s|[jt]sx)$/.test(entry.name)
|
||||
? [target]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
export async function scanOptionalRecipeSources(
|
||||
root: string,
|
||||
{ scanProductionBoundary = true }: Readonly<{
|
||||
scanProductionBoundary?: boolean;
|
||||
}> = {},
|
||||
): Promise<OptionalRecipeSourceViolation[]> {
|
||||
const violations: OptionalRecipeSourceViolation[] = [];
|
||||
for (const file of await sourceFiles(root)) {
|
||||
const relative = path.relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
const relativeToRoot = path.relative(root, file).replaceAll("\\", "/");
|
||||
const content = await readFile(file, "utf8");
|
||||
const imports = [
|
||||
...content.matchAll(/(?:from\s*|import\s*\(\s*)["']([^"']+)["']/g),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((specifier): specifier is string => specifier !== undefined);
|
||||
|
||||
if (
|
||||
scanProductionBoundary &&
|
||||
(relativeToRoot.startsWith("src/") ||
|
||||
(path.basename(path.resolve(root)) === "src" &&
|
||||
!relativeToRoot.startsWith(".."))) &&
|
||||
imports.some((specifier) =>
|
||||
/(?:^|\/)recipes\/frontend-capabilities(?:\/|$)/.test(specifier),
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "PRODUCTION_IMPORTS_RECIPE", path: relative });
|
||||
}
|
||||
|
||||
const productionRelative =
|
||||
path.basename(path.resolve(root)) === "src"
|
||||
? `src/${relativeToRoot}`
|
||||
: relativeToRoot;
|
||||
const isCompositionSource =
|
||||
/(?:^|\/)src\/bootstrap\//.test(productionRelative) ||
|
||||
/(?:^|\/)src\/features\/installed-feature-(?:adapters|runtimes)\./.test(
|
||||
productionRelative,
|
||||
);
|
||||
if (
|
||||
scanProductionBoundary &&
|
||||
isCompositionSource &&
|
||||
(imports.some((specifier) =>
|
||||
/(?:^|\/)adapters\/(?:browser-file-storage|browser-files|browser-transfer|cache-storage|storage\/(?:indexeddb|opfs))(?:\/|$)/.test(
|
||||
specifier,
|
||||
),
|
||||
) ||
|
||||
/["'][^"'\r\n]*(?:^|\/)adapters\/(?:browser-file-storage|browser-files|browser-transfer|cache-storage|storage\/(?:indexeddb|opfs))(?:\/|["'])/u.test(
|
||||
content,
|
||||
))
|
||||
) {
|
||||
violations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_COMPOSED_WITHOUT_SELECTION",
|
||||
path: relative,
|
||||
});
|
||||
}
|
||||
|
||||
const localVendorAdapter =
|
||||
relative.includes("recipes/") && relative.includes("/adapters/");
|
||||
if (
|
||||
!localVendorAdapter &&
|
||||
imports.some((specifier) =>
|
||||
/^(?:@launchdarkly\/|@sentry\/|@opentelemetry\/|@openapitools\/openapi-generator-cli$|@reduxjs\/toolkit$|@tanstack\/react-virtual$|@uppy\/|firebase(?:\/|$)|idb$|react-window$|redux(?:\/|$)|socket\.io-client$|tus-js-client$|workbox-window$|xstate$|zustand$)/.test(
|
||||
specifier,
|
||||
),
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "VENDOR_IMPORT_OUTSIDE_ADAPTER", path: relative });
|
||||
}
|
||||
|
||||
if (
|
||||
/localStorage\s*\.\s*(?:setItem|getItem)\s*\([^)]*(?:credential|password|secret|token)/is.test(
|
||||
content,
|
||||
) ||
|
||||
/searchParams\s*\.\s*set\s*\(\s*["'](?:credential|password|secret|token)/is.test(
|
||||
content,
|
||||
) ||
|
||||
/(?:record|track|emit)\s*\(\s*\{[\s\S]{0,400}(?:credential|password|secret|token)\s*:/i.test(
|
||||
content,
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "CREDENTIAL_LEAK_PATH", path: relative });
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:createStore|configureStore|create\s*\()\s*\([\s\S]{0,600}(?:apiResponse|queryData|serverState)\s*:/i.test(
|
||||
content,
|
||||
)
|
||||
) {
|
||||
violations.push({
|
||||
ruleId: "CLIENT_STORE_DUPLICATES_SERVER_STATE",
|
||||
path: relative,
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export async function scanProductionBundle(
|
||||
distRoot: string,
|
||||
): Promise<string[]> {
|
||||
const violations: string[] = [];
|
||||
const forbiddenRuntimeMarkers = [
|
||||
"frontend-optional-recipe-must-not-reach-production",
|
||||
"Browser file runtime hard limits are invalid.",
|
||||
"Object URL allocation failed",
|
||||
"Storage pressure policy is invalid.",
|
||||
"IndexedDB runtime configuration is invalid.",
|
||||
"Invalid IndexedDB schema migration.",
|
||||
"OPFS runtime policy is invalid.",
|
||||
"OPFS operation failed.",
|
||||
"Public Cache Storage policy is invalid.",
|
||||
"Public cache validation failed.",
|
||||
"Presigned capability vault limit is invalid.",
|
||||
"Resumable upload policy is invalid.",
|
||||
"Image CDN policy registry is invalid.",
|
||||
] as const;
|
||||
for (const file of await sourceFiles(distRoot)) {
|
||||
const content = await readFile(file, "utf8");
|
||||
if (forbiddenRuntimeMarkers.some((marker) => content.includes(marker))) {
|
||||
violations.push(path.relative(process.cwd(), file));
|
||||
}
|
||||
}
|
||||
|
||||
const viteManifestPath = path.join(distRoot, ".vite/manifest.json");
|
||||
const emittedModuleInventoryPath = path.join(
|
||||
distRoot,
|
||||
".vite/module-inventory.json",
|
||||
);
|
||||
const moduleInventoryCandidates = [
|
||||
emittedModuleInventoryPath,
|
||||
...(path.resolve(distRoot) === path.resolve("dist")
|
||||
? ["artifacts/quality/vite-module-inventory.json"]
|
||||
: []),
|
||||
];
|
||||
const viteManifestExists = await readFile(viteManifestPath, "utf8")
|
||||
.then(() => true)
|
||||
.catch((error: unknown) => {
|
||||
if (isRecord(error) && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
});
|
||||
if (!viteManifestExists) return [...new Set(violations)];
|
||||
|
||||
let inventory: unknown;
|
||||
let moduleInventoryPath = emittedModuleInventoryPath;
|
||||
for (const candidate of moduleInventoryCandidates) {
|
||||
try {
|
||||
inventory = JSON.parse(await readFile(candidate, "utf8"));
|
||||
moduleInventoryPath = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// A generated build may move the inventory out of the deploy directory.
|
||||
}
|
||||
}
|
||||
if (inventory === undefined) {
|
||||
violations.push(
|
||||
path.relative(process.cwd(), moduleInventoryPath),
|
||||
);
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
const inventoryDocument = recordValue(inventory);
|
||||
const chunks = recordRows(inventoryDocument.chunks);
|
||||
if (
|
||||
inventoryDocument.schemaVersion !== 1 ||
|
||||
!Array.isArray(inventoryDocument.chunks) ||
|
||||
chunks.length !== inventoryDocument.chunks.length
|
||||
) {
|
||||
violations.push(path.relative(process.cwd(), moduleInventoryPath));
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
|
||||
const forbiddenSourcePrefixes = [
|
||||
"src/application/ports/browser-file-storage/",
|
||||
"src/application/ports/browser-transfer/",
|
||||
"src/adapters/browser-file-storage/",
|
||||
"src/adapters/browser-files/",
|
||||
"src/adapters/browser-transfer/",
|
||||
"src/adapters/cache-storage/",
|
||||
"src/adapters/storage/indexeddb/",
|
||||
"src/adapters/storage/opfs/",
|
||||
] as const;
|
||||
for (const chunk of chunks) {
|
||||
if (
|
||||
typeof chunk.fileName !== "string" ||
|
||||
!Array.isArray(chunk.modules) ||
|
||||
chunk.modules.some((moduleId) => typeof moduleId !== "string")
|
||||
) {
|
||||
violations.push(path.relative(process.cwd(), moduleInventoryPath));
|
||||
continue;
|
||||
}
|
||||
for (const moduleId of chunk.modules as string[]) {
|
||||
if (
|
||||
forbiddenSourcePrefixes.some((prefix) =>
|
||||
moduleId.startsWith(prefix),
|
||||
)
|
||||
) {
|
||||
violations.push(`${chunk.fileName}:${moduleId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
type PackageManager = "pnpm" | "npm" | "yarn";
|
||||
|
||||
type ManagerParseResult = Readonly<{
|
||||
dependencies: readonly string[];
|
||||
unsafeLifecycle: boolean;
|
||||
unsupportedManagerSyntax: boolean;
|
||||
}>;
|
||||
|
||||
type SuppressionState = {
|
||||
effective: boolean | undefined;
|
||||
contradictory: boolean;
|
||||
malformed: boolean;
|
||||
};
|
||||
|
||||
type ShellNpmScopeEnvironmentState = {
|
||||
autoExport: boolean;
|
||||
forbidden: boolean;
|
||||
uncertain: boolean;
|
||||
};
|
||||
|
||||
type ShellCommandPrefix = Readonly<{
|
||||
assignments: readonly Readonly<{
|
||||
dynamicName: boolean;
|
||||
name: string | null;
|
||||
}>[];
|
||||
commandIndex: number;
|
||||
uncertain: boolean;
|
||||
}>;
|
||||
|
||||
type EnvironmentCommandPrefix = Readonly<{
|
||||
assignmentNames: readonly string[];
|
||||
uncertain: boolean;
|
||||
}>;
|
||||
|
||||
type TokenizedShellSegment = Readonly<{
|
||||
tokens: readonly string[];
|
||||
expansionTokens: readonly boolean[];
|
||||
}>;
|
||||
|
||||
const managerNames = new Set<PackageManager>(["pnpm", "npm", "yarn"]);
|
||||
const managerOptionsWithValue = new Set([
|
||||
"-C", "--cache", "--cache-folder", "--config-dir", "--cwd", "--dir", "--filter",
|
||||
"--global-dir", "--globalconfig", "--home", "--lockfile-dir", "--mutex", "--prefix",
|
||||
"--registry", "--store-dir", "--userconfig", "--workspace", "--workspace-dir",
|
||||
]);
|
||||
const managerBooleanOptions = new Set([
|
||||
"--color", "--global", "--no-color", "--offline", "--prefer-offline", "--silent",
|
||||
"--use-stderr", "--verbose", "-g", "-s",
|
||||
]);
|
||||
const npmScriptDispatchBooleanOptions = new Set([
|
||||
"--foreground-scripts", "--if-present", "--ignore-scripts",
|
||||
]);
|
||||
const npmScriptDispatchScopeOptions = new Set([
|
||||
"--prefix", "--workspace", "--workspaces",
|
||||
]);
|
||||
const npmDispatchScopeEnvironmentNames = new Set([
|
||||
"npm_config_globalconfig", "npm_config_prefix", "npm_config_userconfig",
|
||||
"npm_config_workspace", "npm_config_workspaces",
|
||||
]);
|
||||
const npmIndirectConfigAuthorityOptions = new Set([
|
||||
"--globalconfig", "--userconfig",
|
||||
]);
|
||||
const manifestScopeOptions: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
|
||||
pnpm: new Set(["-C", "--dir", "--filter", "--workspace-dir"]),
|
||||
npm: new Set(["--prefix", "--workspace"]),
|
||||
yarn: new Set(["--cwd"]),
|
||||
};
|
||||
const lifecycleMutationCommands = new Set([
|
||||
"add", "ci", "dedupe", "i", "install", "link", "pack", "prune", "publish",
|
||||
"rebuild", "remove", "rm", "uninstall", "unlink", "up", "update", "upgrade",
|
||||
]);
|
||||
const managerBuiltinAliases: Readonly<
|
||||
Record<PackageManager, ReadonlyMap<string, string>>
|
||||
> = {
|
||||
pnpm: new Map([["ln", "link"]]),
|
||||
npm: new Map(),
|
||||
yarn: new Map(),
|
||||
};
|
||||
const unsupportedBuiltinDispatchers: Readonly<
|
||||
Record<PackageManager, ReadonlySet<string>>
|
||||
> = {
|
||||
pnpm: new Set(["dlx", "exec"]),
|
||||
npm: new Set(["exec"]),
|
||||
yarn: new Set(["dlx", "exec", "workspace", "workspaces"]),
|
||||
};
|
||||
const lifecycleBooleanOptions: Readonly<
|
||||
Record<PackageManager, ReadonlySet<string>>
|
||||
> = {
|
||||
pnpm: new Set([
|
||||
"--dry-run", "--force", "--frozen-lockfile", "--lockfile-only",
|
||||
"--no-optional", "--prefer-frozen-lockfile", "--recursive",
|
||||
"--workspace-root", "-D", "-P", "-r", "-w",
|
||||
]),
|
||||
npm: new Set([
|
||||
"--audit", "--dry-run", "--force", "--foreground-scripts", "--fund",
|
||||
"--package-lock-only",
|
||||
]),
|
||||
yarn: new Set([
|
||||
"--check-cache", "--frozen-lockfile", "--ignore-engines",
|
||||
"--ignore-optional", "--immutable", "--immutable-cache", "--inline-builds",
|
||||
"--no-lockfile", "--non-interactive", "--pure-lockfile",
|
||||
]),
|
||||
};
|
||||
const lifecycleOptionsWithValue: Readonly<
|
||||
Record<PackageManager, ReadonlySet<string>>
|
||||
> = {
|
||||
pnpm: new Set(["--child-concurrency", "--modules-dir", "--reporter"]),
|
||||
npm: new Set(["--include", "--install-strategy", "--omit"]),
|
||||
yarn: new Set(["--mode", "--modules-folder", "--production"]),
|
||||
};
|
||||
const knownBuiltinCommands: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
|
||||
pnpm: new Set([
|
||||
"audit", "config", "deploy", "dlx", "exec", "fetch", "help", "list", "ls",
|
||||
"outdated", "root", "server", "setup", "store", "view", "why",
|
||||
]),
|
||||
npm: new Set([
|
||||
"access", "audit", "bugs", "cache", "completion", "config", "diff", "docs",
|
||||
"doctor", "exec", "explore", "fund", "help", "help-search", "hook", "init",
|
||||
"list", "login", "logout", "ls", "org", "outdated", "owner", "ping", "pkg",
|
||||
"prefix", "profile", "query", "repo", "root", "search", "star", "stars",
|
||||
"team", "token", "unstar", "version", "view", "whoami",
|
||||
]),
|
||||
yarn: new Set([
|
||||
"cache", "config", "constraints", "dedupe", "dlx", "exec", "help", "info",
|
||||
"npm", "plugin", "set", "stage", "version", "why",
|
||||
]),
|
||||
};
|
||||
const exactBareSafeBuiltinCommands: Readonly<
|
||||
Record<PackageManager, ReadonlySet<string>>
|
||||
> = {
|
||||
pnpm: new Set(["audit"]),
|
||||
npm: new Set(["audit"]),
|
||||
yarn: new Set(),
|
||||
};
|
||||
const npmImplicitScripts = new Set(["restart", "start", "stop", "test"]);
|
||||
|
||||
export function validatePackageScriptGraph(
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
entryScript: string,
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
|
||||
const visit = (scriptName: string): void => {
|
||||
if (visiting.has(scriptName)) {
|
||||
const start = stack.indexOf(scriptName);
|
||||
failures.push(`package script cycle: ${[...stack.slice(start), scriptName].join(" -> ")}`);
|
||||
return;
|
||||
}
|
||||
if (visited.has(scriptName)) return;
|
||||
const command = scripts[scriptName];
|
||||
if (command === undefined) {
|
||||
failures.push(`package script missing: ${scriptName}`);
|
||||
return;
|
||||
}
|
||||
visiting.add(scriptName);
|
||||
stack.push(scriptName);
|
||||
if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) {
|
||||
failures.push(`${scriptName} must not invoke the CI gate runner`);
|
||||
}
|
||||
const parsed = parseManagerCommands(command, scripts);
|
||||
if (parsed.unsupportedManagerSyntax) {
|
||||
failures.push(`package script manager invocation is not safely parseable: ${scriptName}`);
|
||||
}
|
||||
for (const dependency of parsed.dependencies) {
|
||||
if (dependency === "ci:gate") {
|
||||
failures.push(`${scriptName} must not invoke ci:gate`);
|
||||
} else if (!(dependency in scripts)) {
|
||||
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
|
||||
} else {
|
||||
visit(dependency);
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
visiting.delete(scriptName);
|
||||
visited.add(scriptName);
|
||||
};
|
||||
|
||||
visit(entryScript);
|
||||
return [...new Set(failures)];
|
||||
}
|
||||
|
||||
export function validateInstallScriptPolicy(
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
entryScripts: readonly string[],
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visit = (scriptName: string): void => {
|
||||
if (visited.has(scriptName)) return;
|
||||
visited.add(scriptName);
|
||||
const command = scripts[scriptName];
|
||||
if (command === undefined) {
|
||||
failures.push(`package script missing: ${scriptName}`);
|
||||
return;
|
||||
}
|
||||
const parsed = parseManagerCommands(command, scripts);
|
||||
if (parsed.unsafeLifecycle || parsed.unsupportedManagerSyntax) {
|
||||
failures.push(
|
||||
`install-bearing package script must use --ignore-scripts: ${scriptName}`,
|
||||
);
|
||||
}
|
||||
for (const dependency of parsed.dependencies) {
|
||||
if (dependency !== "ci:gate") visit(dependency);
|
||||
}
|
||||
};
|
||||
for (const entryScript of entryScripts) visit(entryScript);
|
||||
return [...new Set(failures)];
|
||||
}
|
||||
|
||||
export function validateNpmScopeEnvironment(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): string[] {
|
||||
return Object.keys(environment)
|
||||
.filter((name) => npmDispatchScopeEnvironmentNames.has(name.toLowerCase()))
|
||||
.map((name) => `npm scope environment is not allowed: ${name}`);
|
||||
}
|
||||
|
||||
function parseManagerCommands(
|
||||
command: string,
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
): ManagerParseResult {
|
||||
const tokenized = tokenizeShellSegments(command);
|
||||
const dependencies: string[] = [];
|
||||
let unsafeLifecycle = false;
|
||||
let unsupportedManagerSyntax = false;
|
||||
if (!tokenized) {
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([]),
|
||||
unsafeLifecycle: false,
|
||||
unsupportedManagerSyntax: containsManagerReference(command),
|
||||
});
|
||||
}
|
||||
unsupportedManagerSyntax ||= tokenized.unsupportedControl && containsManagerReference(command);
|
||||
const npmScopeEnvironmentState: ShellNpmScopeEnvironmentState = {
|
||||
autoExport: false,
|
||||
forbidden: false,
|
||||
uncertain: false,
|
||||
};
|
||||
for (const segment of tokenized.segments) {
|
||||
const { tokens, expansionTokens } = segment;
|
||||
updateShellNpmScopeEnvironmentState(segment, npmScopeEnvironmentState);
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index]!;
|
||||
if (token === "corepack") {
|
||||
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
|
||||
unsupportedManagerSyntax = true;
|
||||
break;
|
||||
}
|
||||
const wrapped = tokens[index + 1];
|
||||
if (!wrapped || !isPackageManager(wrapped)) {
|
||||
unsupportedManagerSyntax = true;
|
||||
break;
|
||||
}
|
||||
const parsed = parseManagerInvocation(
|
||||
wrapped,
|
||||
tokens,
|
||||
expansionTokens,
|
||||
index + 2,
|
||||
scripts,
|
||||
hasUnsafeNpmScopeEnvironment(
|
||||
npmScopeEnvironmentState,
|
||||
tokens,
|
||||
expansionTokens,
|
||||
index,
|
||||
),
|
||||
);
|
||||
dependencies.push(...parsed.dependencies);
|
||||
unsafeLifecycle ||= parsed.unsafeLifecycle;
|
||||
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
|
||||
break;
|
||||
}
|
||||
if (isPackageManager(token)) {
|
||||
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
|
||||
unsupportedManagerSyntax = true;
|
||||
break;
|
||||
}
|
||||
const parsed = parseManagerInvocation(
|
||||
token,
|
||||
tokens,
|
||||
expansionTokens,
|
||||
index + 1,
|
||||
scripts,
|
||||
hasUnsafeNpmScopeEnvironment(
|
||||
npmScopeEnvironmentState,
|
||||
tokens,
|
||||
expansionTokens,
|
||||
index,
|
||||
),
|
||||
);
|
||||
dependencies.push(...parsed.dependencies);
|
||||
unsafeLifecycle ||= parsed.unsafeLifecycle;
|
||||
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
|
||||
break;
|
||||
}
|
||||
if (containsManagerReference(token)) {
|
||||
unsupportedManagerSyntax = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([...new Set(dependencies)]),
|
||||
unsafeLifecycle,
|
||||
unsupportedManagerSyntax,
|
||||
});
|
||||
}
|
||||
|
||||
function parseManagerInvocation(
|
||||
manager: PackageManager,
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
start: number,
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
hasNpmScopeEnvironment: boolean,
|
||||
): ManagerParseResult {
|
||||
let cursor = start;
|
||||
let changesManifestScope = false;
|
||||
let hasNpmConfigAuthority = false;
|
||||
let consumedManagerSyntax = false;
|
||||
const suppression: SuppressionState = {
|
||||
effective: undefined,
|
||||
contradictory: false,
|
||||
malformed: false,
|
||||
};
|
||||
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
|
||||
consumedManagerSyntax = true;
|
||||
const option = tokens[cursor]!;
|
||||
const parsedSuppression = consumeSuppressionOption(
|
||||
manager,
|
||||
tokens,
|
||||
cursor,
|
||||
suppression,
|
||||
);
|
||||
if (parsedSuppression.recognized) {
|
||||
if (parsedSuppression.unsupported) return unsupportedResult();
|
||||
cursor = parsedSuppression.nextIndex;
|
||||
continue;
|
||||
}
|
||||
const equals = option.indexOf("=");
|
||||
const name = equals < 0 ? option : option.slice(0, equals);
|
||||
if (managerOptionsWithValue.has(name)) {
|
||||
changesManifestScope ||= manifestScopeOptions[manager].has(name);
|
||||
hasNpmConfigAuthority ||=
|
||||
manager === "npm" && npmIndirectConfigAuthorityOptions.has(name);
|
||||
if (equals >= 0) {
|
||||
if (option.slice(equals + 1).length === 0) return unsupportedResult();
|
||||
} else {
|
||||
cursor += 1;
|
||||
if (cursor >= tokens.length || tokens[cursor]!.startsWith("-")) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
}
|
||||
} else if (managerBooleanOptions.has(name)) {
|
||||
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
} else if (option !== "--") {
|
||||
return unsupportedResult();
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
const subcommand = tokens[cursor];
|
||||
if (!subcommand) return unsupportedResult();
|
||||
if (manager === "npm" && (hasNpmScopeEnvironment || hasNpmConfigAuthority)) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
const argumentsAfterCommand = tokens.slice(cursor + 1);
|
||||
if (subcommand === "run" || subcommand === "run-script") {
|
||||
const dependency = argumentsAfterCommand[0];
|
||||
if (!dependency || dependency.startsWith("-")) return unsupportedResult();
|
||||
if (changesManifestScope) return unsupportedResult();
|
||||
if (
|
||||
manager === "npm" &&
|
||||
(expansionTokens.slice(start, cursor + 2).some(Boolean) ||
|
||||
!areNpmScriptDispatchArgumentsSupported(
|
||||
argumentsAfterCommand.slice(1),
|
||||
expansionTokens.slice(cursor + 2),
|
||||
suppression,
|
||||
))
|
||||
) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
return manager === "npm"
|
||||
? npmScriptDependencyResult(dependency, scripts, suppression)
|
||||
: dependencyResult(dependency);
|
||||
}
|
||||
const canonicalSubcommand = managerBuiltinAliases[manager].get(subcommand) ?? subcommand;
|
||||
if (unsupportedBuiltinDispatchers[manager].has(canonicalSubcommand)) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
if (lifecycleMutationCommands.has(canonicalSubcommand)) {
|
||||
const lifecycleArgumentsSupported = parseLifecycleArguments(
|
||||
manager,
|
||||
argumentsAfterCommand,
|
||||
suppression,
|
||||
);
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([]),
|
||||
unsafeLifecycle:
|
||||
!lifecycleArgumentsSupported || !hasEffectiveLifecycleSuppression(suppression),
|
||||
unsupportedManagerSyntax: !lifecycleArgumentsSupported,
|
||||
});
|
||||
}
|
||||
if (knownBuiltinCommands[manager].has(canonicalSubcommand)) {
|
||||
return exactBareSafeBuiltinCommands[manager].has(canonicalSubcommand) &&
|
||||
!consumedManagerSyntax &&
|
||||
argumentsAfterCommand.length === 0
|
||||
? emptyResult()
|
||||
: unsupportedResult();
|
||||
}
|
||||
const isKnownRootScript = Object.prototype.hasOwnProperty.call(scripts, subcommand);
|
||||
const supportsImplicit = /^[A-Za-z0-9:_-]+$/u.test(subcommand) && (
|
||||
((manager === "pnpm" || manager === "yarn") && isKnownRootScript) ||
|
||||
(manager === "npm" && npmImplicitScripts.has(subcommand))
|
||||
);
|
||||
if (supportsImplicit) {
|
||||
if (changesManifestScope) return unsupportedResult();
|
||||
if (
|
||||
manager === "npm" &&
|
||||
(expansionTokens.slice(start, cursor + 1).some(Boolean) ||
|
||||
!areNpmScriptDispatchArgumentsSupported(
|
||||
argumentsAfterCommand,
|
||||
expansionTokens.slice(cursor + 1),
|
||||
suppression,
|
||||
))
|
||||
) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
return manager === "npm"
|
||||
? npmScriptDependencyResult(subcommand, scripts, suppression)
|
||||
: dependencyResult(subcommand);
|
||||
}
|
||||
return unsupportedResult();
|
||||
}
|
||||
|
||||
function areNpmScriptDispatchArgumentsSupported(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
suppression: SuppressionState,
|
||||
): boolean {
|
||||
let index = 0;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index]!;
|
||||
if (token === "--") return true;
|
||||
if (expansionTokens[index]) return false;
|
||||
if (!token.startsWith("-")) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedSuppression = consumeSuppressionOption(
|
||||
"npm",
|
||||
tokens,
|
||||
index,
|
||||
suppression,
|
||||
);
|
||||
if (parsedSuppression.recognized) {
|
||||
if (parsedSuppression.unsupported) return false;
|
||||
index = parsedSuppression.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const equals = token.indexOf("=");
|
||||
const name = equals < 0 ? token : token.slice(0, equals);
|
||||
const isShortWorkspaceOption = token === "-w" || /^-w(?:=)?.+/u.test(token);
|
||||
if (npmScriptDispatchScopeOptions.has(name) || isShortWorkspaceOption) {
|
||||
return false;
|
||||
}
|
||||
if (!npmScriptDispatchBooleanOptions.has(name)) return false;
|
||||
if (equals >= 0 && !/^(?:true|false)$/u.test(token.slice(equals + 1))) {
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasUnsafeNpmScopeEnvironment(
|
||||
state: Readonly<ShellNpmScopeEnvironmentState>,
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
commandIndex: number,
|
||||
): boolean {
|
||||
return state.forbidden || state.uncertain ||
|
||||
hasUnsafeImmediateNpmScopeEnvironment(tokens, expansionTokens, commandIndex);
|
||||
}
|
||||
|
||||
function hasUnsafeManagerCommandPrefix(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
commandIndex: number,
|
||||
): boolean {
|
||||
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
||||
if (prefix.uncertain) return true;
|
||||
let cursor = prefix.commandIndex;
|
||||
if (cursor === commandIndex) return false;
|
||||
if (cursor > commandIndex ||
|
||||
!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) {
|
||||
return true;
|
||||
}
|
||||
return parseEnvironmentCommandPrefix(
|
||||
tokens,
|
||||
expansionTokens,
|
||||
cursor,
|
||||
commandIndex,
|
||||
).uncertain;
|
||||
}
|
||||
|
||||
function hasUnsafeImmediateNpmScopeEnvironment(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
commandIndex: number,
|
||||
): boolean {
|
||||
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
||||
if (prefix.uncertain || prefix.assignments.some(
|
||||
({ name }) => name !== null && isNpmScopeEnvironmentName(name),
|
||||
)) return true;
|
||||
let cursor = prefix.commandIndex;
|
||||
if (cursor === commandIndex) return false;
|
||||
if (!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) return false;
|
||||
const environmentPrefix = parseEnvironmentCommandPrefix(
|
||||
tokens,
|
||||
expansionTokens,
|
||||
cursor,
|
||||
commandIndex,
|
||||
);
|
||||
return environmentPrefix.uncertain || environmentPrefix.assignmentNames.some(
|
||||
(name) => isNpmScopeEnvironmentName(name),
|
||||
);
|
||||
}
|
||||
|
||||
function parseEnvironmentCommandPrefix(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
start: number,
|
||||
commandIndex: number,
|
||||
): EnvironmentCommandPrefix {
|
||||
const assignmentNames: string[] = [];
|
||||
let cursor = start + 1;
|
||||
let uncertain = false;
|
||||
|
||||
while (cursor < commandIndex && tokens[cursor]!.startsWith("-")) {
|
||||
const option = tokens[cursor]!;
|
||||
if (expansionTokens[cursor]) uncertain = true;
|
||||
if (option === "--") {
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
if (option === "-i" || option === "--ignore-environment") {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (option === "-u" || option === "--unset") {
|
||||
cursor += 1;
|
||||
if (cursor >= commandIndex || tokens[cursor]!.startsWith("-")) {
|
||||
uncertain = true;
|
||||
break;
|
||||
}
|
||||
uncertain ||= expansionTokens[cursor] ?? false;
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^--unset=.+/u.test(option)) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
uncertain = true;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
while (cursor < commandIndex) {
|
||||
const token = tokens[cursor]!;
|
||||
if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false)) {
|
||||
uncertain = true;
|
||||
}
|
||||
const assignmentName = parseEnvironmentAssignmentName(token);
|
||||
if (assignmentName) assignmentNames.push(assignmentName);
|
||||
else uncertain = true;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
assignmentNames: Object.freeze(assignmentNames),
|
||||
uncertain,
|
||||
});
|
||||
}
|
||||
|
||||
function parseShellCommandPrefix(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
): ShellCommandPrefix {
|
||||
const assignments: Array<{
|
||||
dynamicName: boolean;
|
||||
name: string | null;
|
||||
}> = [];
|
||||
let cursor = 0;
|
||||
let uncertain = false;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor]!;
|
||||
const name = parseAssignmentName(token);
|
||||
const dynamicName = hasDynamicAssignmentName(
|
||||
token,
|
||||
expansionTokens[cursor] ?? false,
|
||||
);
|
||||
if (!name && !dynamicName) break;
|
||||
assignments.push({ dynamicName, name });
|
||||
uncertain ||= dynamicName;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
while (cursor < tokens.length) {
|
||||
const wrapper = tokens[cursor];
|
||||
if (expansionTokens[cursor]) {
|
||||
uncertain = true;
|
||||
break;
|
||||
}
|
||||
if (wrapper !== "command" && wrapper !== "exec") break;
|
||||
cursor += 1;
|
||||
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
|
||||
const option = tokens[cursor]!;
|
||||
if (option === "--") {
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
if (wrapper === "command" && option === "-p") {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
uncertain = true;
|
||||
cursor += 1;
|
||||
if (wrapper === "exec" && option === "-a" && cursor < tokens.length) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expansionTokens[cursor]) uncertain = true;
|
||||
return Object.freeze({
|
||||
assignments: Object.freeze(assignments.map((assignment) => Object.freeze(assignment))),
|
||||
commandIndex: cursor,
|
||||
uncertain,
|
||||
});
|
||||
}
|
||||
|
||||
function updateShellNpmScopeEnvironmentState(
|
||||
segment: TokenizedShellSegment,
|
||||
state: ShellNpmScopeEnvironmentState,
|
||||
): void {
|
||||
const { tokens, expansionTokens } = segment;
|
||||
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
||||
state.uncertain ||= prefix.uncertain;
|
||||
const command = tokens[prefix.commandIndex];
|
||||
if (!command) {
|
||||
for (const assignment of prefix.assignments) {
|
||||
if (state.autoExport && assignment.name &&
|
||||
isNpmScopeEnvironmentName(assignment.name)) {
|
||||
state.forbidden = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "eval" || command === "." || command === "source") {
|
||||
state.uncertain = true;
|
||||
return;
|
||||
}
|
||||
if (command === "unset" || command === "typeset" || command === "declare" ||
|
||||
command === "local" || command === "readonly") {
|
||||
state.uncertain = true;
|
||||
return;
|
||||
}
|
||||
if (command === "set") {
|
||||
if (tokens.slice(prefix.commandIndex + 1).includes("-a")) state.autoExport = true;
|
||||
if (tokens.slice(prefix.commandIndex + 1).includes("+a")) {
|
||||
state.autoExport = false;
|
||||
state.uncertain = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "export") {
|
||||
let cursor = prefix.commandIndex + 1;
|
||||
for (; cursor < tokens.length; cursor += 1) {
|
||||
const token = tokens[cursor]!;
|
||||
if (token === "--") continue;
|
||||
if (token === "-n" || token.startsWith("-")) {
|
||||
state.uncertain = true;
|
||||
continue;
|
||||
}
|
||||
const assignmentName = parseAssignmentName(token);
|
||||
const bareName = /^[A-Za-z_][A-Za-z0-9_]*$/u.test(token) ? token : null;
|
||||
if (assignmentName || bareName) {
|
||||
if (isNpmScopeEnvironmentName(assignmentName ?? bareName!)) {
|
||||
state.forbidden = true;
|
||||
}
|
||||
} else if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false) ||
|
||||
expansionTokens[cursor]) {
|
||||
state.uncertain = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function isEnvironmentCommand(token: string | undefined, hasExpansion: boolean): boolean {
|
||||
if (!token || hasExpansion) return false;
|
||||
return token.split("/").at(-1) === "env";
|
||||
}
|
||||
|
||||
function parseEnvironmentAssignmentName(token: string): string | null {
|
||||
const equals = token.indexOf("=");
|
||||
return equals > 0 ? token.slice(0, equals) : null;
|
||||
}
|
||||
|
||||
function isNpmScopeEnvironmentName(name: string): boolean {
|
||||
return npmDispatchScopeEnvironmentNames.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
function hasDynamicAssignmentName(token: string, hasExpansion: boolean): boolean {
|
||||
const equals = token.indexOf("=");
|
||||
return hasExpansion && equals > 0 && parseAssignmentName(token) === null;
|
||||
}
|
||||
|
||||
function parseAssignmentName(token: string): string | null {
|
||||
return /^([A-Za-z_][A-Za-z0-9_]*)=/u.exec(token)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseLifecycleArguments(
|
||||
manager: PackageManager,
|
||||
tokens: readonly string[],
|
||||
suppression: SuppressionState,
|
||||
): boolean {
|
||||
let cursor = 0;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor]!;
|
||||
if (!token.startsWith("-")) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
const parsedSuppression = consumeSuppressionOption(
|
||||
manager,
|
||||
tokens,
|
||||
cursor,
|
||||
suppression,
|
||||
);
|
||||
if (parsedSuppression.recognized) {
|
||||
if (parsedSuppression.unsupported) return false;
|
||||
cursor = parsedSuppression.nextIndex;
|
||||
continue;
|
||||
}
|
||||
const parsedOption = consumeAllowedLifecycleOption(manager, tokens, cursor);
|
||||
if (parsedOption === null) return false;
|
||||
cursor = parsedOption;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function consumeSuppressionOption(
|
||||
manager: PackageManager,
|
||||
tokens: readonly string[],
|
||||
index: number,
|
||||
state: SuppressionState,
|
||||
): Readonly<{ recognized: boolean; unsupported: boolean; nextIndex: number }> {
|
||||
const token = tokens[index]!;
|
||||
if (token === "--no-ignore-scripts") {
|
||||
recordSuppression(state, false);
|
||||
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
||||
}
|
||||
const equalsForms = ["--ignore-scripts=", "--config.ignore-scripts="] as const;
|
||||
for (const prefix of equalsForms) {
|
||||
if (!token.startsWith(prefix)) continue;
|
||||
if (prefix.startsWith("--config.") && manager !== "pnpm") {
|
||||
state.malformed = true;
|
||||
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
||||
}
|
||||
const raw = token.slice(prefix.length);
|
||||
if (raw !== "true" && raw !== "false") {
|
||||
state.malformed = true;
|
||||
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
||||
}
|
||||
recordSuppression(state, raw === "true");
|
||||
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
||||
}
|
||||
if (token !== "--ignore-scripts" && token !== "--config.ignore-scripts") {
|
||||
return { recognized: false, unsupported: false, nextIndex: index };
|
||||
}
|
||||
if (token === "--config.ignore-scripts" && manager !== "pnpm") {
|
||||
state.malformed = true;
|
||||
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
||||
}
|
||||
const next = tokens[index + 1];
|
||||
if (next === "true" || next === "false") {
|
||||
const supportsSplitValue = manager === "npm" || manager === "pnpm";
|
||||
if (!supportsSplitValue) {
|
||||
state.malformed = true;
|
||||
return { recognized: true, unsupported: true, nextIndex: index + 2 };
|
||||
}
|
||||
recordSuppression(state, next === "true");
|
||||
return { recognized: true, unsupported: false, nextIndex: index + 2 };
|
||||
}
|
||||
recordSuppression(state, true);
|
||||
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
||||
}
|
||||
|
||||
function recordSuppression(
|
||||
state: SuppressionState,
|
||||
value: boolean,
|
||||
): void {
|
||||
if (state.effective !== undefined && state.effective !== value) {
|
||||
state.contradictory = true;
|
||||
}
|
||||
state.effective = value;
|
||||
}
|
||||
|
||||
function hasEffectiveLifecycleSuppression(state: SuppressionState): boolean {
|
||||
return state.effective === true && !state.contradictory && !state.malformed;
|
||||
}
|
||||
|
||||
function consumeAllowedLifecycleOption(
|
||||
manager: PackageManager,
|
||||
tokens: readonly string[],
|
||||
index: number,
|
||||
): number | null {
|
||||
const option = tokens[index]!;
|
||||
const equals = option.indexOf("=");
|
||||
const name = equals < 0 ? option : option.slice(0, equals);
|
||||
const booleanOption =
|
||||
managerBooleanOptions.has(name) || lifecycleBooleanOptions[manager].has(name);
|
||||
if (booleanOption) {
|
||||
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) return null;
|
||||
return index + 1;
|
||||
}
|
||||
const valuedOption =
|
||||
managerOptionsWithValue.has(name) || lifecycleOptionsWithValue[manager].has(name);
|
||||
if (!valuedOption) return null;
|
||||
if (equals >= 0) return option.slice(equals + 1).length > 0 ? index + 1 : null;
|
||||
const value = tokens[index + 1];
|
||||
if (!value || value.startsWith("-")) return null;
|
||||
return index + 2;
|
||||
}
|
||||
|
||||
function dependencyResult(dependency: string): ManagerParseResult {
|
||||
return dependenciesResult([dependency]);
|
||||
}
|
||||
|
||||
function npmScriptDependencyResult(
|
||||
dependency: string,
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
suppression: SuppressionState,
|
||||
): ManagerParseResult {
|
||||
if (hasEffectiveLifecycleSuppression(suppression)) {
|
||||
return dependenciesResult([dependency]);
|
||||
}
|
||||
return dependenciesResult(
|
||||
[`pre${dependency}`, dependency, `post${dependency}`]
|
||||
.filter((scriptName) => scriptName === dependency || scriptName in scripts),
|
||||
);
|
||||
}
|
||||
|
||||
function dependenciesResult(dependencies: readonly string[]): ManagerParseResult {
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([...dependencies]),
|
||||
unsafeLifecycle: false,
|
||||
unsupportedManagerSyntax: false,
|
||||
});
|
||||
}
|
||||
|
||||
function emptyResult(): ManagerParseResult {
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([]),
|
||||
unsafeLifecycle: false,
|
||||
unsupportedManagerSyntax: false,
|
||||
});
|
||||
}
|
||||
|
||||
function unsupportedResult(): ManagerParseResult {
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([]),
|
||||
unsafeLifecycle: false,
|
||||
unsupportedManagerSyntax: true,
|
||||
});
|
||||
}
|
||||
|
||||
function isPackageManager(value: string): value is PackageManager {
|
||||
return managerNames.has(value as PackageManager);
|
||||
}
|
||||
|
||||
function containsManagerReference(value: string): boolean {
|
||||
return /(?:^|[^A-Za-z0-9_-])(?:corepack|pnpm|npm|yarn)(?:[^A-Za-z0-9_-]|$)/u
|
||||
.test(value);
|
||||
}
|
||||
|
||||
function tokenizeShellSegments(command: string): Readonly<{
|
||||
segments: readonly TokenizedShellSegment[];
|
||||
unsupportedControl: boolean;
|
||||
}> | null {
|
||||
const segments: Array<{ tokens: string[]; expansionTokens: boolean[] }> = [
|
||||
{ tokens: [], expansionTokens: [] },
|
||||
];
|
||||
let token = "";
|
||||
let tokenHasExpansion = false;
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaping = false;
|
||||
let unsupportedControl = false;
|
||||
const pushToken = (): void => {
|
||||
if (token.length > 0) {
|
||||
segments.at(-1)!.tokens.push(token);
|
||||
segments.at(-1)!.expansionTokens.push(tokenHasExpansion);
|
||||
}
|
||||
token = "";
|
||||
tokenHasExpansion = false;
|
||||
};
|
||||
const pushSegment = (): void => {
|
||||
pushToken();
|
||||
if (segments.at(-1)!.tokens.length > 0) {
|
||||
segments.push({ tokens: [], expansionTokens: [] });
|
||||
}
|
||||
};
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const character = command[index]!;
|
||||
if (escaping) {
|
||||
token += character;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\" && quote !== "'") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (character === quote) quote = null;
|
||||
else {
|
||||
if (quote === '"' && character === "$") tokenHasExpansion = true;
|
||||
token += character;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === "`" || (character === "$" && command[index + 1] === "(")) {
|
||||
unsupportedControl = true;
|
||||
if (character === "$") tokenHasExpansion = true;
|
||||
token += character;
|
||||
continue;
|
||||
}
|
||||
if (character === "$" || character === "*" || character === "?" || character === "[") {
|
||||
tokenHasExpansion = true;
|
||||
}
|
||||
if (character === "#") {
|
||||
unsupportedControl = true;
|
||||
pushToken();
|
||||
while (
|
||||
index + 1 < command.length &&
|
||||
command[index + 1] !== "\n" &&
|
||||
command[index + 1] !== "\r"
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "<" || character === ">" || character === "(" || character === ")") {
|
||||
unsupportedControl = true;
|
||||
token += character;
|
||||
continue;
|
||||
}
|
||||
if (/\s/u.test(character)) {
|
||||
pushToken();
|
||||
if (character === "\n" || character === "\r") pushSegment();
|
||||
continue;
|
||||
}
|
||||
if (character === ";" || character === "|" || character === "&") {
|
||||
pushSegment();
|
||||
if (command[index + 1] === character) index += 1;
|
||||
continue;
|
||||
}
|
||||
token += character;
|
||||
}
|
||||
if (quote || escaping) return null;
|
||||
pushToken();
|
||||
return Object.freeze({
|
||||
segments: Object.freeze(
|
||||
segments
|
||||
.filter((segment) => segment.tokens.length > 0)
|
||||
.map((segment) => Object.freeze({
|
||||
tokens: Object.freeze(segment.tokens),
|
||||
expansionTokens: Object.freeze(segment.expansionTokens),
|
||||
})),
|
||||
),
|
||||
unsupportedControl,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
randomBytes as cryptoRandomBytes,
|
||||
} from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rm,
|
||||
rmdir,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
PROMOTED_FILE_NAMES,
|
||||
type PromotedFileName,
|
||||
} from "../contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
assertDistinctProviderTrust,
|
||||
providerPublicKeyFingerprint,
|
||||
providerVerificationArtifactSchema,
|
||||
PROMOTION_VERIFIER_ID,
|
||||
PROMOTION_VERIFIER_VERSION,
|
||||
provenanceProviderAttestationSchema,
|
||||
trustPolicySha256,
|
||||
vulnerabilityProviderReportSchema,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import { verifyExactPromotionBundle } from "./exact-promotion-bundle.ts";
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
} from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
|
||||
|
||||
export type StagedFile = Readonly<{
|
||||
name: PromotedFileName;
|
||||
bytes: Buffer;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export type FinalizedPromotion = Readonly<{
|
||||
stagingRoot: string;
|
||||
cleanupToken: string;
|
||||
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
stagingIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
|
||||
}>;
|
||||
|
||||
export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
repositoryRoot: string;
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
vulnerabilityReportPath: string;
|
||||
provenanceAttestationPath: string;
|
||||
vulnerabilityPublicKeyPath: string;
|
||||
vulnerabilityKeyId: string;
|
||||
provenancePublicKeyPath: string;
|
||||
provenanceKeyId: string;
|
||||
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
||||
vulnerabilityInvocationNonce: string;
|
||||
provenanceInvocationNonce: string;
|
||||
runnerTempRoot: string;
|
||||
}>, dependencies: Readonly<{
|
||||
captureArchive?: typeof captureCiCandidateArchive;
|
||||
nowEpochMs?: () => number;
|
||||
randomBytes?: (bytes: number) => Buffer;
|
||||
afterCapture?: () => Promise<void>;
|
||||
beforePublish?: () => Promise<void>;
|
||||
afterStagingWrite?: () => Promise<void>;
|
||||
afterFileWrite?: (name: PromotedFileName) => Promise<void>;
|
||||
beforeSeal?: () => Promise<void>;
|
||||
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>;
|
||||
}> = {}): Promise<FinalizedPromotion> {
|
||||
const root = path.resolve(input.repositoryRoot);
|
||||
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||
archivePath: input.archivePath,
|
||||
expectedSha256: input.expectedArchiveSha256,
|
||||
});
|
||||
const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] =
|
||||
await Promise.all([
|
||||
capture(root, input.vulnerabilityReportPath, 16_777_216),
|
||||
capture(root, input.provenanceAttestationPath, 16_777_216),
|
||||
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
|
||||
capture(root, input.provenancePublicKeyPath, 1_048_576),
|
||||
]);
|
||||
await dependencies.afterCapture?.();
|
||||
|
||||
const vulnerabilityTrust = capturedTrust(
|
||||
input.vulnerabilityKeyId,
|
||||
vulnerabilityKeyBytes,
|
||||
);
|
||||
const provenanceTrust = capturedTrust(
|
||||
input.provenanceKeyId,
|
||||
provenanceKeyBytes,
|
||||
);
|
||||
assertDistinctProviderTrust({ vulnerabilityTrust, provenanceTrust });
|
||||
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
|
||||
parseJson(vulnerabilityBytes),
|
||||
);
|
||||
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
|
||||
parseJson(provenanceBytes),
|
||||
);
|
||||
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
|
||||
|
||||
const generated = await withVerifiedCapturedCandidate({
|
||||
captured: capturedArchive,
|
||||
verify: async ({ extractionRoot, manifest }) => {
|
||||
const local = await verifyArchivedLocalEvidence({
|
||||
extractionRoot,
|
||||
expectedManifest: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || !local.identity) {
|
||||
throw new Error(
|
||||
`captured local evidence failed final verification: ${local.failures.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
||||
throw new Error("captured source revision differs from expected promotion revision");
|
||||
}
|
||||
const expected = {
|
||||
run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt },
|
||||
source: {
|
||||
revision: local.identity.sourceRevision,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
},
|
||||
candidate: {
|
||||
archiveSha256: capturedArchive.archiveSha256,
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
},
|
||||
secretScanAttestation: {
|
||||
status: "PASS" as const,
|
||||
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
policySha256: local.identity.secretScan.policySha256,
|
||||
sarifSha256: local.identity.secretScan.sarifSha256,
|
||||
scanInputSha256: local.identity.secretScan.scanInputSha256,
|
||||
},
|
||||
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
||||
} as const;
|
||||
const reevaluated = evaluatePromotionEvidence({
|
||||
expected,
|
||||
localStatus: local.status,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
nowEpochMs,
|
||||
});
|
||||
if (reevaluated.status !== "PASS") {
|
||||
throw new Error(
|
||||
`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const providerEvidence = {
|
||||
vulnerabilityReportSha256: sha256(vulnerabilityBytes),
|
||||
provenanceAttestationSha256: sha256(provenanceBytes),
|
||||
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
||||
vulnerabilityKeyId: vulnerabilityTrust.keyId,
|
||||
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
|
||||
provenanceKeyId: provenanceTrust.keyId,
|
||||
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
|
||||
secretScanAttestation: expected.secretScanAttestation,
|
||||
} as const;
|
||||
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
||||
const verifiedAt = new Date(nowEpochMs()).toISOString();
|
||||
const common = {
|
||||
schemaVersion: 3 as const,
|
||||
verifiedAt,
|
||||
status: "PASS" as const,
|
||||
verifier: {
|
||||
id: PROMOTION_VERIFIER_ID,
|
||||
version: PROMOTION_VERIFIER_VERSION,
|
||||
},
|
||||
run: expected.run,
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
providerEvidence,
|
||||
trustPolicySha256: trustDigest,
|
||||
failures: [] as const,
|
||||
};
|
||||
const providerRecord = providerVerificationArtifactSchema.parse({
|
||||
...common,
|
||||
artifactType: "provider-verification",
|
||||
vulnerabilityStatus: reevaluated.vulnerabilityStatus,
|
||||
provenanceAttestationStatus: reevaluated.provenanceAttestationStatus,
|
||||
});
|
||||
const providerRecordBytes = canonicalJsonBytes(providerRecord);
|
||||
const promotionRecord = providerVerificationArtifactSchema.parse({
|
||||
...common,
|
||||
artifactType: "promotion-verification",
|
||||
localEvidenceStatus: local.status,
|
||||
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
||||
providerVerificationSha256: sha256(providerRecordBytes),
|
||||
});
|
||||
return Object.freeze({
|
||||
providerRecordBytes,
|
||||
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
|
||||
exactExpected: Object.freeze({
|
||||
run: expected.run,
|
||||
sourceRevision: expected.source.revision,
|
||||
sourceSetSha256: expected.source.sourceSetSha256,
|
||||
archiveSha256: expected.candidate.archiveSha256,
|
||||
bundleSha256: expected.candidate.bundleSha256,
|
||||
distSha256: expected.candidate.distSha256,
|
||||
lockfileSha256: expected.candidate.lockfileSha256,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const stagedFiles: readonly StagedFile[] = Object.freeze([
|
||||
staged("release-candidate.tar.gz", capturedArchive.bytes),
|
||||
staged("vulnerability-report.json", vulnerabilityBytes),
|
||||
staged("provenance-attestation.json", provenanceBytes),
|
||||
staged("provider-verification.json", generated.providerRecordBytes),
|
||||
staged("promotion-verification.json", generated.promotionRecordBytes),
|
||||
]);
|
||||
if (
|
||||
JSON.stringify(stagedFiles.map(({ name }) => name)) !==
|
||||
JSON.stringify(PROMOTED_FILE_NAMES)
|
||||
) {
|
||||
throw new Error("promotion exact-five canonical file order drift");
|
||||
}
|
||||
await dependencies.beforePublish?.();
|
||||
await verifyExactPromotionBundle(
|
||||
Object.fromEntries(stagedFiles.map(({ name, bytes }) => [name, bytes])),
|
||||
{
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
expected: generated.exactExpected,
|
||||
nowEpochMs,
|
||||
},
|
||||
);
|
||||
return publishPrivatePromotionStaging(
|
||||
input.runnerTempRoot,
|
||||
input.expectedRun,
|
||||
stagedFiles,
|
||||
dependencies.randomBytes ?? cryptoRandomBytes,
|
||||
dependencies.afterStagingWrite,
|
||||
dependencies.afterFileWrite,
|
||||
async (capturedFiles) => {
|
||||
await verifyExactPromotionBundle(
|
||||
capturedFiles,
|
||||
{
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
expected: generated.exactExpected,
|
||||
nowEpochMs,
|
||||
},
|
||||
);
|
||||
},
|
||||
dependencies.afterMkdirBeforeOpen,
|
||||
dependencies.beforeSeal,
|
||||
);
|
||||
}
|
||||
|
||||
export const stageVerifiedPromotion = finalizeVerifiedPromotion;
|
||||
|
||||
export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
runnerTempRoot: string;
|
||||
stagingRoot: string;
|
||||
cleanupToken: string;
|
||||
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
stagingIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
}>, dependencies: Readonly<{
|
||||
beforeRemove?: () => Promise<void>;
|
||||
}> = {}): Promise<void> {
|
||||
const parent = path.resolve(input.runnerTempRoot);
|
||||
const expected = path.join(parent, input.cleanupToken);
|
||||
if (
|
||||
!/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) ||
|
||||
path.resolve(input.stagingRoot) !== expected ||
|
||||
!Number.isSafeInteger(input.runnerTempIdentity.dev) ||
|
||||
input.runnerTempIdentity.dev <= 0 ||
|
||||
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
|
||||
input.runnerTempIdentity.ino <= 0
|
||||
|| !Number.isSafeInteger(input.stagingIdentity.dev)
|
||||
|| input.stagingIdentity.dev <= 0
|
||||
|| !Number.isSafeInteger(input.stagingIdentity.ino)
|
||||
|| input.stagingIdentity.ino <= 0
|
||||
) {
|
||||
throw new TypeError("promotion cleanup root/token mismatch");
|
||||
}
|
||||
const parentHandle = await open(
|
||||
parent,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const openedParent = await parentHandle.stat();
|
||||
assertRunnerTempIdentity(openedParent, input.runnerTempIdentity);
|
||||
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
|
||||
const descriptorMetadata = await stat(descriptorRoot);
|
||||
if (!descriptorMetadata.isDirectory()) {
|
||||
throw new Error("descriptor-relative cleanup is unavailable");
|
||||
}
|
||||
const descriptorExpected = path.join(descriptorRoot, input.cleanupToken);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await lstat(descriptorExpected);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new TypeError("promotion cleanup leaf is unsafe");
|
||||
}
|
||||
assertStagingIdentity(metadata, input.stagingIdentity);
|
||||
const stagingHandle = await open(
|
||||
descriptorExpected,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
|
||||
const names = (await readdir(stagingDescriptorRoot)).sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(names) !==
|
||||
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("promotion cleanup leaf does not contain the exact five files");
|
||||
}
|
||||
await dependencies.beforeRemove?.();
|
||||
const visibleParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
await rm(path.join(stagingDescriptorRoot, name), { force: false });
|
||||
}
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
|
||||
assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity);
|
||||
await rmdir(descriptorExpected);
|
||||
} finally {
|
||||
await stagingHandle.close();
|
||||
}
|
||||
const afterParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
|
||||
} finally {
|
||||
await parentHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishPrivatePromotionStaging(
|
||||
runnerTempRoot: string,
|
||||
run: Readonly<{ id: string; attempt: number }>,
|
||||
files: readonly StagedFile[],
|
||||
randomBytes: (bytes: number) => Buffer,
|
||||
afterStagingWrite?: () => Promise<void>,
|
||||
afterFileWrite?: (name: PromotedFileName) => Promise<void>,
|
||||
sealStagedFiles?: (files: Readonly<Record<PromotedFileName, Buffer>>) => Promise<void>,
|
||||
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>,
|
||||
beforeSeal?: () => Promise<void>,
|
||||
): Promise<FinalizedPromotion> {
|
||||
if (
|
||||
JSON.stringify(files.map(({ name }) => name)) !==
|
||||
JSON.stringify(PROMOTED_FILE_NAMES) ||
|
||||
files.some(
|
||||
({ bytes, sha256: digest }) =>
|
||||
!Buffer.isBuffer(bytes) ||
|
||||
!/^[a-f0-9]{64}$/u.test(digest) ||
|
||||
sha256(bytes) !== digest,
|
||||
)
|
||||
) {
|
||||
throw new TypeError("private promotion staging requires the canonical exact-five bytes");
|
||||
}
|
||||
const parentPath = path.resolve(runnerTempRoot);
|
||||
const before = await lstat(parentPath);
|
||||
if (!before.isDirectory() || before.isSymbolicLink()) {
|
||||
throw new TypeError("runner temporary root must be a real directory");
|
||||
}
|
||||
const parentHandle = await open(
|
||||
parentPath,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
const tokenBytes = randomBytes(16);
|
||||
if (tokenBytes.byteLength !== 16) {
|
||||
await parentHandle.close();
|
||||
throw new TypeError("promotion staging nonce must contain exactly 128 random bits");
|
||||
}
|
||||
const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run";
|
||||
const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`;
|
||||
const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`;
|
||||
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
|
||||
const visibleStaging = path.join(parentPath, cleanupToken);
|
||||
let ownsStaging = false;
|
||||
let stagingHandle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
let createdStagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
|
||||
let stagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
|
||||
let openedIdentityVerified = false;
|
||||
const cleanup = async (primaryFailure?: unknown): Promise<void> => {
|
||||
const cleanupFailures: unknown[] = [];
|
||||
const attemptCleanup = async (operation: () => Promise<void>): Promise<void> => {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
cleanupFailures.push(error);
|
||||
}
|
||||
};
|
||||
if (ownsStaging && openedIdentityVerified && stagingHandle && stagingIdentity) {
|
||||
const ownedIdentity = stagingIdentity;
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
const removals = await Promise.allSettled(
|
||||
files.map(({ name }) => rm(path.join(stagingDescriptorRoot, name), { force: true })),
|
||||
);
|
||||
cleanupFailures.push(
|
||||
...removals.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
),
|
||||
);
|
||||
await attemptCleanup(async () => {
|
||||
let visible;
|
||||
try {
|
||||
visible = await lstat(descriptorStaging);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
visible.isDirectory() &&
|
||||
!visible.isSymbolicLink() &&
|
||||
visible.dev === ownedIdentity.dev &&
|
||||
visible.ino === ownedIdentity.ino
|
||||
) {
|
||||
await rmdir(descriptorStaging);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (stagingHandle) {
|
||||
const ownedHandle = stagingHandle;
|
||||
await attemptCleanup(async () => ownedHandle.close());
|
||||
}
|
||||
await attemptCleanup(async () => parentHandle.close());
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError(
|
||||
primaryFailure === undefined
|
||||
? cleanupFailures
|
||||
: [primaryFailure, ...cleanupFailures],
|
||||
primaryFailure instanceof Error
|
||||
? `${primaryFailure.message}; promotion staging cleanup also failed`
|
||||
: "promotion staging cleanup failed",
|
||||
{ cause: cleanupFailures.at(-1) },
|
||||
);
|
||||
}
|
||||
};
|
||||
let finalizedPromotion: FinalizedPromotion;
|
||||
try {
|
||||
const procMetadata = await stat(descriptorRoot);
|
||||
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
|
||||
await mkdir(descriptorStaging, { mode: 0o700 });
|
||||
ownsStaging = true;
|
||||
const createdStaging = await lstat(descriptorStaging);
|
||||
if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) {
|
||||
throw new Error("created promotion staging leaf is unsafe");
|
||||
}
|
||||
createdStagingIdentity = Object.freeze({
|
||||
dev: createdStaging.dev,
|
||||
ino: createdStaging.ino,
|
||||
});
|
||||
await afterMkdirBeforeOpen?.(visibleStaging);
|
||||
const openedHandle = await open(
|
||||
descriptorStaging,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
let openedStaging;
|
||||
try {
|
||||
openedStaging = await openedHandle.stat();
|
||||
if (!openedStaging.isDirectory()) {
|
||||
throw new Error("promotion staging descriptor is not a directory");
|
||||
}
|
||||
if (
|
||||
openedStaging.dev !== createdStagingIdentity.dev ||
|
||||
openedStaging.ino !== createdStagingIdentity.ino
|
||||
) {
|
||||
throw new Error("promotion staging leaf identity changed between mkdir and open");
|
||||
}
|
||||
openedIdentityVerified = true;
|
||||
} catch (error) {
|
||||
try {
|
||||
await openedHandle.close();
|
||||
} catch (closeError) {
|
||||
throw new AggregateError(
|
||||
[error, closeError],
|
||||
error instanceof Error
|
||||
? `${error.message}; rejected staging descriptor close also failed`
|
||||
: "rejected staging descriptor and close both failed",
|
||||
{ cause: closeError },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
stagingHandle = openedHandle;
|
||||
await stagingHandle.chmod(0o700);
|
||||
stagingIdentity = Object.freeze({ dev: openedStaging.dev, ino: openedStaging.ino });
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), stagingIdentity);
|
||||
for (const file of files) {
|
||||
const handle = await open(
|
||||
path.join(stagingDescriptorRoot, file.name),
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_NOFOLLOW,
|
||||
0o400,
|
||||
);
|
||||
try {
|
||||
await handle.chmod(0o400);
|
||||
await handle.writeFile(file.bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await afterFileWrite?.(file.name);
|
||||
}
|
||||
await syncHandle(stagingHandle);
|
||||
await syncHandle(parentHandle);
|
||||
await afterStagingWrite?.();
|
||||
await beforeSeal?.();
|
||||
const capturedFiles = await captureStagedFiles(stagingHandle, files);
|
||||
await sealStagedFiles?.(capturedFiles);
|
||||
const after = await lstat(parentPath);
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.isSymbolicLink() ||
|
||||
!after.isDirectory()
|
||||
) {
|
||||
throw new Error("runner temporary parent identity changed during staging");
|
||||
}
|
||||
const visible = await lstat(visibleStaging);
|
||||
if (!visible.isDirectory() || visible.isSymbolicLink()) {
|
||||
throw new Error("promotion staging visibility identity mismatch");
|
||||
}
|
||||
assertStagingIdentity(visible, stagingIdentity);
|
||||
ownsStaging = false;
|
||||
finalizedPromotion = Object.freeze({
|
||||
stagingRoot: visibleStaging,
|
||||
cleanupToken,
|
||||
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
|
||||
stagingIdentity,
|
||||
files: Object.freeze(
|
||||
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
await cleanup(error);
|
||||
throw error;
|
||||
}
|
||||
await cleanup();
|
||||
return finalizedPromotion;
|
||||
}
|
||||
|
||||
async function captureStagedFiles(
|
||||
stagingHandle: Awaited<ReturnType<typeof open>>,
|
||||
declaredFiles: readonly StagedFile[],
|
||||
): Promise<Readonly<Record<PromotedFileName, Buffer>>> {
|
||||
const descriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
const names = (await readdir(descriptorRoot)).sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(names) !==
|
||||
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("staged promotion seal requires exactly the canonical five files");
|
||||
}
|
||||
const declared = new Map(declaredFiles.map((file) => [file.name, file] as const));
|
||||
const captured = {} as Record<PromotedFileName, Buffer>;
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
const expected = declared.get(name)!;
|
||||
const handle = await open(
|
||||
path.join(descriptorRoot, name),
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
const maxBytes = name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216;
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
(before.mode & 0o777) !== 0o400 ||
|
||||
before.size <= 0 ||
|
||||
before.size > maxBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`staged promotion file must be regular, single-link, bounded, and mode 0400: ${name}`,
|
||||
);
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.nlink !== 1 ||
|
||||
(after.mode & 0o777) !== 0o400 ||
|
||||
bytes.byteLength !== before.size
|
||||
) {
|
||||
throw new Error(`staged promotion file inode or size changed during seal: ${name}`);
|
||||
}
|
||||
if (sha256(bytes) !== expected.sha256) {
|
||||
throw new Error(`staged promotion file digest mismatch during seal: ${name}`);
|
||||
}
|
||||
captured[name] = bytes;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
return Object.freeze(captured);
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function assertStagingIdentity(
|
||||
metadata: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
isDirectory: () => boolean;
|
||||
isSymbolicLink?: () => boolean;
|
||||
}>,
|
||||
expected: Readonly<{ dev: number; ino: number }>,
|
||||
): void {
|
||||
if (
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino ||
|
||||
!metadata.isDirectory() ||
|
||||
metadata.isSymbolicLink?.()
|
||||
) {
|
||||
throw new Error("promotion staging leaf identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
function assertRunnerTempIdentity(
|
||||
metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>,
|
||||
expected: Readonly<{ dev: number; ino: number }>,
|
||||
): void {
|
||||
if (
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino ||
|
||||
!metadata.isDirectory() ||
|
||||
metadata.isSymbolicLink?.()
|
||||
) {
|
||||
throw new Error("runner temporary parent identity changed during cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust {
|
||||
const publicKey = createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||
);
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
async function capture(root: string, configuredPath: string, maxBytes: number): Promise<Buffer> {
|
||||
const absolute = path.resolve(root, configuredPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
const outside =
|
||||
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||
return readBoundedRegularFile({
|
||||
root: outside ? path.dirname(absolute) : root,
|
||||
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
|
||||
maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function staged(name: PromotedFileName, bytes: Buffer): StagedFile {
|
||||
return Object.freeze({ name, bytes, sha256: sha256(bytes) });
|
||||
}
|
||||
|
||||
function canonicalJsonBytes(value: unknown): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): unknown {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
} catch {
|
||||
throw new TypeError("captured provider evidence is not valid UTF-8 JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
|
||||
try {
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
export type ProviderKind = "vulnerability" | "provenance";
|
||||
|
||||
const MEMORY_MAX = 1_073_741_824;
|
||||
const TASKS_MAX = 64;
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const RUNTIME_GRACE_MS = 10_000;
|
||||
const UNIT_NAME = /^ca-provider-(?:vulnerability|provenance)-[1-9][0-9]*-[0-9a-f]{24}\.scope$/u;
|
||||
const UNIT_NONCE = /^[0-9a-f]{24}$/u;
|
||||
const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
||||
const ENFORCEMENT_GATE = [
|
||||
'cgroup_path=""',
|
||||
"while IFS=: read -r hierarchy controllers candidate; do",
|
||||
' if [ "$hierarchy" = 0 ] && [ -z "$controllers" ]; then cgroup_path=$candidate; fi',
|
||||
"done < /proc/self/cgroup",
|
||||
'if [ -z "$cgroup_path" ]; then',
|
||||
" printf '%s\\n' 'provider cgroup enforcement failed: unified cgroup v2 membership is required' >&2",
|
||||
" exit 125",
|
||||
"fi",
|
||||
'case "$cgroup_path" in',
|
||||
' */"$0") ;;',
|
||||
" *)",
|
||||
" printf '%s\\n' 'provider cgroup enforcement failed: unit membership is invalid' >&2",
|
||||
" exit 125",
|
||||
" ;;",
|
||||
"esac",
|
||||
"cgroup_root=/sys/fs/cgroup$cgroup_path",
|
||||
"require_cgroup_value() {",
|
||||
' actual=$(/bin/cat "$cgroup_root/$1") || {',
|
||||
" printf 'provider cgroup enforcement failed: cannot read %s\\n' \"$1\" >&2",
|
||||
" exit 125",
|
||||
" }",
|
||||
' if [ "$actual" != "$2" ]; then',
|
||||
" printf 'provider cgroup enforcement failed: %s is %s, expected %s\\n' \"$1\" \"$actual\" \"$2\" >&2",
|
||||
" exit 125",
|
||||
" fi",
|
||||
"}",
|
||||
`require_cgroup_value memory.max ${MEMORY_MAX}`,
|
||||
"require_cgroup_value memory.swap.max 0",
|
||||
`require_cgroup_value pids.max ${TASKS_MAX}`,
|
||||
"require_cgroup_value cpu.max '100000 100000'",
|
||||
'exec "$@"',
|
||||
].join("\n");
|
||||
|
||||
export function formatProviderCgroupUnitName(
|
||||
kind: ProviderKind,
|
||||
supervisorPid: number,
|
||||
nonce: string,
|
||||
): string {
|
||||
if (!Number.isSafeInteger(supervisorPid) || supervisorPid <= 0 || !UNIT_NONCE.test(nonce)) {
|
||||
throw new TypeError("provider cgroup unit identity is invalid");
|
||||
}
|
||||
const unit = `ca-provider-${kind}-${supervisorPid}-${nonce}.scope`;
|
||||
assertUnit(unit);
|
||||
return unit;
|
||||
}
|
||||
|
||||
export function systemdRunProviderArguments(
|
||||
unit: string,
|
||||
timeoutMs: number,
|
||||
cpuSeconds: number,
|
||||
nodeExecutable: string,
|
||||
wrapperScript: string,
|
||||
reportPath: string,
|
||||
reportDev: number,
|
||||
reportIno: number,
|
||||
): string[] {
|
||||
assertUnit(unit);
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > Number.MAX_SAFE_INTEGER - RUNTIME_GRACE_MS) {
|
||||
throw new TypeError("provider cgroup runtime is invalid");
|
||||
}
|
||||
if (!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0) {
|
||||
throw new TypeError("provider cgroup CPU limit is invalid");
|
||||
}
|
||||
if (
|
||||
!nodeExecutable.startsWith("/") || !wrapperScript.startsWith("/") ||
|
||||
!reportPath.startsWith("/") || reportPath.includes("\0") ||
|
||||
!Number.isSafeInteger(reportDev) || reportDev <= 0 ||
|
||||
!Number.isSafeInteger(reportIno) || reportIno <= 0
|
||||
) {
|
||||
throw new TypeError("provider scope wrapper path is invalid");
|
||||
}
|
||||
return [
|
||||
"--user",
|
||||
"--scope",
|
||||
"--collect",
|
||||
"--quiet",
|
||||
"--expand-environment=no",
|
||||
`--unit=${unit}`,
|
||||
`--property=MemoryMax=${MEMORY_MAX}`,
|
||||
"--property=MemorySwapMax=0",
|
||||
`--property=TasksMax=${TASKS_MAX}`,
|
||||
"--property=CPUQuota=100%",
|
||||
"--property=CPUQuotaPeriodSec=100ms",
|
||||
"--property=KillMode=control-group",
|
||||
"--property=SendSIGKILL=yes",
|
||||
`--property=TimeoutStopSec=${STOP_TIMEOUT_MS}ms`,
|
||||
`--property=RuntimeMaxSec=${timeoutMs + RUNTIME_GRACE_MS}ms`,
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-eu",
|
||||
"-c",
|
||||
ENFORCEMENT_GATE,
|
||||
unit,
|
||||
nodeExecutable,
|
||||
wrapperScript,
|
||||
String(cpuSeconds),
|
||||
reportPath,
|
||||
String(reportDev),
|
||||
String(reportIno),
|
||||
];
|
||||
}
|
||||
|
||||
export type ProviderScopeFrame = Readonly<{
|
||||
bwrapInput: Buffer;
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
}>;
|
||||
|
||||
export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
|
||||
if (
|
||||
!Buffer.isBuffer(input.bwrapInput) || input.bwrapInput.byteLength === 0 ||
|
||||
!input.reportPath.startsWith("/") || input.reportPath.includes("\0") ||
|
||||
!Number.isSafeInteger(input.reportDev) || input.reportDev <= 0 ||
|
||||
!Number.isSafeInteger(input.reportIno) || input.reportIno <= 0
|
||||
) {
|
||||
throw new TypeError("provider scope frame is invalid");
|
||||
}
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
bwrapInputBase64: input.bwrapInput.toString("base64"),
|
||||
reportPath: input.reportPath,
|
||||
reportDev: input.reportDev,
|
||||
reportIno: input.reportIno,
|
||||
}));
|
||||
const frame = Buffer.allocUnsafe(4 + payload.byteLength);
|
||||
frame.writeUInt32BE(payload.byteLength, 0);
|
||||
payload.copy(frame, 4);
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function encodeProviderBwrapInput(
|
||||
arguments_: readonly string[],
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Buffer {
|
||||
if (arguments_.some((argument) => argument.includes("\0"))) {
|
||||
throw new TypeError("provider bwrap argument is invalid");
|
||||
}
|
||||
const entries = Object.entries(environment).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
if (entries.some(([name, value]) =>
|
||||
!ENVIRONMENT_NAME.test(name) || value === undefined || value.includes("\0")
|
||||
)) {
|
||||
throw new TypeError("provider bwrap environment is invalid");
|
||||
}
|
||||
const input = ["--clearenv"];
|
||||
for (const [name, value] of entries) input.push("--setenv", name, value ?? "");
|
||||
input.push(...arguments_);
|
||||
return Buffer.from(`${input.join("\0")}\0`);
|
||||
}
|
||||
|
||||
export function systemctlKillProviderArguments(unit: string): string[] {
|
||||
assertUnit(unit);
|
||||
return ["--user", "kill", "--kill-whom=all", "--signal=SIGKILL", unit];
|
||||
}
|
||||
|
||||
function assertUnit(unit: string): void {
|
||||
if (!UNIT_NAME.test(unit)) throw new TypeError("provider cgroup unit name is invalid");
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { createHash, verify, type KeyObject } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
canonicalizeSupplyChainValue,
|
||||
supplyChainDigest,
|
||||
} from "./supply-chain.ts";
|
||||
|
||||
export const PROVIDER_FUTURE_SKEW_MS = 5 * 60 * 1_000;
|
||||
export const PROVIDER_MAX_LIFETIME_MS = 2 * 60 * 60 * 1_000;
|
||||
export const PROMOTION_VERIFIER_ID =
|
||||
"clean-architecture-frontend-template/promotion-verifier";
|
||||
export const PROMOTION_VERIFIER_VERSION = "3";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const fingerprint = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
|
||||
const revision = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u);
|
||||
const nonce = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const nonEmptyString = z.string().min(1);
|
||||
const timestamp = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u)
|
||||
.refine((value) => new Date(value).toISOString() === value);
|
||||
const runSchema = z
|
||||
.object({ id: z.string().min(1).max(128), attempt: z.int().min(1).max(1_000) })
|
||||
.strict();
|
||||
const sourceSchema = z
|
||||
.object({ revision, sourceSetSha256: sha256 })
|
||||
.strict();
|
||||
const candidateSchema = z
|
||||
.object({
|
||||
archiveSha256: sha256,
|
||||
bundleSha256: sha256,
|
||||
distSha256: sha256,
|
||||
lockfileSha256: sha256,
|
||||
})
|
||||
.strict();
|
||||
const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict();
|
||||
export const secretScanAttestationSchema = z
|
||||
.object({
|
||||
status: z.literal("PASS"),
|
||||
localEvidenceAssessmentSha256: sha256,
|
||||
sourceSetSha256: sha256,
|
||||
policySha256: sha256,
|
||||
sarifSha256: sha256,
|
||||
scanInputSha256: sha256,
|
||||
})
|
||||
.strict();
|
||||
const signatureSchema = z
|
||||
.object({
|
||||
algorithm: z.literal("Ed25519"),
|
||||
keyId: nonEmptyString,
|
||||
publicKeyFingerprint: fingerprint,
|
||||
value: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u),
|
||||
})
|
||||
.strict();
|
||||
const providerCommon = {
|
||||
schemaVersion: z.literal(2),
|
||||
provider: nonEmptyString,
|
||||
issuedAt: timestamp,
|
||||
expiresAt: timestamp,
|
||||
run: providerRunSchema,
|
||||
source: sourceSchema,
|
||||
candidate: candidateSchema,
|
||||
signature: signatureSchema,
|
||||
} as const;
|
||||
|
||||
export const vulnerabilityProviderReportSchema = z
|
||||
.object({
|
||||
...providerCommon,
|
||||
evidenceType: z.literal("vulnerability-report"),
|
||||
secretScanAttestation: secretScanAttestationSchema,
|
||||
findings: z.array(z.record(z.string(), z.json())),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const provenanceProviderAttestationSchema = z
|
||||
.object({
|
||||
...providerCommon,
|
||||
evidenceType: z.literal("provenance-attestation"),
|
||||
signer: nonEmptyString,
|
||||
subject: z
|
||||
.object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() })
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const verificationCommon = {
|
||||
schemaVersion: z.literal(3),
|
||||
verifiedAt: timestamp,
|
||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
verifier: z
|
||||
.object({ id: nonEmptyString, version: nonEmptyString })
|
||||
.strict(),
|
||||
run: runSchema,
|
||||
source: sourceSchema,
|
||||
candidate: candidateSchema,
|
||||
providerEvidence: z
|
||||
.object({
|
||||
vulnerabilityReportSha256: sha256,
|
||||
provenanceAttestationSha256: sha256,
|
||||
vulnerabilityInvocationNonce: nonce,
|
||||
provenanceInvocationNonce: nonce,
|
||||
vulnerabilityKeyId: nonEmptyString,
|
||||
vulnerabilityKeyFingerprint: fingerprint,
|
||||
provenanceKeyId: nonEmptyString,
|
||||
provenanceKeyFingerprint: fingerprint,
|
||||
secretScanAttestation: secretScanAttestationSchema,
|
||||
})
|
||||
.strict(),
|
||||
trustPolicySha256: sha256,
|
||||
failures: z.array(z.string()),
|
||||
} as const;
|
||||
|
||||
const providerVerificationV3Schema = z
|
||||
.object({
|
||||
...verificationCommon,
|
||||
artifactType: z.literal("provider-verification"),
|
||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const promotionVerificationV3Schema = z
|
||||
.object({
|
||||
...verificationCommon,
|
||||
artifactType: z.literal("promotion-verification"),
|
||||
localEvidenceStatus: z.enum(["PASS", "FAIL"]),
|
||||
localEvidenceAssessmentSha256: sha256,
|
||||
providerVerificationSha256: sha256,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerVerificationArtifactSchema = z
|
||||
.discriminatedUnion("artifactType", [
|
||||
providerVerificationV3Schema,
|
||||
promotionVerificationV3Schema,
|
||||
])
|
||||
.superRefine((record, context) => {
|
||||
const subordinatePass =
|
||||
record.artifactType === "provider-verification"
|
||||
? record.vulnerabilityStatus === "PASS" &&
|
||||
record.provenanceAttestationStatus === "PASS"
|
||||
: record.localEvidenceStatus === "PASS";
|
||||
const coherentPass = subordinatePass && record.failures.length === 0;
|
||||
if ((record.status === "PASS") !== coherentPass) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "verification PASS must agree with subordinate statuses and failures",
|
||||
});
|
||||
}
|
||||
if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["failures"],
|
||||
message: "failed verification requires a failure diagnostic",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ProviderVerificationArtifactType = z.infer<
|
||||
typeof providerVerificationArtifactSchema
|
||||
>["artifactType"];
|
||||
|
||||
export type ProviderTrust = Readonly<{
|
||||
keyId: string;
|
||||
publicKey: KeyObject;
|
||||
publicKeyFingerprint: string;
|
||||
}>;
|
||||
|
||||
export type ExpectedPromotionContext = Readonly<{
|
||||
run: Readonly<{ id: string; attempt: number }>;
|
||||
source: Readonly<{ revision: string; sourceSetSha256: string }>;
|
||||
candidate: Readonly<{
|
||||
archiveSha256: string;
|
||||
bundleSha256: string;
|
||||
distSha256: string;
|
||||
lockfileSha256: string;
|
||||
}>;
|
||||
vulnerabilityInvocationNonce: string;
|
||||
provenanceInvocationNonce: string;
|
||||
secretScanAttestation: z.infer<typeof secretScanAttestationSchema>;
|
||||
}>;
|
||||
|
||||
export type PromotionEvidenceResult = Readonly<{
|
||||
status: "PASS" | "FAIL_UNVERIFIED";
|
||||
vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED";
|
||||
provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED";
|
||||
failures: readonly string[];
|
||||
}>;
|
||||
|
||||
export function validateProviderEvidence(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
value: unknown;
|
||||
expected: ExpectedPromotionContext;
|
||||
trust: ProviderTrust | null;
|
||||
nowEpochMs?: () => number;
|
||||
}>): Readonly<{
|
||||
evidence: unknown | null;
|
||||
status: "PASS" | "FAIL_UNVERIFIED";
|
||||
failures: readonly string[];
|
||||
}> {
|
||||
const failures: string[] = [];
|
||||
const now = (input.nowEpochMs ?? Date.now)();
|
||||
if (input.kind === "vulnerability") {
|
||||
const parsed = vulnerabilityProviderReportSchema.safeParse(input.value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
evidence: null,
|
||||
status: "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze([
|
||||
"external vulnerability provider report is missing or invalid",
|
||||
]),
|
||||
});
|
||||
}
|
||||
validateCommonContext(
|
||||
"vulnerability report",
|
||||
parsed.data,
|
||||
input.expected,
|
||||
input.expected.vulnerabilityInvocationNonce,
|
||||
input.trust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (
|
||||
JSON.stringify(parsed.data.secretScanAttestation) !==
|
||||
JSON.stringify(input.expected.secretScanAttestation)
|
||||
) {
|
||||
failures.push("vulnerability report secret scan attestation mismatch");
|
||||
}
|
||||
if (parsed.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
return Object.freeze({
|
||||
evidence: parsed.data,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
const parsed = provenanceProviderAttestationSchema.safeParse(input.value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
evidence: null,
|
||||
status: "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze([
|
||||
"external signed provenance attestation is missing or invalid",
|
||||
]),
|
||||
});
|
||||
}
|
||||
validateCommonContext(
|
||||
"provenance attestation",
|
||||
parsed.data,
|
||||
input.expected,
|
||||
input.expected.provenanceInvocationNonce,
|
||||
input.trust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (parsed.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
||||
failures.push("provenance attestation subject dist digest mismatch");
|
||||
}
|
||||
return Object.freeze({
|
||||
evidence: parsed.data,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED",
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
|
||||
if (!isRecord(value)) return Buffer.from("null", "utf8");
|
||||
const { signature: _signature, ...payload } = value;
|
||||
return Buffer.from(JSON.stringify(canonicalizeSupplyChainValue(payload)), "utf8");
|
||||
}
|
||||
|
||||
export function providerPublicKeyFingerprint(publicKey: KeyObject): string {
|
||||
if (publicKey.asymmetricKeyType !== "ed25519") {
|
||||
throw new TypeError("provider trust key must be Ed25519");
|
||||
}
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
export function createTrustPolicy(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>) {
|
||||
assertDistinctProviderTrust(input);
|
||||
return Object.freeze({
|
||||
algorithm: "Ed25519" as const,
|
||||
vulnerability: Object.freeze({
|
||||
keyId: input.vulnerabilityTrust.keyId,
|
||||
publicKeyFingerprint: input.vulnerabilityTrust.publicKeyFingerprint,
|
||||
}),
|
||||
provenance: Object.freeze({
|
||||
keyId: input.provenanceTrust.keyId,
|
||||
publicKeyFingerprint: input.provenanceTrust.publicKeyFingerprint,
|
||||
}),
|
||||
issuedAtFutureSkewMs: PROVIDER_FUTURE_SKEW_MS,
|
||||
maximumLifetimeMs: PROVIDER_MAX_LIFETIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertDistinctProviderTrust(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>): void {
|
||||
const vulnerabilityFingerprint = providerPublicKeyFingerprint(
|
||||
input.vulnerabilityTrust.publicKey,
|
||||
);
|
||||
const provenanceFingerprint = providerPublicKeyFingerprint(
|
||||
input.provenanceTrust.publicKey,
|
||||
);
|
||||
if (
|
||||
input.vulnerabilityTrust.keyId === input.provenanceTrust.keyId ||
|
||||
vulnerabilityFingerprint === provenanceFingerprint ||
|
||||
input.vulnerabilityTrust.publicKeyFingerprint ===
|
||||
input.provenanceTrust.publicKeyFingerprint
|
||||
) {
|
||||
throw new TypeError("provider trust roles require distinct key identities and DER-SPKI fingerprints");
|
||||
}
|
||||
}
|
||||
|
||||
export function trustPolicySha256(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>): string {
|
||||
return supplyChainDigest(createTrustPolicy(input));
|
||||
}
|
||||
|
||||
export function evaluatePromotionEvidence(input: Readonly<{
|
||||
expected: ExpectedPromotionContext;
|
||||
localStatus: unknown;
|
||||
vulnerabilityReport: unknown;
|
||||
provenanceAttestation: unknown;
|
||||
vulnerabilityTrust: ProviderTrust | null;
|
||||
provenanceTrust: ProviderTrust | null;
|
||||
nowEpochMs?: () => number;
|
||||
}>): PromotionEvidenceResult {
|
||||
const failures: string[] = [];
|
||||
if (input.localStatus !== "PASS") {
|
||||
failures.push("local supply-chain evidence is not PASS");
|
||||
}
|
||||
const now = (input.nowEpochMs ?? Date.now)();
|
||||
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
|
||||
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
||||
input.vulnerabilityReport,
|
||||
);
|
||||
if (!vulnerability.success) {
|
||||
failures.push("external vulnerability provider report is missing or invalid");
|
||||
} else {
|
||||
const before = failures.length;
|
||||
validateCommonContext(
|
||||
"vulnerability report",
|
||||
vulnerability.data,
|
||||
input.expected,
|
||||
input.expected.vulnerabilityInvocationNonce,
|
||||
input.vulnerabilityTrust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (
|
||||
JSON.stringify(vulnerability.data.secretScanAttestation) !==
|
||||
JSON.stringify(input.expected.secretScanAttestation)
|
||||
) {
|
||||
failures.push("vulnerability report secret scan attestation mismatch");
|
||||
}
|
||||
if (vulnerability.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
if (failures.length === before && input.localStatus === "PASS") {
|
||||
vulnerabilityStatus = "PASS";
|
||||
}
|
||||
}
|
||||
|
||||
const provenance = provenanceProviderAttestationSchema.safeParse(
|
||||
input.provenanceAttestation,
|
||||
);
|
||||
if (!provenance.success) {
|
||||
failures.push("external signed provenance attestation is missing or invalid");
|
||||
} else {
|
||||
const before = failures.length;
|
||||
validateCommonContext(
|
||||
"provenance attestation",
|
||||
provenance.data,
|
||||
input.expected,
|
||||
input.expected.provenanceInvocationNonce,
|
||||
input.provenanceTrust,
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) {
|
||||
failures.push("provenance attestation subject dist digest mismatch");
|
||||
}
|
||||
if (failures.length === before && input.localStatus === "PASS") {
|
||||
provenanceAttestationStatus = "PASS";
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
status:
|
||||
failures.length === 0 &&
|
||||
vulnerabilityStatus === "PASS" &&
|
||||
provenanceAttestationStatus === "PASS"
|
||||
? "PASS"
|
||||
: "FAIL_UNVERIFIED",
|
||||
vulnerabilityStatus,
|
||||
provenanceAttestationStatus,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
function validateCommonContext(
|
||||
label: "vulnerability report" | "provenance attestation",
|
||||
evidence: z.infer<
|
||||
| typeof vulnerabilityProviderReportSchema
|
||||
| typeof provenanceProviderAttestationSchema
|
||||
>,
|
||||
expected: ExpectedPromotionContext,
|
||||
expectedNonce: string,
|
||||
trust: ProviderTrust | null,
|
||||
now: number,
|
||||
failures: string[],
|
||||
): void {
|
||||
if (
|
||||
evidence.run.id !== expected.run.id ||
|
||||
evidence.run.attempt !== expected.run.attempt
|
||||
) {
|
||||
failures.push(`${label} run identity mismatch`);
|
||||
}
|
||||
if (evidence.run.invocationNonce !== expectedNonce) {
|
||||
failures.push(`${label} invocation nonce mismatch`);
|
||||
}
|
||||
if (
|
||||
evidence.source.revision !== expected.source.revision ||
|
||||
evidence.source.sourceSetSha256 !== expected.source.sourceSetSha256
|
||||
) {
|
||||
failures.push(`${label} source identity mismatch`);
|
||||
}
|
||||
if (JSON.stringify(evidence.candidate) !== JSON.stringify(expected.candidate)) {
|
||||
failures.push(`${label} candidate identity mismatch`);
|
||||
}
|
||||
validateEvidenceTime(label, evidence.issuedAt, evidence.expiresAt, now, failures);
|
||||
if (
|
||||
!trust ||
|
||||
evidence.signature.keyId !== trust.keyId ||
|
||||
evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint
|
||||
) {
|
||||
failures.push(`${label} trust identity mismatch`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
providerPublicKeyFingerprint(trust.publicKey) !== trust.publicKeyFingerprint ||
|
||||
!verify(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(evidence),
|
||||
trust.publicKey,
|
||||
Buffer.from(evidence.signature.value, "base64"),
|
||||
)
|
||||
) {
|
||||
failures.push(`${label} signature verification failed`);
|
||||
}
|
||||
} catch {
|
||||
failures.push(`${label} signature verification failed`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidenceTime(
|
||||
label: string,
|
||||
issuedAt: string,
|
||||
expiresAt: string,
|
||||
now: number,
|
||||
failures: string[],
|
||||
): void {
|
||||
const issued = Date.parse(issuedAt);
|
||||
const expires = Date.parse(expiresAt);
|
||||
if (issued > now + PROVIDER_FUTURE_SKEW_MS) {
|
||||
failures.push(`${label} issuedAt exceeds allowed future skew`);
|
||||
}
|
||||
if (expires <= now) failures.push(`${label} is expired`);
|
||||
if (expires <= issued) failures.push(`${label} validity window is not positive`);
|
||||
if (expires - issued > PROVIDER_MAX_LIFETIME_MS) {
|
||||
failures.push(`${label} validity window exceeds two hours`);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, type FileHandle } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
decodeProviderGuardianPublished,
|
||||
decodeProviderGuardianReady,
|
||||
encodeProviderGuardianCommit,
|
||||
encodeProviderGuardianGuard,
|
||||
encodeProviderGuardianPublish,
|
||||
MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES,
|
||||
MAX_PROVIDER_GUARDIAN_LEASE_MS,
|
||||
MAX_PROVIDER_SEALED_BYTES,
|
||||
providerGuardianRawStagingLeaf,
|
||||
providerGuardianSealedTempLeaf,
|
||||
type ProviderGuardianKind,
|
||||
} from "./provider-guardian-protocol.ts";
|
||||
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
|
||||
|
||||
const RESPONSE_TIMEOUT_MS = 5_000;
|
||||
const CLOSE_TIMEOUT_MS = 5_000;
|
||||
const MAX_CONTROL_OUTPUT_BYTES = 4_096;
|
||||
|
||||
type OwnedIdentity = Readonly<{ dev: number; ino: number }>;
|
||||
type RecoveryAuthority = Readonly<{
|
||||
rawDirectoryHandle: FileHandle;
|
||||
evidenceDirectoryHandle: FileHandle;
|
||||
rawStagingHandle: FileHandle;
|
||||
sealedTempHandle: FileHandle;
|
||||
rawIdentity: OwnedIdentity;
|
||||
sealedIdentity: OwnedIdentity;
|
||||
rawStagingPinnedPath: string;
|
||||
rawPinnedPath: string;
|
||||
sealedTempPinnedPath: string;
|
||||
sealedPinnedPath: string;
|
||||
}>;
|
||||
|
||||
export type ProviderGuardianLease = Readonly<{
|
||||
pid: number;
|
||||
rawPath: string;
|
||||
rawIdentity: OwnedIdentity;
|
||||
sealedPath: string;
|
||||
sealedTempPath: string;
|
||||
sealedIdentity: OwnedIdentity;
|
||||
prematureExit: Promise<Error>;
|
||||
publish(bytes: Buffer): Promise<void>;
|
||||
commit(): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export type StartProviderGuardianInput = Readonly<{
|
||||
kind: ProviderGuardianKind;
|
||||
workspaceRoot: string;
|
||||
leaseMs: number;
|
||||
guardianScript: string;
|
||||
}>;
|
||||
|
||||
export type ProviderScopeGuardianLatch = Readonly<{
|
||||
activeFailure: Promise<Error>;
|
||||
close(): Promise<void>;
|
||||
failure(): Error | undefined;
|
||||
}>;
|
||||
|
||||
export function createProviderScopeGuardianLatch(
|
||||
guardianExit: Promise<Error>,
|
||||
): ProviderScopeGuardianLatch {
|
||||
let active = true;
|
||||
let closing: Promise<void> | undefined;
|
||||
let observedFailure: Error | undefined;
|
||||
let signalActiveFailure!: (error: Error) => void;
|
||||
const activeFailure = new Promise<Error>((resolve) => { signalActiveFailure = resolve; });
|
||||
void guardianExit.then((error) => {
|
||||
observedFailure = error;
|
||||
if (active) signalActiveFailure(error);
|
||||
});
|
||||
return Object.freeze({
|
||||
activeFailure,
|
||||
close: () => {
|
||||
closing ??= Promise.resolve().then(() => { active = false; });
|
||||
return closing;
|
||||
},
|
||||
failure: () => observedFailure,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertProviderGuardianLeasePaths(
|
||||
lease: Readonly<{ rawPath: string; sealedPath: string }>,
|
||||
expected: Readonly<{ rawPath: string; sealedPath: string }>,
|
||||
): void {
|
||||
if (lease.rawPath !== expected.rawPath) {
|
||||
throw new Error("provider guardian returned a noncanonical raw path");
|
||||
}
|
||||
if (lease.sealedPath !== expected.sealedPath) {
|
||||
throw new Error("provider guardian returned a noncanonical sealed path");
|
||||
}
|
||||
}
|
||||
|
||||
type GuardianResult = Readonly<{
|
||||
code: number | null;
|
||||
error?: Error;
|
||||
signal: NodeJS.Signals | null;
|
||||
}>;
|
||||
|
||||
export async function startProviderGuardian(
|
||||
input: StartProviderGuardianInput,
|
||||
): Promise<ProviderGuardianLease> {
|
||||
if (
|
||||
(input.kind !== "vulnerability" && input.kind !== "provenance") ||
|
||||
!path.isAbsolute(input.workspaceRoot) || !path.isAbsolute(input.guardianScript) ||
|
||||
!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0 ||
|
||||
input.leaseMs > MAX_PROVIDER_GUARDIAN_LEASE_MS
|
||||
) {
|
||||
throw new TypeError("provider guardian client input is invalid");
|
||||
}
|
||||
const rawLeaf = input.kind === "vulnerability"
|
||||
? "vulnerability-report.json"
|
||||
: "provenance-attestation.json";
|
||||
const evidenceRoot = path.resolve(input.workspaceRoot, "provider-evidence");
|
||||
const rawDirectory = path.join(evidenceRoot, "untrusted");
|
||||
const rawPath = path.join(evidenceRoot, "untrusted", rawLeaf);
|
||||
const sealedPath = path.join(evidenceRoot, rawLeaf);
|
||||
const nonce = randomBytes(32);
|
||||
const rawStagingLeaf = providerGuardianRawStagingLeaf(input.kind, nonce);
|
||||
const sealedTempLeaf = providerGuardianSealedTempLeaf(input.kind, nonce);
|
||||
const sealedTempPath = path.join(evidenceRoot, sealedTempLeaf);
|
||||
const recovery = await openRecoveryAuthority({
|
||||
rawDirectory,
|
||||
evidenceRoot,
|
||||
rawLeaf,
|
||||
rawStagingLeaf,
|
||||
sealedLeaf: rawLeaf,
|
||||
sealedTempLeaf,
|
||||
});
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
await assertRecoveryLeavesMissing(recovery);
|
||||
child = spawn(process.execPath, [input.guardianScript], {
|
||||
cwd: input.workspaceRoot,
|
||||
env: {},
|
||||
stdio: [
|
||||
"pipe",
|
||||
"pipe",
|
||||
"pipe",
|
||||
recovery.rawDirectoryHandle.fd,
|
||||
recovery.evidenceDirectoryHandle.fd,
|
||||
recovery.rawStagingHandle.fd,
|
||||
recovery.sealedTempHandle.fd,
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
return await closeRecoveryAndThrow(recovery, error);
|
||||
}
|
||||
if (!child.pid || !child.stdin || !child.stdout || !child.stderr) {
|
||||
child.kill("SIGKILL");
|
||||
return await closeRecoveryAndThrow(
|
||||
recovery,
|
||||
new Error("provider guardian process pipes are unavailable"),
|
||||
);
|
||||
}
|
||||
|
||||
let state: "starting" | "guarding" | "publishing" | "published" |
|
||||
"committing" | "aborting" | "terminated" = "starting";
|
||||
let stderr = Buffer.alloc(0);
|
||||
let inputError: Error | undefined;
|
||||
child.stdin.once("error", (error) => { inputError = error; });
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (stderr.byteLength < MAX_CONTROL_OUTPUT_BYTES) {
|
||||
stderr = Buffer.concat([stderr, bytes.subarray(0, MAX_CONTROL_OUTPUT_BYTES - stderr.byteLength)]);
|
||||
}
|
||||
});
|
||||
const completion = guardianCompletion(child);
|
||||
let signalPrematureExit!: (error: Error) => void;
|
||||
const prematureExit = new Promise<Error>((resolve) => { signalPrematureExit = resolve; });
|
||||
void completion.then((result) => {
|
||||
if (state === "guarding" || state === "publishing" || state === "published") {
|
||||
signalPrematureExit(guardianCloseError(result, stderr));
|
||||
}
|
||||
});
|
||||
|
||||
let ready: ReturnType<typeof decodeProviderGuardianReady>;
|
||||
try {
|
||||
const readyResponse = waitForFrame(child.stdout, completion, "READY");
|
||||
child.stdin.write(encodeProviderGuardianGuard({
|
||||
kind: input.kind,
|
||||
nonce,
|
||||
deadlineEpochMs: Date.now() + input.leaseMs,
|
||||
}));
|
||||
ready = decodeProviderGuardianReady(await readyResponse, nonce);
|
||||
if (
|
||||
ready.sealedTempLeaf !== sealedTempLeaf ||
|
||||
ready.rawDev !== recovery.rawIdentity.dev ||
|
||||
ready.rawIno !== recovery.rawIdentity.ino ||
|
||||
ready.sealedDev !== recovery.sealedIdentity.dev ||
|
||||
ready.sealedIno !== recovery.sealedIdentity.ino
|
||||
) {
|
||||
throw new TypeError("provider guardian READY identity is invalid for its allocation");
|
||||
}
|
||||
await assertPinnedLeafIdentity(recovery.rawPinnedPath, {
|
||||
dev: ready.rawDev,
|
||||
ino: ready.rawIno,
|
||||
}, 0o600);
|
||||
await assertPinnedLeafIdentity(recovery.sealedTempPinnedPath, {
|
||||
dev: ready.sealedDev,
|
||||
ino: ready.sealedIno,
|
||||
}, 0o600);
|
||||
await assertPinnedLeafMissing(recovery.rawStagingPinnedPath);
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw guardianCloseError(await completion, stderr);
|
||||
}
|
||||
state = "guarding";
|
||||
} catch (error) {
|
||||
state = "aborting";
|
||||
child.stdin.end();
|
||||
const failures = [toError(error)];
|
||||
try {
|
||||
await waitForClose(completion, child);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
try {
|
||||
await cleanupStartupRecovery(recovery);
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
try {
|
||||
await closeRecoveryAuthority(recovery);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
state = "terminated";
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, "provider guardian startup failed", { cause: error });
|
||||
}
|
||||
throw failures[0]!;
|
||||
}
|
||||
|
||||
const rawIdentity = Object.freeze({ dev: ready.rawDev, ino: ready.rawIno });
|
||||
const sealedIdentity = Object.freeze({ dev: ready.sealedDev, ino: ready.sealedIno });
|
||||
const fallback = Object.freeze({
|
||||
rawStagingPath: recovery.rawStagingPinnedPath,
|
||||
rawPath: recovery.rawPinnedPath,
|
||||
rawIdentity,
|
||||
sealedPath: recovery.sealedPinnedPath,
|
||||
sealedTempPath: recovery.sealedTempPinnedPath,
|
||||
sealedIdentity,
|
||||
});
|
||||
|
||||
const publish = async (bytes: Buffer): Promise<void> => {
|
||||
if (state !== "guarding") throw new Error("provider guardian lease is not ready to publish");
|
||||
if (!Buffer.isBuffer(bytes) || bytes.byteLength <= 0 || bytes.byteLength > MAX_PROVIDER_SEALED_BYTES) {
|
||||
throw new TypeError("provider guardian sealed bytes are invalid");
|
||||
}
|
||||
state = "publishing";
|
||||
try {
|
||||
await writePinnedSealedBytes(recovery.sealedTempHandle, sealedIdentity, bytes);
|
||||
const publishedResponse = waitForFrame(child.stdout!, completion, "PUBLISHED");
|
||||
child.stdin!.write(encodeProviderGuardianPublish({
|
||||
nonce,
|
||||
sealedDev: sealedIdentity.dev,
|
||||
sealedIno: sealedIdentity.ino,
|
||||
size: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
}));
|
||||
decodeProviderGuardianPublished(
|
||||
await publishedResponse,
|
||||
nonce,
|
||||
sealedIdentity,
|
||||
);
|
||||
if (inputError) throw inputError;
|
||||
state = "published";
|
||||
} catch (error) {
|
||||
state = "guarding";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const commit = async (): Promise<void> => {
|
||||
if (state !== "published") throw new Error("provider guardian lease is not ready to commit");
|
||||
state = "committing";
|
||||
child.stdin!.write(encodeProviderGuardianCommit(nonce));
|
||||
child.stdin!.end();
|
||||
let result: GuardianResult;
|
||||
try {
|
||||
result = await waitForClose(completion, child);
|
||||
} catch (error) {
|
||||
state = "terminated";
|
||||
return await cleanupFallbackCloseAndThrow(fallback, recovery, error);
|
||||
}
|
||||
state = "terminated";
|
||||
if (inputError) return await cleanupFallbackCloseAndThrow(fallback, recovery, inputError);
|
||||
if (result.error || result.code !== 0 || result.signal !== null) {
|
||||
return await cleanupFallbackCloseAndThrow(
|
||||
fallback,
|
||||
recovery,
|
||||
guardianCloseError(result, stderr),
|
||||
);
|
||||
}
|
||||
await closeRecoveryAuthority(recovery);
|
||||
};
|
||||
|
||||
const abort = async (): Promise<void> => {
|
||||
if (state !== "guarding" && state !== "published") {
|
||||
throw new Error("provider guardian lease already terminated");
|
||||
}
|
||||
state = "aborting";
|
||||
child.stdin!.end();
|
||||
let closeError: unknown;
|
||||
try {
|
||||
await waitForClose(completion, child);
|
||||
} catch (error) {
|
||||
closeError = error;
|
||||
}
|
||||
state = "terminated";
|
||||
if (closeError) return await cleanupFallbackCloseAndThrow(fallback, recovery, closeError);
|
||||
await cleanupFallbackAndClose(fallback, recovery);
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
pid: child.pid,
|
||||
rawPath,
|
||||
rawIdentity,
|
||||
sealedPath,
|
||||
sealedTempPath,
|
||||
sealedIdentity,
|
||||
prematureExit,
|
||||
publish,
|
||||
commit,
|
||||
abort,
|
||||
});
|
||||
}
|
||||
|
||||
async function writePinnedSealedBytes(
|
||||
handle: FileHandle,
|
||||
identity: OwnedIdentity,
|
||||
bytes: Buffer,
|
||||
): Promise<void> {
|
||||
assertPinnedMetadata(await handle.stat(), identity, 0o600, 0);
|
||||
await handle.truncate(0);
|
||||
await handle.writeFile(bytes);
|
||||
await handle.chmod(0o400);
|
||||
await handle.sync();
|
||||
assertPinnedMetadata(await handle.stat(), identity, 0o400, bytes.byteLength);
|
||||
}
|
||||
|
||||
function assertPinnedMetadata(
|
||||
metadata: Awaited<ReturnType<Awaited<ReturnType<typeof open>>["stat"]>>,
|
||||
identity: OwnedIdentity,
|
||||
mode: number,
|
||||
size: number,
|
||||
): void {
|
||||
if (
|
||||
!metadata.isFile() || Number(metadata.dev) !== identity.dev ||
|
||||
Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 ||
|
||||
(Number(metadata.mode) & 0o777) !== mode || Number(metadata.size) !== size
|
||||
) {
|
||||
throw new TypeError("provider guardian sealed temp identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
function guardianCompletion(child: ReturnType<typeof spawn>): Promise<GuardianResult> {
|
||||
return new Promise((resolve) => {
|
||||
child.once("error", (error) => resolve({ code: null, error, signal: null }));
|
||||
child.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFrame(
|
||||
stdout: NodeJS.ReadableStream,
|
||||
completion: Promise<GuardianResult>,
|
||||
label: string,
|
||||
): Promise<Buffer> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending = Buffer.alloc(0);
|
||||
const response = new Promise<Buffer>((resolve, reject) => {
|
||||
const onData = (chunk: Buffer | string): void => {
|
||||
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
if (pending.byteLength > MAX_CONTROL_OUTPUT_BYTES) {
|
||||
reject(new Error(`provider guardian ${label} output exceeded its bound`));
|
||||
return;
|
||||
}
|
||||
if (pending.byteLength < 4) return;
|
||||
const payloadBytes = pending.readUInt32BE(0);
|
||||
if (payloadBytes <= 0 || payloadBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
|
||||
reject(new Error(`provider guardian ${label} frame length is invalid`));
|
||||
return;
|
||||
}
|
||||
if (pending.byteLength < payloadBytes + 4) return;
|
||||
if (pending.byteLength !== payloadBytes + 4) {
|
||||
reject(new Error(`provider guardian ${label} output has trailing bytes`));
|
||||
return;
|
||||
}
|
||||
resolve(pending.subarray(4));
|
||||
};
|
||||
stdout.on("data", onData);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
response,
|
||||
completion.then((result) => { throw guardianCloseError(result, Buffer.alloc(0)); }),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`provider guardian ${label} timed out`)),
|
||||
RESPONSE_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
stdout.removeAllListeners("data");
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForClose(
|
||||
completion: Promise<GuardianResult>,
|
||||
child: ReturnType<typeof spawn>,
|
||||
): Promise<GuardianResult> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
completion,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("provider guardian did not close within its bound"));
|
||||
}, CLOSE_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
type FallbackIdentity = Readonly<{
|
||||
rawStagingPath: string;
|
||||
rawPath: string;
|
||||
rawIdentity: OwnedIdentity;
|
||||
sealedPath: string;
|
||||
sealedTempPath: string;
|
||||
sealedIdentity: OwnedIdentity;
|
||||
}>;
|
||||
|
||||
async function cleanupFallback(input: FallbackIdentity): Promise<void> {
|
||||
const failures: Error[] = [];
|
||||
for (const target of [
|
||||
{ path: input.rawStagingPath, identity: input.rawIdentity },
|
||||
{ path: input.rawPath, identity: input.rawIdentity },
|
||||
{ path: input.sealedTempPath, identity: input.sealedIdentity },
|
||||
{ path: input.sealedPath, identity: input.sealedIdentity },
|
||||
]) {
|
||||
try {
|
||||
await cleanupOwnedProviderReport({
|
||||
reportPath: target.path,
|
||||
reportDev: target.identity.dev,
|
||||
reportIno: target.identity.ino,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(toError(error));
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "provider guardian fallback cleanup failed", {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupFallbackCloseAndThrow(
|
||||
fallback: FallbackIdentity,
|
||||
recovery: RecoveryAuthority,
|
||||
primaryError: unknown,
|
||||
): Promise<never> {
|
||||
const failures = [toError(primaryError)];
|
||||
try {
|
||||
await cleanupFallback(fallback);
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
try {
|
||||
await closeRecoveryAuthority(recovery);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, "provider guardian failure and recovery failed", {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
throw failures[0]!;
|
||||
}
|
||||
|
||||
async function cleanupFallbackAndClose(
|
||||
fallback: FallbackIdentity,
|
||||
recovery: RecoveryAuthority,
|
||||
): Promise<void> {
|
||||
const failures: Error[] = [];
|
||||
try {
|
||||
await cleanupFallback(fallback);
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
try {
|
||||
await closeRecoveryAuthority(recovery);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "provider guardian abort recovery failed", {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openRecoveryAuthority(input: Readonly<{
|
||||
rawDirectory: string;
|
||||
evidenceRoot: string;
|
||||
rawLeaf: string;
|
||||
rawStagingLeaf: string;
|
||||
sealedLeaf: string;
|
||||
sealedTempLeaf: string;
|
||||
}>): Promise<RecoveryAuthority> {
|
||||
let rawDirectoryHandle: FileHandle | undefined;
|
||||
let evidenceDirectoryHandle: FileHandle | undefined;
|
||||
let rawStagingHandle: FileHandle | undefined;
|
||||
let sealedTempHandle: FileHandle | undefined;
|
||||
let rawIdentity: OwnedIdentity | undefined;
|
||||
let sealedIdentity: OwnedIdentity | undefined;
|
||||
let rawStagingPinnedPath: string | undefined;
|
||||
let rawPinnedPath: string | undefined;
|
||||
let sealedTempPinnedPath: string | undefined;
|
||||
let sealedPinnedPath: string | undefined;
|
||||
try {
|
||||
rawDirectoryHandle = await open(
|
||||
input.rawDirectory,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
await assertPinnedDirectory(rawDirectoryHandle, input.rawDirectory, "raw");
|
||||
evidenceDirectoryHandle = await open(
|
||||
input.evidenceRoot,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
await assertPinnedDirectory(evidenceDirectoryHandle, input.evidenceRoot, "evidence");
|
||||
rawStagingPinnedPath =
|
||||
`/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawStagingLeaf}`;
|
||||
rawPinnedPath = `/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawLeaf}`;
|
||||
sealedTempPinnedPath =
|
||||
`/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedTempLeaf}`;
|
||||
sealedPinnedPath = `/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedLeaf}`;
|
||||
rawStagingHandle = await open(
|
||||
rawStagingPinnedPath,
|
||||
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const rawMetadata = await rawStagingHandle.stat();
|
||||
rawIdentity = Object.freeze({ dev: rawMetadata.dev, ino: rawMetadata.ino });
|
||||
assertAllocatedPrivateMetadata(rawMetadata, rawIdentity, "raw staging");
|
||||
await assertPinnedLeafIdentity(rawStagingPinnedPath, rawIdentity, 0o600);
|
||||
sealedTempHandle = await open(
|
||||
sealedTempPinnedPath,
|
||||
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const sealedMetadata = await sealedTempHandle.stat();
|
||||
sealedIdentity = Object.freeze({ dev: sealedMetadata.dev, ino: sealedMetadata.ino });
|
||||
assertAllocatedPrivateMetadata(sealedMetadata, sealedIdentity, "sealed temp");
|
||||
await assertPinnedLeafIdentity(sealedTempPinnedPath, sealedIdentity, 0o600);
|
||||
return Object.freeze({
|
||||
rawDirectoryHandle,
|
||||
evidenceDirectoryHandle,
|
||||
rawStagingHandle,
|
||||
sealedTempHandle,
|
||||
rawIdentity,
|
||||
sealedIdentity,
|
||||
rawStagingPinnedPath,
|
||||
rawPinnedPath,
|
||||
sealedTempPinnedPath,
|
||||
sealedPinnedPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const failures = [toError(error)];
|
||||
for (const target of [
|
||||
{ path: rawStagingPinnedPath, identity: rawIdentity },
|
||||
{ path: rawPinnedPath, identity: rawIdentity },
|
||||
{ path: sealedTempPinnedPath, identity: sealedIdentity },
|
||||
{ path: sealedPinnedPath, identity: sealedIdentity },
|
||||
]) {
|
||||
if (!target.path || !target.identity) continue;
|
||||
try {
|
||||
await cleanupOwnedProviderReport({
|
||||
reportPath: target.path,
|
||||
reportDev: target.identity.dev,
|
||||
reportIno: target.identity.ino,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
}
|
||||
for (const handle of [
|
||||
sealedTempHandle,
|
||||
rawStagingHandle,
|
||||
evidenceDirectoryHandle,
|
||||
rawDirectoryHandle,
|
||||
]) {
|
||||
if (!handle) continue;
|
||||
try { await handle.close(); } catch (closeError) { failures.push(toError(closeError)); }
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, "provider guardian recovery setup failed", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw failures[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllocatedPrivateMetadata(
|
||||
metadata: Awaited<ReturnType<FileHandle["stat"]>>,
|
||||
identity: OwnedIdentity,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!metadata.isFile() || Number(metadata.dev) !== identity.dev ||
|
||||
Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 ||
|
||||
(Number(metadata.mode) & 0o777) !== 0o600 || Number(metadata.size) !== 0
|
||||
) {
|
||||
throw new TypeError(`provider guardian ${label} allocation is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPinnedDirectory(
|
||||
handle: FileHandle,
|
||||
canonicalPath: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const [descriptorMetadata, pathMetadata] = await Promise.all([
|
||||
handle.stat(),
|
||||
lstat(canonicalPath),
|
||||
]);
|
||||
if (
|
||||
!descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() ||
|
||||
pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev ||
|
||||
descriptorMetadata.ino !== pathMetadata.ino
|
||||
) {
|
||||
throw new TypeError(`provider guardian ${label} recovery directory identity changed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRecoveryLeavesMissing(recovery: RecoveryAuthority): Promise<void> {
|
||||
await assertPinnedLeafMissing(recovery.rawPinnedPath);
|
||||
await assertPinnedLeafMissing(recovery.sealedPinnedPath);
|
||||
assertAllocatedPrivateMetadata(
|
||||
await recovery.rawStagingHandle.stat(),
|
||||
recovery.rawIdentity,
|
||||
"raw staging",
|
||||
);
|
||||
assertAllocatedPrivateMetadata(
|
||||
await recovery.sealedTempHandle.stat(),
|
||||
recovery.sealedIdentity,
|
||||
"sealed temp",
|
||||
);
|
||||
await assertPinnedLeafIdentity(
|
||||
recovery.rawStagingPinnedPath,
|
||||
recovery.rawIdentity,
|
||||
0o600,
|
||||
);
|
||||
await assertPinnedLeafIdentity(
|
||||
recovery.sealedTempPinnedPath,
|
||||
recovery.sealedIdentity,
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
|
||||
async function assertPinnedLeafMissing(target: string): Promise<void> {
|
||||
try {
|
||||
await lstat(target);
|
||||
throw new Error("provider guardian transaction leaf already exists");
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPinnedLeafIdentity(
|
||||
target: string,
|
||||
identity: OwnedIdentity,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
const metadata = await lstat(target);
|
||||
if (
|
||||
!metadata.isFile() || metadata.isSymbolicLink() || metadata.dev !== identity.dev ||
|
||||
metadata.ino !== identity.ino || metadata.nlink !== 1 ||
|
||||
(metadata.mode & 0o777) !== mode || metadata.size !== 0
|
||||
) {
|
||||
throw new TypeError("provider guardian READY identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupStartupRecovery(recovery: RecoveryAuthority): Promise<void> {
|
||||
await cleanupFallback({
|
||||
rawStagingPath: recovery.rawStagingPinnedPath,
|
||||
rawPath: recovery.rawPinnedPath,
|
||||
rawIdentity: recovery.rawIdentity,
|
||||
sealedTempPath: recovery.sealedTempPinnedPath,
|
||||
sealedPath: recovery.sealedPinnedPath,
|
||||
sealedIdentity: recovery.sealedIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeRecoveryAuthority(recovery: RecoveryAuthority): Promise<void> {
|
||||
const failures: Error[] = [];
|
||||
for (const handle of [
|
||||
recovery.rawStagingHandle,
|
||||
recovery.sealedTempHandle,
|
||||
recovery.rawDirectoryHandle,
|
||||
recovery.evidenceDirectoryHandle,
|
||||
]) {
|
||||
try { await handle.close(); } catch (error) { failures.push(toError(error)); }
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "provider guardian recovery directory close failed", {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function closeRecoveryAndThrow(
|
||||
recovery: RecoveryAuthority,
|
||||
primaryError: unknown,
|
||||
): Promise<never> {
|
||||
const failures = [toError(primaryError)];
|
||||
try {
|
||||
await cleanupStartupRecovery(recovery);
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
try {
|
||||
await closeRecoveryAuthority(recovery);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures,
|
||||
"provider guardian failure and recovery close failed", { cause: failures[0] });
|
||||
}
|
||||
throw failures[0]!;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
|
||||
function guardianCloseError(result: GuardianResult, stderr: Buffer): Error {
|
||||
if (result.error) return result.error;
|
||||
const detail = stderr.toString("utf8").trim();
|
||||
return new Error(
|
||||
`provider guardian failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}${detail ? `, output=${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
export type ProviderGuardianKind = "vulnerability" | "provenance";
|
||||
|
||||
export const MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES = 4_092;
|
||||
export const MAX_PROVIDER_GUARDIAN_LEASE_MS = 40 * 60 * 1_000;
|
||||
export const MAX_PROVIDER_SEALED_BYTES = 8_388_608;
|
||||
const V2_GUARD_KEYS = ["type", "version", "kind", "nonce", "deadlineEpochMs"] as const;
|
||||
const READY_KEYS = [
|
||||
"type", "version", "nonce", "rawDev", "rawIno",
|
||||
"sealedTempLeaf", "sealedDev", "sealedIno",
|
||||
] as const;
|
||||
const PUBLISH_KEYS = [
|
||||
"type", "version", "nonce", "sealedDev", "sealedIno", "size", "sha256",
|
||||
] as const;
|
||||
const PUBLISHED_KEYS = ["type", "version", "nonce", "sealedDev", "sealedIno"] as const;
|
||||
const COMMIT_KEYS = ["type", "version", "nonce"] as const;
|
||||
|
||||
export type ProviderGuardianGuard = Readonly<{
|
||||
kind: ProviderGuardianKind;
|
||||
nonce: Buffer;
|
||||
deadlineEpochMs: number;
|
||||
}>;
|
||||
|
||||
export type ProviderGuardianReady = Readonly<{
|
||||
nonce: Buffer;
|
||||
rawDev: number;
|
||||
rawIno: number;
|
||||
sealedTempLeaf: string;
|
||||
sealedDev: number;
|
||||
sealedIno: number;
|
||||
}>;
|
||||
|
||||
export type ProviderGuardianPublish = Readonly<{
|
||||
nonce: Buffer;
|
||||
sealedDev: number;
|
||||
sealedIno: number;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export type ProviderGuardianPublished = Readonly<{
|
||||
nonce: Buffer;
|
||||
sealedDev: number;
|
||||
sealedIno: number;
|
||||
}>;
|
||||
|
||||
export function providerGuardianSealedTempLeaf(
|
||||
kind: ProviderGuardianKind,
|
||||
nonce: Buffer,
|
||||
): string {
|
||||
const rawLeaf = baseLeaf(kind);
|
||||
assertV2Nonce(nonce);
|
||||
return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.tmp`;
|
||||
}
|
||||
|
||||
export function providerGuardianRawStagingLeaf(
|
||||
kind: ProviderGuardianKind,
|
||||
nonce: Buffer,
|
||||
): string {
|
||||
const rawLeaf = baseLeaf(kind);
|
||||
assertV2Nonce(nonce);
|
||||
return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.raw.tmp`;
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianGuard(input: ProviderGuardianGuard): Buffer {
|
||||
assertV2Guard(input);
|
||||
return prefixFrame(encodeV2GuardPayload(input));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianGuard(
|
||||
payload: Buffer,
|
||||
options: Readonly<{ nowEpochMs: number; maxLeaseMs: number }>,
|
||||
): ProviderGuardianGuard {
|
||||
const value = parseRecord(payload, V2_GUARD_KEYS, "guard");
|
||||
const guard: ProviderGuardianGuard = {
|
||||
kind: value.kind as ProviderGuardianKind,
|
||||
nonce: parseV2Nonce(value.nonce),
|
||||
deadlineEpochMs: value.deadlineEpochMs as number,
|
||||
};
|
||||
if (value.type !== "guard" || value.version !== 2) {
|
||||
throw new TypeError("provider guardian guard version is invalid");
|
||||
}
|
||||
assertV2Guard(guard);
|
||||
if (
|
||||
!Number.isSafeInteger(options.nowEpochMs) ||
|
||||
!Number.isSafeInteger(options.maxLeaseMs) || options.maxLeaseMs <= 0 ||
|
||||
guard.deadlineEpochMs <= options.nowEpochMs ||
|
||||
guard.deadlineEpochMs > options.nowEpochMs + options.maxLeaseMs
|
||||
) {
|
||||
throw new TypeError("provider guardian guard deadline is invalid");
|
||||
}
|
||||
assertCanonical(payload, encodeV2GuardPayload(guard), "guard");
|
||||
return guard;
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianReady(input: ProviderGuardianReady): Buffer {
|
||||
assertReady(input);
|
||||
return prefixFrame(encodeReadyPayload(input));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianReady(
|
||||
payload: Buffer,
|
||||
expectedNonce: Buffer,
|
||||
): ProviderGuardianReady {
|
||||
assertV2Nonce(expectedNonce);
|
||||
const value = parseRecord(payload, READY_KEYS, "READY");
|
||||
const ready: ProviderGuardianReady = {
|
||||
nonce: parseV2Nonce(value.nonce),
|
||||
rawDev: value.rawDev as number,
|
||||
rawIno: value.rawIno as number,
|
||||
sealedTempLeaf: value.sealedTempLeaf as string,
|
||||
sealedDev: value.sealedDev as number,
|
||||
sealedIno: value.sealedIno as number,
|
||||
};
|
||||
if (value.type !== "ready" || value.version !== 2) {
|
||||
throw new TypeError("provider guardian READY version is invalid");
|
||||
}
|
||||
assertReady(ready);
|
||||
assertAuthenticatedNonce(ready.nonce, expectedNonce, "READY");
|
||||
assertCanonical(payload, encodeReadyPayload(ready), "READY");
|
||||
return ready;
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianPublish(input: ProviderGuardianPublish): Buffer {
|
||||
assertPublish(input);
|
||||
return prefixFrame(encodePublishPayload(input));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianPublish(
|
||||
payload: Buffer,
|
||||
expectedNonce: Buffer,
|
||||
): ProviderGuardianPublish {
|
||||
assertV2Nonce(expectedNonce);
|
||||
const value = parseRecord(payload, PUBLISH_KEYS, "publish");
|
||||
const publish: ProviderGuardianPublish = {
|
||||
nonce: parseV2Nonce(value.nonce),
|
||||
sealedDev: value.sealedDev as number,
|
||||
sealedIno: value.sealedIno as number,
|
||||
size: value.size as number,
|
||||
sha256: value.sha256 as string,
|
||||
};
|
||||
if (value.type !== "publish" || value.version !== 2) {
|
||||
throw new TypeError("provider guardian publish version is invalid");
|
||||
}
|
||||
assertPublish(publish);
|
||||
assertAuthenticatedNonce(publish.nonce, expectedNonce, "publish");
|
||||
assertCanonical(payload, encodePublishPayload(publish), "publish");
|
||||
return publish;
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianPublished(input: ProviderGuardianPublished): Buffer {
|
||||
assertPublished(input);
|
||||
return prefixFrame(encodePublishedPayload(input));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianPublished(
|
||||
payload: Buffer,
|
||||
expectedNonce: Buffer,
|
||||
expectedIdentity: Readonly<{ dev: number; ino: number }>,
|
||||
): void {
|
||||
assertV2Nonce(expectedNonce);
|
||||
const value = parseRecord(payload, PUBLISHED_KEYS, "PUBLISHED");
|
||||
const published: ProviderGuardianPublished = {
|
||||
nonce: parseV2Nonce(value.nonce),
|
||||
sealedDev: value.sealedDev as number,
|
||||
sealedIno: value.sealedIno as number,
|
||||
};
|
||||
if (value.type !== "published" || value.version !== 2) {
|
||||
throw new TypeError("provider guardian PUBLISHED version is invalid");
|
||||
}
|
||||
assertPublished(published);
|
||||
assertAuthenticatedNonce(published.nonce, expectedNonce, "PUBLISHED");
|
||||
if (published.sealedDev !== expectedIdentity.dev || published.sealedIno !== expectedIdentity.ino) {
|
||||
throw new TypeError("provider guardian PUBLISHED identity is invalid");
|
||||
}
|
||||
assertCanonical(payload, encodePublishedPayload(published), "PUBLISHED");
|
||||
}
|
||||
|
||||
function encodeV2GuardPayload(input: ProviderGuardianGuard): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "guard",
|
||||
version: 2,
|
||||
kind: input.kind,
|
||||
nonce: input.nonce.toString("hex"),
|
||||
deadlineEpochMs: input.deadlineEpochMs,
|
||||
}));
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianCommit(nonce: Buffer): Buffer {
|
||||
assertV2Nonce(nonce);
|
||||
return prefixFrame(encodeCommitPayload(nonce));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianCommit(payload: Buffer, expectedNonce: Buffer): void {
|
||||
assertPayloadSize(payload);
|
||||
assertV2Nonce(expectedNonce);
|
||||
const decoded = decodeUtf8(payload);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(decoded);
|
||||
} catch {
|
||||
throw new TypeError("provider guardian commit JSON is invalid");
|
||||
}
|
||||
if (!isRecord(value) || !hasExactKeys(value, COMMIT_KEYS)) {
|
||||
throw new TypeError("provider guardian commit fields are invalid");
|
||||
}
|
||||
const nonce = parseV2Nonce(value.nonce);
|
||||
if (
|
||||
value.type !== "commit" || value.version !== 2 ||
|
||||
nonce.byteLength !== expectedNonce.byteLength ||
|
||||
!timingSafeEqual(nonce, expectedNonce)
|
||||
) {
|
||||
throw new TypeError("provider guardian commit authentication failed");
|
||||
}
|
||||
if (!payload.equals(encodeCommitPayload(nonce))) {
|
||||
throw new TypeError("provider guardian commit is not canonical");
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCommitPayload(nonce: Buffer): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "commit",
|
||||
version: 2,
|
||||
nonce: nonce.toString("hex"),
|
||||
}));
|
||||
}
|
||||
|
||||
function encodeReadyPayload(input: ProviderGuardianReady): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "ready",
|
||||
version: 2,
|
||||
nonce: input.nonce.toString("hex"),
|
||||
rawDev: input.rawDev,
|
||||
rawIno: input.rawIno,
|
||||
sealedTempLeaf: input.sealedTempLeaf,
|
||||
sealedDev: input.sealedDev,
|
||||
sealedIno: input.sealedIno,
|
||||
}));
|
||||
}
|
||||
|
||||
function encodePublishPayload(input: ProviderGuardianPublish): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "publish",
|
||||
version: 2,
|
||||
nonce: input.nonce.toString("hex"),
|
||||
sealedDev: input.sealedDev,
|
||||
sealedIno: input.sealedIno,
|
||||
size: input.size,
|
||||
sha256: input.sha256,
|
||||
}));
|
||||
}
|
||||
|
||||
function encodePublishedPayload(input: ProviderGuardianPublished): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "published",
|
||||
version: 2,
|
||||
nonce: input.nonce.toString("hex"),
|
||||
sealedDev: input.sealedDev,
|
||||
sealedIno: input.sealedIno,
|
||||
}));
|
||||
}
|
||||
|
||||
function prefixFrame(payload: Buffer): Buffer {
|
||||
if (payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
|
||||
throw new TypeError("provider guardian frame size is invalid");
|
||||
}
|
||||
const frame = Buffer.allocUnsafe(payload.byteLength + 4);
|
||||
frame.writeUInt32BE(payload.byteLength, 0);
|
||||
payload.copy(frame, 4);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function assertV2Guard(input: ProviderGuardianGuard): void {
|
||||
if (
|
||||
(input.kind !== "vulnerability" && input.kind !== "provenance") ||
|
||||
!isV2Nonce(input.nonce) ||
|
||||
!Number.isSafeInteger(input.deadlineEpochMs) || input.deadlineEpochMs <= 0
|
||||
) {
|
||||
throw new TypeError("provider guardian guard fields are invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function baseLeaf(kind: ProviderGuardianKind): string {
|
||||
if (kind === "vulnerability") return "vulnerability-report.json";
|
||||
if (kind === "provenance") return "provenance-attestation.json";
|
||||
throw new TypeError("provider guardian kind is invalid");
|
||||
}
|
||||
|
||||
function assertReady(input: ProviderGuardianReady): void {
|
||||
if (
|
||||
!isV2Nonce(input.nonce) ||
|
||||
!isIdentityPart(input.rawDev) || !isIdentityPart(input.rawIno) ||
|
||||
typeof input.sealedTempLeaf !== "string" ||
|
||||
!/^\.(?:vulnerability-report|provenance-attestation)\.json\.guardian-[0-9a-f]{32}\.tmp$/u
|
||||
.test(input.sealedTempLeaf) ||
|
||||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno)
|
||||
) {
|
||||
throw new TypeError("provider guardian READY fields are invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertPublish(input: ProviderGuardianPublish): void {
|
||||
if (
|
||||
!isV2Nonce(input.nonce) ||
|
||||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno) ||
|
||||
!Number.isSafeInteger(input.size) || input.size <= 0 || input.size > MAX_PROVIDER_SEALED_BYTES ||
|
||||
typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(input.sha256)
|
||||
) {
|
||||
throw new TypeError("provider guardian publish fields are invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertPublished(input: ProviderGuardianPublished): void {
|
||||
if (
|
||||
!isV2Nonce(input.nonce) ||
|
||||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno)
|
||||
) {
|
||||
throw new TypeError("provider guardian PUBLISHED fields are invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertV2Nonce(nonce: Buffer): void {
|
||||
if (!isV2Nonce(nonce)) throw new TypeError("provider guardian nonce is invalid");
|
||||
}
|
||||
|
||||
function isV2Nonce(nonce: Buffer): boolean {
|
||||
return Buffer.isBuffer(nonce) && nonce.byteLength === 32;
|
||||
}
|
||||
|
||||
function parseV2Nonce(value: unknown): Buffer {
|
||||
return typeof value === "string" && /^[0-9a-f]{64}$/u.test(value)
|
||||
? Buffer.from(value, "hex")
|
||||
: Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function isIdentityPart(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
function assertAuthenticatedNonce(received: Buffer, expected: Buffer, label: string): void {
|
||||
if (received.byteLength !== expected.byteLength || !timingSafeEqual(received, expected)) {
|
||||
throw new TypeError(`provider guardian ${label} authentication failed`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRecord(
|
||||
payload: Buffer,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
assertPayloadSize(payload);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(decodeUtf8(payload));
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && /provider guardian/u.test(error.message)) throw error;
|
||||
throw new TypeError(`provider guardian ${label} JSON is invalid`, { cause: error });
|
||||
}
|
||||
if (!isRecord(value) || !hasExactKeys(value, expectedKeys)) {
|
||||
throw new TypeError(`provider guardian ${label} fields are invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertCanonical(payload: Buffer, canonical: Buffer, label: string): void {
|
||||
if (!payload.equals(canonical)) {
|
||||
throw new TypeError(`provider guardian ${label} frame is not canonical`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPayloadSize(payload: Buffer): void {
|
||||
if (!Buffer.isBuffer(payload) || payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
|
||||
throw new TypeError("provider guardian frame size is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8(payload: Buffer): string {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = new TextDecoder("utf-8", { fatal: true }).decode(payload);
|
||||
} catch {
|
||||
throw new TypeError("provider guardian frame UTF-8 is invalid");
|
||||
}
|
||||
if (decoded.includes("\0")) throw new TypeError("provider guardian frame contains NUL");
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value);
|
||||
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type ProviderOutputLimiter = Readonly<{
|
||||
consume(chunk: Buffer | string): void;
|
||||
bytes(): number;
|
||||
}>;
|
||||
|
||||
export function createProviderOutputLimiter(
|
||||
maxBytes: number,
|
||||
onExceeded: () => void,
|
||||
): ProviderOutputLimiter {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || typeof onExceeded !== "function") {
|
||||
throw new TypeError("provider output limiter input is invalid");
|
||||
}
|
||||
let observedBytes = 0;
|
||||
let exceeded = false;
|
||||
return Object.freeze({
|
||||
consume: (chunk: Buffer | string) => {
|
||||
if (exceeded) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(chunk);
|
||||
observedBytes += bytes;
|
||||
if (observedBytes > maxBytes) {
|
||||
exceeded = true;
|
||||
onExceeded();
|
||||
}
|
||||
},
|
||||
bytes: () => observedBytes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
|
||||
export type ProviderProcessInput = Readonly<{
|
||||
executable: string;
|
||||
arguments: readonly string[];
|
||||
environment: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
}>;
|
||||
|
||||
type ProviderChild = Pick<ChildProcess, "kill" | "once" | "pid">;
|
||||
|
||||
export async function runProviderProcess(
|
||||
input: ProviderProcessInput,
|
||||
dependencies: Readonly<{
|
||||
spawnChild?: (input: ProviderProcessInput) => ProviderChild;
|
||||
setTimer?: (callback: () => void, milliseconds: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
killProcessGroup?: (child: ProviderChild) => void;
|
||||
}> = {},
|
||||
): Promise<void> {
|
||||
if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs <= 0) {
|
||||
throw new TypeError("provider process timeout must be a positive integer");
|
||||
}
|
||||
const child = (dependencies.spawnChild ?? defaultSpawn)(input);
|
||||
const setTimer = dependencies.setTimer ?? setTimeout;
|
||||
const clearTimer = dependencies.clearTimer ?? clearTimeout;
|
||||
const killProcessGroup = dependencies.killProcessGroup ?? defaultKillProcessGroup;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const killFailures: unknown[] = [];
|
||||
const settle = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimer(timer);
|
||||
error ? reject(error) : resolve();
|
||||
};
|
||||
const timer = setTimer(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
killProcessGroup(child);
|
||||
} catch (groupError) {
|
||||
killFailures.push(groupError);
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch (fallbackError) {
|
||||
killFailures.push(fallbackError);
|
||||
}
|
||||
}
|
||||
}, input.timeoutMs);
|
||||
child.once("error", (error: Error) => {
|
||||
if (!timedOut) settle(error);
|
||||
});
|
||||
child.once("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timedOut) {
|
||||
const timeoutError = new Error(
|
||||
"sandboxed external provider command timed out after process close",
|
||||
);
|
||||
settle(
|
||||
killFailures.length === 0
|
||||
? timeoutError
|
||||
: new AggregateError(
|
||||
[timeoutError, ...killFailures],
|
||||
"sandboxed external provider timed out and process-group kill failed before close",
|
||||
{ cause: killFailures.at(-1) },
|
||||
),
|
||||
);
|
||||
} else if (code === 0 && signal === null) {
|
||||
settle();
|
||||
} else {
|
||||
settle(
|
||||
new Error(
|
||||
`sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function defaultSpawn(input: ProviderProcessInput): ProviderChild {
|
||||
return spawn(input.executable, [...input.arguments], {
|
||||
env: input.environment,
|
||||
stdio: "inherit",
|
||||
detached: true,
|
||||
});
|
||||
}
|
||||
|
||||
function defaultKillProcessGroup(child: ProviderChild): void {
|
||||
if (child.pid && child.pid > 0) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ESRCH")) throw error;
|
||||
}
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { lstat, rename, unlink } from "node:fs/promises";
|
||||
|
||||
export type ProviderRawIdentity = Readonly<{
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
}>;
|
||||
|
||||
export async function cleanupOwnedProviderReport(
|
||||
identity: ProviderRawIdentity,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const metadata = await lstat(identity.reportPath);
|
||||
if (!matchesReportIdentity(metadata, identity)) return false;
|
||||
const quarantine = `${identity.reportPath}.parent-loss-${process.pid}-${randomBytes(16).toString("hex")}`;
|
||||
await rename(identity.reportPath, quarantine);
|
||||
const quarantinedMetadata = await lstat(quarantine);
|
||||
if (!matchesReportIdentity(quarantinedMetadata, identity)) {
|
||||
throw new Error("provider raw output identity changed during parent-loss cleanup");
|
||||
}
|
||||
await unlink(quarantine);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesReportIdentity(
|
||||
metadata: Awaited<ReturnType<typeof lstat>>,
|
||||
identity: ProviderRawIdentity,
|
||||
): boolean {
|
||||
return metadata.isFile() && !metadata.isSymbolicLink() &&
|
||||
metadata.dev === identity.reportDev && metadata.ino === identity.reportIno;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
fstatSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
readlinkSync,
|
||||
readSync,
|
||||
type Stats,
|
||||
writeSync,
|
||||
createReadStream,
|
||||
} from "node:fs";
|
||||
import { link, lstat, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
decodeProviderGuardianCommit,
|
||||
decodeProviderGuardianGuard,
|
||||
decodeProviderGuardianPublish,
|
||||
encodeProviderGuardianPublished,
|
||||
encodeProviderGuardianReady,
|
||||
MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES,
|
||||
MAX_PROVIDER_GUARDIAN_LEASE_MS,
|
||||
providerGuardianRawStagingLeaf,
|
||||
providerGuardianSealedTempLeaf,
|
||||
type ProviderGuardianGuard,
|
||||
type ProviderGuardianKind,
|
||||
} from "./provider-guardian-protocol.ts";
|
||||
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
|
||||
|
||||
type OwnedIdentity = Readonly<{ dev: number; ino: number }>;
|
||||
type BootstrapAuthority = Readonly<{
|
||||
kind: ProviderGuardianKind;
|
||||
noncePrefix: string;
|
||||
rawStagingLeaf: string;
|
||||
rawStagingPath: string;
|
||||
rawPath: string;
|
||||
rawIdentity: OwnedIdentity;
|
||||
sealedTempLeaf: string;
|
||||
sealedTempPath: string;
|
||||
sealedPath: string;
|
||||
sealedIdentity: OwnedIdentity;
|
||||
}>;
|
||||
type BoundPrivateAuthority = Readonly<{
|
||||
identity: OwnedIdentity;
|
||||
leaf: string;
|
||||
noncePrefix: string;
|
||||
path: string;
|
||||
stem: string;
|
||||
}>;
|
||||
type GuardianTransaction = Readonly<{ guard: ProviderGuardianGuard }>;
|
||||
|
||||
const RAW_DIRECTORY_FD = 3;
|
||||
const EVIDENCE_DIRECTORY_FD = 4;
|
||||
const RAW_STAGING_FD = 5;
|
||||
const SEALED_TEMP_FD = 6;
|
||||
const RAW_DIRECTORY_PATH = `/proc/self/fd/${RAW_DIRECTORY_FD}`;
|
||||
const EVIDENCE_DIRECTORY_PATH = `/proc/self/fd/${EVIDENCE_DIRECTORY_FD}`;
|
||||
const RAW_STAGING_FD_PATH = `/proc/self/fd/${RAW_STAGING_FD}`;
|
||||
const SEALED_TEMP_FD_PATH = `/proc/self/fd/${SEALED_TEMP_FD}`;
|
||||
const RAW_STAGING_PATTERN =
|
||||
/^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.raw\.tmp$/u;
|
||||
const SEALED_TEMP_PATTERN =
|
||||
/^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.tmp$/u;
|
||||
|
||||
let privateFdsClosed = false;
|
||||
const bootstrap = await initializeBootstrap();
|
||||
let pending = Buffer.alloc(0);
|
||||
let expectedBytes: number | undefined;
|
||||
let state: "starting" | "guarding" | "published" | "commitPending" = "starting";
|
||||
let transaction: GuardianTransaction | undefined;
|
||||
let terminal = false;
|
||||
let deadline: NodeJS.Timeout | undefined;
|
||||
let operations = Promise.resolve();
|
||||
const liveness = createReadStream("", { fd: 0, autoClose: false });
|
||||
|
||||
liveness.on("data", consumeChunk);
|
||||
liveness.once("end", () => {
|
||||
enqueue(async () => {
|
||||
if (state === "commitPending" && pending.byteLength === 0 && expectedBytes === undefined) {
|
||||
await succeedOnCommittedEof();
|
||||
return;
|
||||
}
|
||||
if (state === "starting" && pending.byteLength > 0) {
|
||||
await failClosed(126, "provider guardian frame is truncated");
|
||||
return;
|
||||
}
|
||||
await failClosed(125, "provider guardian liveness EOF");
|
||||
});
|
||||
});
|
||||
liveness.once("error", (error) => {
|
||||
enqueue(async () => failClosed(125, `provider guardian liveness error: ${error.message}`));
|
||||
});
|
||||
|
||||
async function initializeBootstrap(): Promise<BootstrapAuthority> {
|
||||
const rawDirectory = path.resolve(process.cwd(), "provider-evidence/untrusted");
|
||||
const evidenceDirectory = path.resolve(process.cwd(), "provider-evidence");
|
||||
const failures: Error[] = [];
|
||||
const rawDirectoryValid = captureInheritedDirectory(
|
||||
RAW_DIRECTORY_FD,
|
||||
rawDirectory,
|
||||
"raw",
|
||||
failures,
|
||||
);
|
||||
const evidenceDirectoryValid = captureInheritedDirectory(
|
||||
EVIDENCE_DIRECTORY_FD,
|
||||
evidenceDirectory,
|
||||
"evidence",
|
||||
failures,
|
||||
);
|
||||
|
||||
const rawDescriptor = capturePrivateDescriptor(RAW_STAGING_FD, "raw staging", failures);
|
||||
const sealedDescriptor = capturePrivateDescriptor(SEALED_TEMP_FD, "sealed temp", failures);
|
||||
const rawAuthority = rawDescriptor && rawDirectoryValid
|
||||
? capturePrivateAlias({
|
||||
descriptorMetadata: rawDescriptor,
|
||||
descriptorTarget: RAW_STAGING_FD_PATH,
|
||||
expectedDirectory: rawDirectory,
|
||||
descriptorDirectory: RAW_DIRECTORY_PATH,
|
||||
grammar: RAW_STAGING_PATTERN,
|
||||
label: "raw staging",
|
||||
}, failures)
|
||||
: undefined;
|
||||
const sealedAuthority = sealedDescriptor && evidenceDirectoryValid
|
||||
? capturePrivateAlias({
|
||||
descriptorMetadata: sealedDescriptor,
|
||||
descriptorTarget: SEALED_TEMP_FD_PATH,
|
||||
expectedDirectory: evidenceDirectory,
|
||||
descriptorDirectory: EVIDENCE_DIRECTORY_PATH,
|
||||
grammar: SEALED_TEMP_PATTERN,
|
||||
label: "sealed temp",
|
||||
}, failures)
|
||||
: undefined;
|
||||
|
||||
if (!rawAuthority || !sealedAuthority) {
|
||||
return await failBootstrap(rawAuthority, sealedAuthority, failures);
|
||||
}
|
||||
try {
|
||||
if (
|
||||
rawAuthority.stem !== sealedAuthority.stem ||
|
||||
rawAuthority.noncePrefix !== sealedAuthority.noncePrefix
|
||||
) {
|
||||
throw new TypeError("provider guardian inherited private aliases disagree");
|
||||
}
|
||||
const kind = providerKindFromStem(rawAuthority.stem);
|
||||
const rawLeaf = kind === "vulnerability"
|
||||
? "vulnerability-report.json"
|
||||
: "provenance-attestation.json";
|
||||
return Object.freeze({
|
||||
kind,
|
||||
noncePrefix: rawAuthority.noncePrefix,
|
||||
rawStagingLeaf: rawAuthority.leaf,
|
||||
rawStagingPath: rawAuthority.path,
|
||||
rawPath: `${RAW_DIRECTORY_PATH}/${rawLeaf}`,
|
||||
rawIdentity: rawAuthority.identity,
|
||||
sealedTempLeaf: sealedAuthority.leaf,
|
||||
sealedTempPath: sealedAuthority.path,
|
||||
sealedPath: `${EVIDENCE_DIRECTORY_PATH}/${rawLeaf}`,
|
||||
sealedIdentity: sealedAuthority.identity,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(toError(error));
|
||||
return await failBootstrap(rawAuthority, sealedAuthority, failures);
|
||||
}
|
||||
}
|
||||
|
||||
function captureInheritedDirectory(
|
||||
fd: number,
|
||||
canonicalPath: string,
|
||||
label: string,
|
||||
failures: Error[],
|
||||
): boolean {
|
||||
try {
|
||||
assertInheritedDirectory(fd, canonicalPath, label);
|
||||
return true;
|
||||
} catch (error) {
|
||||
failures.push(toError(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function capturePrivateDescriptor(
|
||||
fd: number,
|
||||
label: string,
|
||||
failures: Error[],
|
||||
): Stats | undefined {
|
||||
try {
|
||||
return fstatSync(fd);
|
||||
} catch (error) {
|
||||
failures.push(new Error(`provider guardian inherited ${label} fd is invalid`, {
|
||||
cause: error,
|
||||
}));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function capturePrivateAlias(
|
||||
input: Readonly<{
|
||||
descriptorMetadata: Stats;
|
||||
descriptorTarget: string;
|
||||
expectedDirectory: string;
|
||||
descriptorDirectory: string;
|
||||
grammar: RegExp;
|
||||
label: string;
|
||||
}>,
|
||||
failures: Error[],
|
||||
): BoundPrivateAuthority | undefined {
|
||||
try {
|
||||
return bindPrivateAlias(input);
|
||||
} catch (error) {
|
||||
failures.push(toError(error));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function bindPrivateAlias(input: Readonly<{
|
||||
descriptorMetadata: Stats;
|
||||
descriptorTarget: string;
|
||||
expectedDirectory: string;
|
||||
descriptorDirectory: string;
|
||||
grammar: RegExp;
|
||||
label: string;
|
||||
}>): BoundPrivateAuthority {
|
||||
const descriptorTarget = readlinkSync(input.descriptorTarget);
|
||||
if (path.dirname(descriptorTarget) !== input.expectedDirectory) {
|
||||
throw new TypeError(
|
||||
`provider guardian inherited ${input.label} alias is outside its directory`,
|
||||
);
|
||||
}
|
||||
const leaf = path.basename(descriptorTarget);
|
||||
const match = input.grammar.exec(leaf);
|
||||
if (!match) {
|
||||
throw new TypeError(`provider guardian inherited ${input.label} alias is invalid`);
|
||||
}
|
||||
const boundPath = `${input.descriptorDirectory}/${leaf}`;
|
||||
const pathnameMetadata = lstatSync(boundPath);
|
||||
assertPrivateMetadata(input.descriptorMetadata, pathnameMetadata, input.label);
|
||||
return Object.freeze({
|
||||
identity: Object.freeze({
|
||||
dev: input.descriptorMetadata.dev,
|
||||
ino: input.descriptorMetadata.ino,
|
||||
}),
|
||||
leaf,
|
||||
noncePrefix: match[2]!,
|
||||
path: boundPath,
|
||||
stem: match[1]!,
|
||||
});
|
||||
}
|
||||
|
||||
async function failBootstrap(
|
||||
rawAuthority: BoundPrivateAuthority | undefined,
|
||||
sealedAuthority: BoundPrivateAuthority | undefined,
|
||||
failures: Error[],
|
||||
): Promise<never> {
|
||||
for (const authority of [rawAuthority, sealedAuthority]) {
|
||||
if (!authority) continue;
|
||||
await cleanupOwnedPath(authority.path, authority.identity, failures);
|
||||
}
|
||||
closePrivateFds(failures);
|
||||
writeAggregateDiagnostic("provider guardian bootstrap failed", failures);
|
||||
process.exit(126);
|
||||
}
|
||||
|
||||
function assertPrivateMetadata(
|
||||
descriptorMetadata: Stats,
|
||||
pathnameMetadata: Stats,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!descriptorMetadata.isFile() || !pathnameMetadata.isFile() ||
|
||||
pathnameMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathnameMetadata.dev ||
|
||||
descriptorMetadata.ino !== pathnameMetadata.ino || descriptorMetadata.nlink !== 1 ||
|
||||
pathnameMetadata.nlink !== 1 || (descriptorMetadata.mode & 0o777) !== 0o600 ||
|
||||
(pathnameMetadata.mode & 0o777) !== 0o600 || descriptorMetadata.size !== 0 ||
|
||||
pathnameMetadata.size !== 0
|
||||
) {
|
||||
throw new TypeError(`provider guardian inherited ${label} identity is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function providerKindFromStem(stem: string): ProviderGuardianKind {
|
||||
if (stem === "vulnerability-report") return "vulnerability";
|
||||
if (stem === "provenance-attestation") return "provenance";
|
||||
throw new TypeError("provider guardian inherited private kind is invalid");
|
||||
}
|
||||
|
||||
function consumeChunk(chunk: Buffer | string): void {
|
||||
if (terminal) return;
|
||||
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
if (expectedBytes === undefined && pending.byteLength >= 4) {
|
||||
expectedBytes = pending.readUInt32BE(0);
|
||||
if (expectedBytes <= 0 || expectedBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
|
||||
enqueue(async () => failClosed(126, "provider guardian frame length is invalid"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) {
|
||||
const payload = pending.subarray(4);
|
||||
pending = Buffer.alloc(0);
|
||||
expectedBytes = undefined;
|
||||
enqueue(async () => handleFrame(payload));
|
||||
} else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) {
|
||||
enqueue(async () => failClosed(126, "provider guardian frame has trailing bytes"));
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(operation: () => Promise<void>): void {
|
||||
operations = operations.then(operation).catch(async (error) => {
|
||||
await failClosed(126, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFrame(payload: Buffer): Promise<void> {
|
||||
if (state === "starting") {
|
||||
await establishTransaction(payload);
|
||||
} else if (state === "guarding") {
|
||||
await publishSealedArtifact(payload);
|
||||
} else if (state === "published") {
|
||||
await prepareCommit(payload);
|
||||
} else {
|
||||
await failClosed(126, "provider guardian received data after commit");
|
||||
}
|
||||
}
|
||||
|
||||
async function establishTransaction(payload: Buffer): Promise<void> {
|
||||
const nowEpochMs = Date.now();
|
||||
const guard = decodeProviderGuardianGuard(payload, {
|
||||
nowEpochMs,
|
||||
maxLeaseMs: MAX_PROVIDER_GUARDIAN_LEASE_MS,
|
||||
});
|
||||
if (
|
||||
guard.kind !== bootstrap.kind ||
|
||||
guard.nonce.subarray(0, 16).toString("hex") !== bootstrap.noncePrefix ||
|
||||
providerGuardianRawStagingLeaf(guard.kind, guard.nonce) !== bootstrap.rawStagingLeaf ||
|
||||
providerGuardianSealedTempLeaf(guard.kind, guard.nonce) !== bootstrap.sealedTempLeaf
|
||||
) {
|
||||
throw new TypeError("provider guardian guard does not match inherited private aliases");
|
||||
}
|
||||
assertBoundPrivateLeaf(
|
||||
RAW_STAGING_FD,
|
||||
bootstrap.rawStagingPath,
|
||||
bootstrap.rawIdentity,
|
||||
"raw staging",
|
||||
);
|
||||
assertBoundPrivateLeaf(
|
||||
SEALED_TEMP_FD,
|
||||
bootstrap.sealedTempPath,
|
||||
bootstrap.sealedIdentity,
|
||||
"sealed temp",
|
||||
);
|
||||
transaction = Object.freeze({ guard });
|
||||
|
||||
await link(bootstrap.rawStagingPath, bootstrap.rawPath);
|
||||
assertOwnedPathMetadata(bootstrap.rawStagingPath, bootstrap.rawIdentity, 2, 0o600, 0,
|
||||
"raw staging link");
|
||||
assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 2, 0o600, 0,
|
||||
"canonical raw link");
|
||||
await unlink(bootstrap.rawStagingPath);
|
||||
fsyncSync(RAW_DIRECTORY_FD);
|
||||
assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 1, 0o600, 0,
|
||||
"canonical raw");
|
||||
assertBoundPrivateLeaf(
|
||||
SEALED_TEMP_FD,
|
||||
bootstrap.sealedTempPath,
|
||||
bootstrap.sealedIdentity,
|
||||
"sealed temp",
|
||||
);
|
||||
|
||||
const remainingLeaseMs = guard.deadlineEpochMs - Date.now();
|
||||
if (remainingLeaseMs <= 0) {
|
||||
throw new TypeError("provider guardian guard deadline expired during startup");
|
||||
}
|
||||
deadline = setTimeout(() => {
|
||||
enqueue(async () => failClosed(124, "provider guardian lease deadline expired", true));
|
||||
}, remainingLeaseMs);
|
||||
writeSync(1, encodeProviderGuardianReady({
|
||||
nonce: guard.nonce,
|
||||
rawDev: bootstrap.rawIdentity.dev,
|
||||
rawIno: bootstrap.rawIdentity.ino,
|
||||
sealedTempLeaf: bootstrap.sealedTempLeaf,
|
||||
sealedDev: bootstrap.sealedIdentity.dev,
|
||||
sealedIno: bootstrap.sealedIdentity.ino,
|
||||
}));
|
||||
state = "guarding";
|
||||
}
|
||||
|
||||
function assertBoundPrivateLeaf(
|
||||
fd: number,
|
||||
target: string,
|
||||
identity: OwnedIdentity,
|
||||
label: string,
|
||||
): void {
|
||||
const descriptorMetadata = fstatSync(fd);
|
||||
const pathnameMetadata = lstatSync(target);
|
||||
assertOwnedMetadata(descriptorMetadata, identity, 1, 0o600, 0, label);
|
||||
assertOwnedMetadata(pathnameMetadata, identity, 1, 0o600, 0, label);
|
||||
if (pathnameMetadata.isSymbolicLink()) {
|
||||
throw new TypeError(`provider guardian ${label} alias became symbolic`);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishSealedArtifact(payload: Buffer): Promise<void> {
|
||||
if (!transaction) {
|
||||
throw new Error("provider guardian transaction identity is unavailable");
|
||||
}
|
||||
const publication = decodeProviderGuardianPublish(payload, transaction.guard.nonce);
|
||||
if (
|
||||
publication.sealedDev !== bootstrap.sealedIdentity.dev ||
|
||||
publication.sealedIno !== bootstrap.sealedIdentity.ino
|
||||
) {
|
||||
throw new TypeError("provider guardian publish identity is invalid");
|
||||
}
|
||||
assertOwnedMetadata(
|
||||
fstatSync(SEALED_TEMP_FD),
|
||||
bootstrap.sealedIdentity,
|
||||
1,
|
||||
0o400,
|
||||
publication.size,
|
||||
"sealed publish descriptor",
|
||||
);
|
||||
const pathnameMetadata = await lstat(bootstrap.sealedTempPath);
|
||||
assertOwnedMetadata(
|
||||
pathnameMetadata,
|
||||
bootstrap.sealedIdentity,
|
||||
1,
|
||||
0o400,
|
||||
publication.size,
|
||||
"sealed publish pathname",
|
||||
);
|
||||
if (pathnameMetadata.isSymbolicLink()) {
|
||||
throw new TypeError("provider guardian sealed publish pathname became symbolic");
|
||||
}
|
||||
const actualSha256 = hashInheritedFile(SEALED_TEMP_FD, publication.size);
|
||||
if (actualSha256 !== publication.sha256) {
|
||||
throw new TypeError("provider guardian publish hash is invalid");
|
||||
}
|
||||
try {
|
||||
await lstat(bootstrap.sealedPath);
|
||||
throw new Error("provider guardian sealed output already exists");
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
await link(bootstrap.sealedTempPath, bootstrap.sealedPath);
|
||||
await unlink(bootstrap.sealedTempPath);
|
||||
fsyncSync(EVIDENCE_DIRECTORY_FD);
|
||||
const finalMetadata = await lstat(bootstrap.sealedPath);
|
||||
assertOwnedMetadata(
|
||||
finalMetadata,
|
||||
bootstrap.sealedIdentity,
|
||||
1,
|
||||
0o400,
|
||||
publication.size,
|
||||
"sealed final",
|
||||
);
|
||||
if (finalMetadata.isSymbolicLink()) {
|
||||
throw new TypeError("provider guardian sealed final became symbolic");
|
||||
}
|
||||
state = "published";
|
||||
writeSync(1, encodeProviderGuardianPublished({
|
||||
nonce: transaction.guard.nonce,
|
||||
sealedDev: bootstrap.sealedIdentity.dev,
|
||||
sealedIno: bootstrap.sealedIdentity.ino,
|
||||
}));
|
||||
}
|
||||
|
||||
function assertOwnedPathMetadata(
|
||||
target: string,
|
||||
identity: OwnedIdentity,
|
||||
expectedLinks: number,
|
||||
expectedMode: number,
|
||||
expectedSize: number,
|
||||
label: string,
|
||||
): void {
|
||||
const metadata = lstatSync(target);
|
||||
assertOwnedMetadata(metadata, identity, expectedLinks, expectedMode, expectedSize, label);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`provider guardian ${label} became symbolic`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOwnedMetadata(
|
||||
metadata: Stats,
|
||||
identity: OwnedIdentity,
|
||||
expectedLinks: number,
|
||||
expectedMode: number,
|
||||
expectedSize: number,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!metadata.isFile() || metadata.dev !== identity.dev || metadata.ino !== identity.ino ||
|
||||
metadata.nlink !== expectedLinks || (metadata.mode & 0o777) !== expectedMode ||
|
||||
metadata.size !== expectedSize
|
||||
) {
|
||||
throw new TypeError(`provider guardian ${label} metadata is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function hashInheritedFile(fd: number, size: number): string {
|
||||
const digest = createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(Math.min(65_536, size));
|
||||
let position = 0;
|
||||
while (position < size) {
|
||||
const requested = Math.min(buffer.byteLength, size - position);
|
||||
const bytesRead = readSync(fd, buffer, 0, requested, position);
|
||||
if (bytesRead <= 0) throw new Error("provider guardian sealed publish read was truncated");
|
||||
digest.update(buffer.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
return digest.digest("hex");
|
||||
}
|
||||
|
||||
async function prepareCommit(payload: Buffer): Promise<void> {
|
||||
if (!transaction) throw new Error("provider guardian transaction identity is unavailable");
|
||||
decodeProviderGuardianCommit(payload, transaction.guard.nonce);
|
||||
const removedRaw = await cleanupOwnedProviderReport({
|
||||
reportPath: bootstrap.rawPath,
|
||||
reportDev: bootstrap.rawIdentity.dev,
|
||||
reportIno: bootstrap.rawIdentity.ino,
|
||||
});
|
||||
if (!removedRaw) throw new Error("provider guardian raw output disappeared before commit");
|
||||
state = "commitPending";
|
||||
}
|
||||
|
||||
async function succeedOnCommittedEof(): Promise<void> {
|
||||
const closeErrors: Error[] = [];
|
||||
closePrivateFds(closeErrors);
|
||||
if (closeErrors.length > 0) {
|
||||
await failClosed(
|
||||
126,
|
||||
"provider guardian private descriptor close failed",
|
||||
false,
|
||||
closeErrors,
|
||||
);
|
||||
return;
|
||||
}
|
||||
terminal = true;
|
||||
if (deadline) clearTimeout(deadline);
|
||||
liveness.removeAllListeners();
|
||||
liveness.destroy();
|
||||
closeControlInputBestEffort();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function failClosed(
|
||||
exitCode: number,
|
||||
message: string,
|
||||
forceSignal = false,
|
||||
priorErrors: readonly Error[] = [],
|
||||
): Promise<void> {
|
||||
if (terminal) return;
|
||||
terminal = true;
|
||||
if (deadline) clearTimeout(deadline);
|
||||
liveness.removeAllListeners();
|
||||
liveness.destroy();
|
||||
const failures = [new Error(message), ...priorErrors];
|
||||
await cleanupOwnedPath(bootstrap.rawStagingPath, bootstrap.rawIdentity, failures);
|
||||
await cleanupOwnedPath(bootstrap.rawPath, bootstrap.rawIdentity, failures);
|
||||
await cleanupOwnedPath(bootstrap.sealedTempPath, bootstrap.sealedIdentity, failures);
|
||||
await cleanupOwnedPath(bootstrap.sealedPath, bootstrap.sealedIdentity, failures);
|
||||
closePrivateFds(failures);
|
||||
closeControlInputBestEffort();
|
||||
writeAggregateDiagnostic("provider guardian failed", failures);
|
||||
if (forceSignal) {
|
||||
try {
|
||||
process.kill(process.pid, "SIGKILL");
|
||||
} finally {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
async function cleanupOwnedPath(
|
||||
target: string,
|
||||
identity: OwnedIdentity,
|
||||
errors: Error[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await cleanupOwnedProviderReport({
|
||||
reportPath: target,
|
||||
reportDev: identity.dev,
|
||||
reportIno: identity.ino,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
function closePrivateFds(errors: Error[]): void {
|
||||
if (privateFdsClosed) return;
|
||||
privateFdsClosed = true;
|
||||
for (const fd of [RAW_STAGING_FD, SEALED_TEMP_FD]) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch (error) {
|
||||
errors.push(toError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeControlInputBestEffort(): void {
|
||||
try {
|
||||
closeSync(0);
|
||||
} catch {
|
||||
// Terminal cleanup and the exit status must not depend on a diagnostic fd.
|
||||
}
|
||||
}
|
||||
|
||||
function writeAggregateDiagnostic(label: string, failures: readonly Error[]): void {
|
||||
const aggregate = failures.length > 1
|
||||
? new AggregateError(failures, label, { cause: failures[0] })
|
||||
: failures[0];
|
||||
const detail = aggregate instanceof AggregateError
|
||||
? aggregate.errors.map((error) => toError(error).message).join("; ")
|
||||
: aggregate?.message ?? label;
|
||||
try {
|
||||
writeSync(2, `${label}: ${detail}\n`);
|
||||
} catch {
|
||||
// A closed parent-side pipe must not convert fail-closed termination to exit 0.
|
||||
}
|
||||
}
|
||||
|
||||
function assertInheritedDirectory(fd: number, canonicalPath: string, label: string): void {
|
||||
const descriptorMetadata = fstatSync(fd);
|
||||
const pathMetadata = lstatSync(canonicalPath);
|
||||
if (
|
||||
!descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() ||
|
||||
pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev ||
|
||||
descriptorMetadata.ino !== pathMetadata.ino
|
||||
) {
|
||||
throw new TypeError(`provider guardian inherited ${label} fd is not a directory`);
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, createReadStream, writeSync } from "node:fs";
|
||||
|
||||
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
|
||||
|
||||
const MAX_FRAME_BYTES = 16_777_216;
|
||||
const reportIdentity = parseReportIdentity(process.argv.slice(2));
|
||||
let pending = Buffer.alloc(0);
|
||||
let expectedBytes: number | undefined;
|
||||
let provider: ReturnType<typeof spawn> | undefined;
|
||||
let providerClosed = false;
|
||||
let livenessLost = false;
|
||||
const liveness = createReadStream("", { fd: 0, autoClose: false });
|
||||
|
||||
liveness.on("data", (chunk: Buffer | string) => {
|
||||
if (provider) {
|
||||
terminateForProtocolFailure("provider scope received trailing protocol bytes");
|
||||
return;
|
||||
}
|
||||
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
if (expectedBytes === undefined && pending.byteLength >= 4) {
|
||||
expectedBytes = pending.readUInt32BE(0);
|
||||
if (expectedBytes <= 0 || expectedBytes > MAX_FRAME_BYTES) {
|
||||
terminateForProtocolFailure("provider scope frame length is invalid");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) {
|
||||
launchProvider(pending.subarray(4));
|
||||
pending = Buffer.alloc(0);
|
||||
} else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) {
|
||||
terminateForProtocolFailure("provider scope frame has trailing bytes");
|
||||
}
|
||||
});
|
||||
|
||||
liveness.once("end", () => terminateForParentLoss());
|
||||
liveness.once("error", () => terminateForParentLoss());
|
||||
|
||||
function launchProvider(payload: Buffer): void {
|
||||
const frame = parseFrame(payload);
|
||||
if (
|
||||
frame.reportPath !== reportIdentity.reportPath ||
|
||||
frame.reportDev !== reportIdentity.reportDev ||
|
||||
frame.reportIno !== reportIdentity.reportIno
|
||||
) {
|
||||
throw new TypeError("provider scope frame identity does not match its launch identity");
|
||||
}
|
||||
const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64");
|
||||
provider = spawn("/usr/bin/bwrap", ["--args", "0"], {
|
||||
detached: true,
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
});
|
||||
provider.stdin?.end(bwrapInput);
|
||||
provider.once("error", (error) => finishProvider(frame, null, null, error));
|
||||
provider.once("close", (code, signal) => finishProvider(frame, code, signal));
|
||||
}
|
||||
|
||||
async function finishProvider(
|
||||
frame: ReturnType<typeof parseFrame>,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
error?: Error,
|
||||
): Promise<void> {
|
||||
if (providerClosed) return;
|
||||
providerClosed = true;
|
||||
if (livenessLost) await cleanupOwnedProviderReport(frame);
|
||||
closeLivenessInput();
|
||||
if (error) {
|
||||
writeSync(2, `${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (signal) process.exit(128 + signalNumber(signal));
|
||||
process.exit(code ?? 1);
|
||||
}
|
||||
|
||||
function terminateForParentLoss(): void {
|
||||
if (livenessLost) return;
|
||||
livenessLost = true;
|
||||
if (!provider || providerClosed) {
|
||||
void cleanupAfterParentLossAndExit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(-provider.pid!, "SIGKILL");
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ESRCH")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAfterParentLossAndExit(): Promise<void> {
|
||||
try {
|
||||
await cleanupOwnedProviderReport(reportIdentity);
|
||||
} catch (error) {
|
||||
writeSync(2, `${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
closeLivenessInput();
|
||||
process.exit(125);
|
||||
}
|
||||
|
||||
function terminateForProtocolFailure(message: string): void {
|
||||
writeSync(2, `${message}\n`);
|
||||
terminateForParentLoss();
|
||||
}
|
||||
|
||||
function closeLivenessInput(): void {
|
||||
liveness.removeAllListeners();
|
||||
liveness.destroy();
|
||||
try {
|
||||
closeSync(0);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EBADF")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrame(payload: Buffer): Readonly<{
|
||||
bwrapInputBase64: string;
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
}> {
|
||||
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)) as Record<string, unknown>;
|
||||
if (
|
||||
typeof value.bwrapInputBase64 !== "string" ||
|
||||
typeof value.reportPath !== "string" || !value.reportPath.startsWith("/") ||
|
||||
!Number.isSafeInteger(value.reportDev) || Number(value.reportDev) <= 0 ||
|
||||
!Number.isSafeInteger(value.reportIno) || Number(value.reportIno) <= 0
|
||||
) {
|
||||
throw new TypeError("provider scope frame payload is invalid");
|
||||
}
|
||||
return {
|
||||
bwrapInputBase64: value.bwrapInputBase64,
|
||||
reportPath: value.reportPath,
|
||||
reportDev: Number(value.reportDev),
|
||||
reportIno: Number(value.reportIno),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReportIdentity(arguments_: readonly string[]): Readonly<{
|
||||
cpuSeconds: number;
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
}> {
|
||||
const [cpuValue, reportPath, devValue, inoValue, ...trailing] = arguments_;
|
||||
const cpuSeconds = Number(cpuValue);
|
||||
const reportDev = Number(devValue);
|
||||
const reportIno = Number(inoValue);
|
||||
if (
|
||||
trailing.length > 0 ||
|
||||
!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0 ||
|
||||
typeof reportPath !== "string" || !reportPath.startsWith("/") || reportPath.includes("\0") ||
|
||||
!Number.isSafeInteger(reportDev) || reportDev <= 0 ||
|
||||
!Number.isSafeInteger(reportIno) || reportIno <= 0
|
||||
) {
|
||||
throw new TypeError("provider scope launch identity is invalid");
|
||||
}
|
||||
return { cpuSeconds, reportPath, reportDev, reportIno };
|
||||
}
|
||||
|
||||
function signalNumber(signal: NodeJS.Signals): number {
|
||||
return signal === "SIGKILL" ? 9 : signal === "SIGXCPU" ? 24 : 1;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { randomBytes as cryptoRandomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
type CapturedCandidateArchive,
|
||||
} from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import type { ExpectedPromotionContext, ProviderTrust } from "./provider-evidence.ts";
|
||||
import { validateProviderUpload } from "./provider-upload-validator.ts";
|
||||
|
||||
export type ProviderInvocation = Readonly<{
|
||||
candidateRoot: string;
|
||||
environment: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
|
||||
export async function superviseProviderEvidence(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
||||
trust: ProviderTrust;
|
||||
executeProvider: (invocation: ProviderInvocation) => Promise<void>;
|
||||
captureReport: () => Promise<Buffer>;
|
||||
}>, dependencies: Readonly<{
|
||||
captureArchive?: typeof captureCiCandidateArchive;
|
||||
withVerifiedCandidate?: typeof withVerifiedCapturedCandidate;
|
||||
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
||||
validateUpload?: typeof validateProviderUpload;
|
||||
randomBytes?: (bytes: number) => Buffer;
|
||||
nowEpochMs?: () => number;
|
||||
}> = {}): Promise<Readonly<{
|
||||
evidence: unknown;
|
||||
invocationNonce: string;
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
}>> {
|
||||
const captured = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||
archivePath: input.archivePath,
|
||||
expectedSha256: input.expectedArchiveSha256,
|
||||
});
|
||||
const nonceBytes = (dependencies.randomBytes ?? cryptoRandomBytes)(32);
|
||||
if (nonceBytes.byteLength !== 32) {
|
||||
throw new TypeError("provider invocation nonce must contain exactly 32 bytes");
|
||||
}
|
||||
const invocationNonce = nonceBytes.toString("hex");
|
||||
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
|
||||
const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({
|
||||
captured,
|
||||
verify: async ({ extractionRoot, manifest }) => {
|
||||
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
||||
extractionRoot,
|
||||
expectedManifest: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || !local.identity) {
|
||||
throw new Error(
|
||||
`provider candidate local assessment failed: ${local.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
||||
throw new Error("provider candidate source revision mismatch");
|
||||
}
|
||||
const expectedContext: ExpectedPromotionContext = Object.freeze({
|
||||
run: Object.freeze({ id: input.expectedRun.id, attempt: input.expectedRun.attempt }),
|
||||
source: Object.freeze({
|
||||
revision: local.identity.sourceRevision,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
}),
|
||||
candidate: Object.freeze({
|
||||
archiveSha256: captured.archiveSha256,
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
}),
|
||||
secretScanAttestation: Object.freeze({
|
||||
status: "PASS" as const,
|
||||
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
policySha256: local.identity.secretScan.policySha256,
|
||||
sarifSha256: local.identity.secretScan.sarifSha256,
|
||||
scanInputSha256: local.identity.secretScan.scanInputSha256,
|
||||
}),
|
||||
vulnerabilityInvocationNonce:
|
||||
input.kind === "vulnerability" ? invocationNonce : "0".repeat(64),
|
||||
provenanceInvocationNonce:
|
||||
input.kind === "provenance" ? invocationNonce : "0".repeat(64),
|
||||
});
|
||||
const issuedNow = nowEpochMs();
|
||||
const issuedAt = new Date(issuedNow).toISOString();
|
||||
const expiresAt = new Date(issuedNow + 60 * 60 * 1_000).toISOString();
|
||||
await input.executeProvider({
|
||||
candidateRoot: extractionRoot,
|
||||
environment: providerInvocationEnvironment({
|
||||
kind: input.kind,
|
||||
expectedContext,
|
||||
invocationNonce,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
trust: input.trust,
|
||||
}),
|
||||
});
|
||||
const capturedReport = await input.captureReport();
|
||||
const evidence = await (dependencies.validateUpload ?? validateProviderUpload)({
|
||||
kind: input.kind,
|
||||
verifiedManifest: manifest,
|
||||
archiveSha256: captured.archiveSha256,
|
||||
candidateRoot: extractionRoot,
|
||||
capturedReport,
|
||||
expectedContext,
|
||||
trust: input.trust,
|
||||
nowEpochMs,
|
||||
});
|
||||
return Object.freeze({ evidence, invocationNonce, expectedContext });
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function providerInvocationEnvironment(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
invocationNonce: string;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
trust: ProviderTrust;
|
||||
}>): Readonly<Record<string, string>> {
|
||||
return Object.freeze({
|
||||
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
||||
PROVIDER_EVIDENCE_TYPE:
|
||||
input.kind === "vulnerability"
|
||||
? "vulnerability-report"
|
||||
: "provenance-attestation",
|
||||
PROVIDER_ISSUED_AT: input.issuedAt,
|
||||
PROVIDER_EXPIRES_AT: input.expiresAt,
|
||||
PROVIDER_INVOCATION_NONCE: input.invocationNonce,
|
||||
PROVIDER_KEY_ID: input.trust.keyId,
|
||||
PROVIDER_PUBLIC_KEY_FINGERPRINT: input.trust.publicKeyFingerprint,
|
||||
CI_RUN_ID: input.expectedContext.run.id,
|
||||
CI_RUN_ATTEMPT: String(input.expectedContext.run.attempt),
|
||||
SOURCE_REVISION: input.expectedContext.source.revision,
|
||||
SOURCE_SET_SHA256: input.expectedContext.source.sourceSetSha256,
|
||||
CANDIDATE_ROOT: "/candidate",
|
||||
CANDIDATE_LOCKFILE_PATH: "/candidate/pnpm-lock.yaml",
|
||||
CANDIDATE_ARCHIVE_SHA256: input.expectedContext.candidate.archiveSha256,
|
||||
CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256,
|
||||
CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256,
|
||||
CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256,
|
||||
SECRET_SCAN_STATUS: input.expectedContext.secretScanAttestation.status,
|
||||
SECRET_SCAN_LOCAL_EVIDENCE_ASSESSMENT_SHA256:
|
||||
input.expectedContext.secretScanAttestation.localEvidenceAssessmentSha256,
|
||||
SECRET_SCAN_SOURCE_SET_SHA256:
|
||||
input.expectedContext.secretScanAttestation.sourceSetSha256,
|
||||
SECRET_SCAN_POLICY_SHA256:
|
||||
input.expectedContext.secretScanAttestation.policySha256,
|
||||
SECRET_SCAN_SARIF_SHA256:
|
||||
input.expectedContext.secretScanAttestation.sarifSha256,
|
||||
SECRET_SCAN_INPUT_SHA256:
|
||||
input.expectedContext.secretScanAttestation.scanInputSha256,
|
||||
});
|
||||
}
|
||||
|
||||
export type CaptureArchiveDependency = (
|
||||
input: Readonly<{ archivePath: string; expectedSha256: string }>,
|
||||
) => Promise<CapturedCandidateArchive>;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createPublicKey } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import {
|
||||
providerPublicKeyFingerprint,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
|
||||
export async function readProviderTrust(
|
||||
configuredRoot: string,
|
||||
publicKeyPath: string | undefined,
|
||||
keyId: string | undefined,
|
||||
): Promise<ProviderTrust | null> {
|
||||
if (!publicKeyPath || !keyId?.trim()) return null;
|
||||
try {
|
||||
const root = path.resolve(configuredRoot);
|
||||
const absolute = path.resolve(root, publicKeyPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
const outside =
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative);
|
||||
const bytes = await readBoundedRegularFile({
|
||||
root: outside ? path.dirname(absolute) : root,
|
||||
relativePath: outside
|
||||
? path.basename(absolute)
|
||||
: relative.replaceAll(path.sep, "/"),
|
||||
maxBytes: 1_048_576,
|
||||
});
|
||||
const publicKey = createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||
);
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
validateProviderEvidence,
|
||||
type ExpectedPromotionContext,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
verifyReleaseCandidate,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "./release-candidate.ts";
|
||||
|
||||
export async function validateProviderUpload(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
verifiedManifest: ReleaseCandidateManifest;
|
||||
archiveSha256: string;
|
||||
candidateRoot: string;
|
||||
capturedReport: Buffer;
|
||||
expectedContext: ExpectedPromotionContext;
|
||||
trust: ProviderTrust;
|
||||
nowEpochMs?: () => number;
|
||||
}>): Promise<unknown> {
|
||||
if (
|
||||
input.expectedContext.candidate.archiveSha256 !== input.archiveSha256 ||
|
||||
input.expectedContext.candidate.bundleSha256 !== input.verifiedManifest.bundleSha256 ||
|
||||
input.expectedContext.candidate.distSha256 !== input.verifiedManifest.distSha256 ||
|
||||
input.expectedContext.candidate.lockfileSha256 !== input.verifiedManifest.lockfileSha256
|
||||
) {
|
||||
throw new Error("provider supervisor expected candidate context mismatch");
|
||||
}
|
||||
const verifiedCandidate = await verifyReleaseCandidate(
|
||||
input.verifiedManifest,
|
||||
input.candidateRoot,
|
||||
);
|
||||
if (verifiedCandidate.failures.length > 0) {
|
||||
throw new Error(
|
||||
`provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(input.capturedReport),
|
||||
) as unknown;
|
||||
} catch {
|
||||
throw new TypeError("provider output is not canonical UTF-8 JSON");
|
||||
}
|
||||
const evaluated = validateProviderEvidence({
|
||||
kind: input.kind,
|
||||
value: report,
|
||||
expected: input.expectedContext,
|
||||
trust: input.trust,
|
||||
nowEpochMs: input.nowEpochMs,
|
||||
});
|
||||
if (evaluated.status !== "PASS" || !evaluated.evidence) {
|
||||
throw new Error(
|
||||
`provider evidence context validation failed: ${evaluated.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
return evaluated.evidence;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type RealtimeBoundaryRuleId =
|
||||
| "NATIVE_REALTIME_API_OUTSIDE_ADAPTER"
|
||||
| "PRESENTATION_INTERVAL_OWNER"
|
||||
| "UNSELECTED_REALTIME_RUNTIME_COMPOSED";
|
||||
|
||||
export type RealtimeBoundaryViolation = Readonly<{
|
||||
ruleId: RealtimeBoundaryRuleId;
|
||||
file: string;
|
||||
line: number;
|
||||
}>;
|
||||
|
||||
const SOURCE_EXTENSION = /\.(?:[cm]?ts|tsx)$/u;
|
||||
const OWNED_NATIVE_ROOTS = [
|
||||
"src/adapters/realtime/",
|
||||
"src/adapters/web-push/",
|
||||
] as const;
|
||||
const REALTIME_ADAPTER_IMPORT =
|
||||
/(?:from\s*|import\s*\()\s*["'][^"']*\/adapters\/(?:realtime|web-push)(?:\/[^"']*)?["']/gu;
|
||||
const NATIVE_REALTIME_PATTERNS = [
|
||||
/\bnew\s+(?:WebSocket|EventSource|Notification)\s*\(/gu,
|
||||
/\bNotification\s*\.\s*requestPermission\s*\(/gu,
|
||||
/\.\s*showNotification\s*\(/gu,
|
||||
/\.\s*pushManager\s*\.\s*(?:subscribe|getSubscription)\s*\(/gu,
|
||||
/\bReflect\s*\.\s*get\s*\([^,]+,\s*["'](?:WebSocket|EventSource|Notification|pushManager)["']/gu,
|
||||
] as const;
|
||||
const PRESENTATION_INTERVAL = /\bsetInterval\s*\(/gu;
|
||||
|
||||
export async function scanRealtimeBoundaries(
|
||||
sourceRoot: string,
|
||||
): Promise<readonly RealtimeBoundaryViolation[]> {
|
||||
const absoluteRoot = path.resolve(sourceRoot);
|
||||
const files = await collectSourceFiles(absoluteRoot);
|
||||
const violations: RealtimeBoundaryViolation[] = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
const logicalFile = logicalSourcePath(absoluteRoot, file);
|
||||
inspectFile(source, logicalFile, violations);
|
||||
}
|
||||
return Object.freeze(
|
||||
violations
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.ruleId.localeCompare(right.ruleId),
|
||||
)
|
||||
.map((violation) => Object.freeze(violation)),
|
||||
);
|
||||
}
|
||||
|
||||
function inspectFile(
|
||||
source: string,
|
||||
logicalFile: string,
|
||||
violations: RealtimeBoundaryViolation[],
|
||||
): void {
|
||||
const nativeOwned = OWNED_NATIVE_ROOTS.some((root) =>
|
||||
logicalFile.startsWith(root),
|
||||
);
|
||||
const presentationOwned =
|
||||
logicalFile.startsWith("src/presentation/") ||
|
||||
/^src\/features\/[^/]+\/presentation\//u.test(logicalFile);
|
||||
const compositionBoundary =
|
||||
logicalFile.startsWith("src/bootstrap/") ||
|
||||
/^src\/features\/installed-feature-/u.test(logicalFile);
|
||||
|
||||
const report = (
|
||||
ruleId: RealtimeBoundaryRuleId,
|
||||
index: number,
|
||||
): void => {
|
||||
violations.push({
|
||||
ruleId,
|
||||
file: logicalFile,
|
||||
line: lineAt(source, index),
|
||||
});
|
||||
};
|
||||
|
||||
if (!nativeOwned) {
|
||||
for (const pattern of NATIVE_REALTIME_PATTERNS) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
report(
|
||||
"NATIVE_REALTIME_API_OUTSIDE_ADAPTER",
|
||||
match.index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (presentationOwned) {
|
||||
for (const match of source.matchAll(PRESENTATION_INTERVAL)) {
|
||||
report("PRESENTATION_INTERVAL_OWNER", match.index);
|
||||
}
|
||||
}
|
||||
if (compositionBoundary) {
|
||||
for (const match of source.matchAll(REALTIME_ADAPTER_IMPORT)) {
|
||||
report("UNSELECTED_REALTIME_RUNTIME_COMPOSED", match.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSourceFiles(
|
||||
directory: string,
|
||||
): Promise<readonly string[]> {
|
||||
const output: string[] = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const resolved = path.join(directory, entry.name);
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
!["node_modules", "dist", "artifacts", ".tmp"].includes(
|
||||
entry.name,
|
||||
)
|
||||
) {
|
||||
output.push(...(await collectSourceFiles(resolved)));
|
||||
} else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name)) {
|
||||
output.push(resolved);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function logicalSourcePath(root: string, file: string): string {
|
||||
const workspaceRelative = path
|
||||
.relative(process.cwd(), file)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
if (root === path.resolve("src")) return workspaceRelative;
|
||||
return path.relative(root, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function lineAt(source: string, index: number): number {
|
||||
let line = 1;
|
||||
for (let offset = 0; offset < index; offset += 1) {
|
||||
if (source.charCodeAt(offset) === 10) line += 1;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const COMPATIBILITY_IMPACTS = Object.freeze([
|
||||
"none",
|
||||
"additive",
|
||||
"behavior-change",
|
||||
"breaking",
|
||||
] as const);
|
||||
|
||||
type CompatibilityImpact = (typeof COMPATIBILITY_IMPACTS)[number];
|
||||
type RegistryRecord = Record<string, unknown> & {
|
||||
registryId?: unknown;
|
||||
contract?: unknown;
|
||||
rows?: unknown;
|
||||
};
|
||||
type RegistryChange = {
|
||||
changeId: string;
|
||||
registryId: string;
|
||||
rowName: string;
|
||||
field: string;
|
||||
kind: string;
|
||||
impact: CompatibilityImpact;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
type RegistryDiff = Readonly<{
|
||||
impact: CompatibilityImpact;
|
||||
changes: readonly RegistryChange[];
|
||||
}>;
|
||||
|
||||
const impactRank = new Map(
|
||||
COMPATIBILITY_IMPACTS.map((impact, index) => [impact, index]),
|
||||
);
|
||||
|
||||
export function canonicalizeRegistryValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const projected: unknown[] = value.map(canonicalizeRegistryValue);
|
||||
return projected.every(
|
||||
(item) =>
|
||||
item === null ||
|
||||
["string", "number", "boolean"].includes(typeof item),
|
||||
)
|
||||
? projected.sort((left, right) =>
|
||||
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
||||
)
|
||||
: projected;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalizeRegistryValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function canonicalRegistryJson(value: unknown): string {
|
||||
return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined";
|
||||
}
|
||||
|
||||
export function registrySnapshotDigest(snapshot: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(canonicalRegistryJson(snapshot))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function strongestImpact(
|
||||
current: CompatibilityImpact,
|
||||
candidate: CompatibilityImpact,
|
||||
): CompatibilityImpact {
|
||||
return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0)
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
function valueType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function changeId(
|
||||
registryId: string,
|
||||
rowName: string,
|
||||
field: string,
|
||||
kind: string,
|
||||
): string {
|
||||
return `${registryId}:${rowName}:${field}:${kind}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a semantic diff. Object key and primitive-array ordering is
|
||||
* canonicalized before comparison and therefore cannot create a false change.
|
||||
*/
|
||||
export function diffRegistrySnapshots(
|
||||
before: Readonly<Record<string, unknown>>,
|
||||
after: Readonly<Record<string, unknown>>,
|
||||
): RegistryDiff {
|
||||
const changes: RegistryChange[] = [];
|
||||
let impact: CompatibilityImpact = "none";
|
||||
const beforeRegistryRows = (before.registries ?? []) as RegistryRecord[];
|
||||
const beforeRegistries = new Map<string, RegistryRecord>(
|
||||
beforeRegistryRows.map((registry) => [
|
||||
String(registry.registryId),
|
||||
registry,
|
||||
]),
|
||||
);
|
||||
const afterRegistryRows = (after.registries ?? []) as RegistryRecord[];
|
||||
const afterRegistries = new Map<string, RegistryRecord>(
|
||||
afterRegistryRows.map((registry) => [
|
||||
String(registry.registryId),
|
||||
registry,
|
||||
]),
|
||||
);
|
||||
const registryIds = new Set([
|
||||
...beforeRegistries.keys(),
|
||||
...afterRegistries.keys(),
|
||||
]);
|
||||
|
||||
for (const registryId of [...registryIds].sort()) {
|
||||
const previous = beforeRegistries.get(registryId);
|
||||
const current = afterRegistries.get(registryId);
|
||||
if (!previous || !current) {
|
||||
const changeImpact = previous ? "breaking" : "additive";
|
||||
impact = strongestImpact(impact, changeImpact);
|
||||
changes.push({
|
||||
changeId: changeId(registryId, "*", "*", previous ? "removed" : "added"),
|
||||
registryId,
|
||||
rowName: "*",
|
||||
field: "*",
|
||||
kind: previous ? "registry-removed" : "registry-added",
|
||||
impact: changeImpact,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousContract = (previous.contract ?? {}) as Record<string, unknown>;
|
||||
const currentContract = (current.contract ?? {}) as Record<string, unknown>;
|
||||
const contractFields = new Set([
|
||||
...Object.keys(previousContract),
|
||||
...Object.keys(currentContract),
|
||||
]);
|
||||
for (const field of [...contractFields].sort()) {
|
||||
const beforeHas = Object.hasOwn(previousContract, field);
|
||||
const afterHas = Object.hasOwn(currentContract, field);
|
||||
const beforeValue = previousContract[field];
|
||||
const afterValue = currentContract[field];
|
||||
if (
|
||||
beforeHas &&
|
||||
afterHas &&
|
||||
canonicalRegistryJson(beforeValue) ===
|
||||
canonicalRegistryJson(afterValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const kind = !beforeHas
|
||||
? "contract-field-added"
|
||||
: !afterHas
|
||||
? "contract-field-removed"
|
||||
: "contract-field-changed";
|
||||
impact = strongestImpact(impact, "breaking");
|
||||
changes.push({
|
||||
changeId: changeId(registryId, "$contract", field, kind),
|
||||
registryId,
|
||||
rowName: "$contract",
|
||||
field,
|
||||
kind,
|
||||
impact: "breaking",
|
||||
before: canonicalizeRegistryValue(beforeValue),
|
||||
after: canonicalizeRegistryValue(afterValue),
|
||||
});
|
||||
}
|
||||
|
||||
const breakingFields = new Set(
|
||||
(currentContract.breakingFields ?? []) as string[],
|
||||
);
|
||||
const beforeRows = (previous.rows ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const afterRows = (current.rows ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const rowNames = new Set([
|
||||
...Object.keys(beforeRows),
|
||||
...Object.keys(afterRows),
|
||||
]);
|
||||
for (const rowName of [...rowNames].sort()) {
|
||||
const beforeRow = beforeRows[rowName];
|
||||
const afterRow = afterRows[rowName];
|
||||
if (!beforeRow || !afterRow) {
|
||||
const changeImpact = beforeRow ? "breaking" : "additive";
|
||||
impact = strongestImpact(impact, changeImpact);
|
||||
changes.push({
|
||||
changeId: changeId(
|
||||
registryId,
|
||||
rowName,
|
||||
"*",
|
||||
beforeRow ? "removed" : "added",
|
||||
),
|
||||
registryId,
|
||||
rowName,
|
||||
field: "*",
|
||||
kind: beforeRow ? "row-removed" : "row-added",
|
||||
impact: changeImpact,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fields = new Set([
|
||||
...Object.keys(beforeRow),
|
||||
...Object.keys(afterRow),
|
||||
]);
|
||||
for (const field of [...fields].sort()) {
|
||||
const beforeHas = Object.hasOwn(beforeRow, field);
|
||||
const afterHas = Object.hasOwn(afterRow, field);
|
||||
const beforeValue = beforeRow[field];
|
||||
const afterValue = afterRow[field];
|
||||
if (
|
||||
beforeHas &&
|
||||
afterHas &&
|
||||
canonicalRegistryJson(beforeValue) ===
|
||||
canonicalRegistryJson(afterValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let kind: string;
|
||||
let changeImpact: CompatibilityImpact;
|
||||
if (!beforeHas) {
|
||||
kind = "field-added";
|
||||
changeImpact = "additive";
|
||||
} else if (!afterHas) {
|
||||
kind = "field-removed";
|
||||
changeImpact = "breaking";
|
||||
} else if (valueType(beforeValue) !== valueType(afterValue)) {
|
||||
kind = "field-type-changed";
|
||||
changeImpact = "breaking";
|
||||
} else if (
|
||||
Array.isArray(beforeValue) &&
|
||||
Array.isArray(afterValue) &&
|
||||
beforeValue.some(
|
||||
(item) =>
|
||||
!afterValue.some(
|
||||
(candidate) =>
|
||||
canonicalRegistryJson(candidate) ===
|
||||
canonicalRegistryJson(item),
|
||||
),
|
||||
)
|
||||
) {
|
||||
kind = "allowed-value-removed";
|
||||
changeImpact = "breaking";
|
||||
} else {
|
||||
kind = "field-changed";
|
||||
changeImpact = breakingFields.has(field)
|
||||
? "breaking"
|
||||
: "behavior-change";
|
||||
}
|
||||
impact = strongestImpact(impact, changeImpact);
|
||||
changes.push({
|
||||
changeId: changeId(registryId, rowName, field, kind),
|
||||
registryId,
|
||||
rowName,
|
||||
field,
|
||||
kind,
|
||||
impact: changeImpact,
|
||||
before: canonicalizeRegistryValue(beforeValue),
|
||||
after: canonicalizeRegistryValue(afterValue),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
impact,
|
||||
changes: Object.freeze(changes),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyRegistryBaselineApproval(
|
||||
snapshot: Readonly<Record<string, unknown>>,
|
||||
approval: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const actualDigest = registrySnapshotDigest(snapshot);
|
||||
const approvedDigest = approval.snapshotDigest;
|
||||
return Object.freeze({
|
||||
passed:
|
||||
approval.schemaVersion === 1 &&
|
||||
typeof approval.owner === "string" &&
|
||||
approval.owner.length > 0 &&
|
||||
typeof approval.approvedAt === "string" &&
|
||||
approvedDigest === actualDigest,
|
||||
actualDigest,
|
||||
approvedDigest:
|
||||
typeof approvedDigest === "string" ? approvedDigest : "missing",
|
||||
});
|
||||
}
|
||||
|
||||
export function validateBreakingEvidence(
|
||||
diff: RegistryDiff,
|
||||
evidenceFile: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const entries = (evidenceFile.changes ?? []) as Array<Record<string, unknown>>;
|
||||
const evidence = new Map<string, Record<string, unknown>>(
|
||||
entries.map((entry) => [String(entry.changeId), entry]),
|
||||
);
|
||||
const failures: string[] = [];
|
||||
for (const change of diff.changes.filter(
|
||||
(entry) => entry.impact === "breaking",
|
||||
)) {
|
||||
const entry = evidence.get(change.changeId);
|
||||
if (!entry) {
|
||||
failures.push(`breaking change missing evidence: ${change.changeId}`);
|
||||
continue;
|
||||
}
|
||||
for (const field of [
|
||||
"versionBump",
|
||||
"migration",
|
||||
"compatibilityWindow",
|
||||
"rollback",
|
||||
"owner",
|
||||
]) {
|
||||
const value = entry[field];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
failures.push(
|
||||
`breaking change ${change.changeId} missing non-empty ${field}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const candidateFileSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
bytes: z.int().nonnegative(),
|
||||
sha256,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const releaseCandidateManifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
distSha256: sha256,
|
||||
lockfileSha256: sha256,
|
||||
bundleSha256: sha256,
|
||||
files: z.array(candidateFileSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ReleaseCandidateManifest = z.infer<
|
||||
typeof releaseCandidateManifestSchema
|
||||
>;
|
||||
|
||||
export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||
"artifacts/release/release-candidate.json";
|
||||
export const LOCAL_EVIDENCE_ASSESSMENT_PATH =
|
||||
"artifacts/security/local-evidence-assessment.json";
|
||||
|
||||
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
|
||||
"scripts/contracts/release-artifacts.ts",
|
||||
"scripts/create-release-candidate.ts",
|
||||
"scripts/generate-supply-chain.ts",
|
||||
"scripts/lib/build-manifest-outputs.ts",
|
||||
"scripts/lib/json-schema.ts",
|
||||
"scripts/lib/local-policy-evidence.ts",
|
||||
"scripts/lib/local-release-evidence.ts",
|
||||
"scripts/lib/release-candidate.ts",
|
||||
"scripts/lib/release-input-evidence.ts",
|
||||
"scripts/lib/release-runtime-coherence.ts",
|
||||
"scripts/lib/repository-file-inventory.ts",
|
||||
"scripts/lib/secret-scan-evaluator.ts",
|
||||
"scripts/lib/secret-scan-policy.ts",
|
||||
"scripts/lib/secret-scan.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
"src/features/installed-contract-contributions.ts",
|
||||
"src/features/installed-feature-contracts.ts",
|
||||
] as const);
|
||||
|
||||
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
|
||||
"config/security/dependency-baseline.approval.json",
|
||||
"config/security/dependency-baseline.json",
|
||||
"config/security/dependency-change-evidence.json",
|
||||
"config/security/dependency-policy.json",
|
||||
"config/security/secret-scan-policy.json",
|
||||
"config/security/vulnerability-exceptions.json",
|
||||
"config/security/vulnerability-policy.json",
|
||||
"schemas/artifacts/build-manifest.schema.json",
|
||||
"schemas/artifacts/dependency-inventory.schema.json",
|
||||
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
|
||||
] as const);
|
||||
|
||||
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"pnpm-lock.yaml",
|
||||
"artifacts/performance/bundle.json",
|
||||
"artifacts/quality/vite-module-inventory.json",
|
||||
"artifacts/release/build-manifest.json",
|
||||
"artifacts/release/checksums.txt",
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
"artifacts/release/provenance.json",
|
||||
"artifacts/release/verification.json",
|
||||
"artifacts/release/sbom.cdx.json",
|
||||
"artifacts/security/dependency-diff.json",
|
||||
"artifacts/security/license-report.json",
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
"artifacts/security/scan.sarif",
|
||||
"artifacts/security/supply-chain-coherence.json",
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
"artifacts/security/vulnerability-report.json",
|
||||
...LOCAL_EVIDENCE_POLICY_INPUT_PATHS,
|
||||
]);
|
||||
|
||||
export type DistOutput = Readonly<{
|
||||
path: string;
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export async function collectDistOutputs(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<DistOutput[]> {
|
||||
const distRoot = path.resolve(repositoryRoot, "dist");
|
||||
const files = await regularFilesWithin(distRoot);
|
||||
if (files.length === 0) {
|
||||
throw new Error("dist is missing or empty; run the production build first");
|
||||
}
|
||||
return Promise.all(
|
||||
files.map(async (absolutePath) => {
|
||||
const content = await readFile(absolutePath);
|
||||
return Object.freeze({
|
||||
path: path
|
||||
.relative(repositoryRoot, absolutePath)
|
||||
.replaceAll(path.sep, "/"),
|
||||
bytes: content.byteLength,
|
||||
gzipBytes: gzipSync(content).byteLength,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function distSha256(outputs: readonly DistOutput[]): string {
|
||||
return supplyChainDigest(
|
||||
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||
path: outputPath,
|
||||
bytes,
|
||||
sha256,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createReleaseCandidateManifest(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<ReleaseCandidateManifest> {
|
||||
const outputs = await collectDistOutputs(repositoryRoot);
|
||||
const evidence = await Promise.all(
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS.map((file) =>
|
||||
digestRequiredFile(repositoryRoot, file),
|
||||
),
|
||||
);
|
||||
const files = [
|
||||
...outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||
path: outputPath,
|
||||
bytes,
|
||||
sha256,
|
||||
})),
|
||||
...evidence,
|
||||
].sort((left, right) => asciiCompare(left.path, right.path));
|
||||
const dependencyInventory = JSON.parse(
|
||||
await readFile(
|
||||
path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { lockfileSha256?: unknown };
|
||||
const rawLockfileSha256 = evidence.find(
|
||||
(file) => file.path === "pnpm-lock.yaml",
|
||||
)?.sha256;
|
||||
if (
|
||||
typeof rawLockfileSha256 !== "string" ||
|
||||
dependencyInventory.lockfileSha256 !== rawLockfileSha256
|
||||
) {
|
||||
throw new Error(
|
||||
"raw pnpm-lock digest mismatch with dependency inventory",
|
||||
);
|
||||
}
|
||||
return releaseCandidateManifestSchema.parse({
|
||||
schemaVersion: 1,
|
||||
distSha256: distSha256(outputs),
|
||||
lockfileSha256: rawLockfileSha256,
|
||||
bundleSha256: supplyChainDigest(files),
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function verifyReleaseCandidate(
|
||||
value: unknown,
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<Readonly<{
|
||||
manifest: ReleaseCandidateManifest | null;
|
||||
currentDistSha256: string | null;
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
const parsed = releaseCandidateManifestSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
manifest: null,
|
||||
currentDistSha256: null,
|
||||
failures: Object.freeze(["release candidate manifest schema mismatch"]),
|
||||
});
|
||||
}
|
||||
const failures: string[] = [];
|
||||
let actual: ReleaseCandidateManifest | null = null;
|
||||
try {
|
||||
actual = await createReleaseCandidateManifest(repositoryRoot);
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`release candidate inputs unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (actual) {
|
||||
if (parsed.data.distSha256 !== actual.distSha256) {
|
||||
failures.push("release candidate dist digest mismatch");
|
||||
}
|
||||
if (parsed.data.lockfileSha256 !== actual.lockfileSha256) {
|
||||
failures.push("release candidate lockfile digest mismatch");
|
||||
}
|
||||
if (parsed.data.bundleSha256 !== actual.bundleSha256) {
|
||||
failures.push("release candidate bundle digest mismatch");
|
||||
}
|
||||
if (JSON.stringify(parsed.data.files) !== JSON.stringify(actual.files)) {
|
||||
failures.push("release candidate file set or file digest mismatch");
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
manifest: parsed.data,
|
||||
currentDistSha256: actual?.distSha256 ?? null,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
async function digestRequiredFile(repositoryRoot: string, file: string) {
|
||||
const absolutePath = path.resolve(repositoryRoot, file);
|
||||
const relative = path.relative(repositoryRoot, absolutePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`candidate path escapes repository root: ${file}`);
|
||||
}
|
||||
const metadata = await lstat(absolutePath);
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error(`candidate input is not a regular file: ${file}`);
|
||||
}
|
||||
const content = await readFile(absolutePath);
|
||||
return Object.freeze({
|
||||
path: file,
|
||||
bytes: content.byteLength,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
});
|
||||
}
|
||||
|
||||
async function regularFilesWithin(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
asciiCompare(left.name, right.name),
|
||||
)) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await regularFilesWithin(target)));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(target);
|
||||
} else {
|
||||
throw new Error(`dist contains a non-regular entry: ${target}`);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
|
||||
export async function digestReleaseInputFiles(
|
||||
files: readonly string[],
|
||||
readBytes: (file: string) => Promise<Buffer> = readFile,
|
||||
): Promise<string> {
|
||||
const rows = await Promise.all(
|
||||
[...files].sort().map(async (file) => ({
|
||||
path: file,
|
||||
sha256: createHash("sha256")
|
||||
.update(await readBytes(file))
|
||||
.digest("hex"),
|
||||
})),
|
||||
);
|
||||
return supplyChainDigest(rows);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { isVersionCompatible } from "../../src/application/policies/compatibility.ts";
|
||||
import { verifyContractSet } from "../../src/contracts/contract-set.ts";
|
||||
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
|
||||
import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts";
|
||||
import { compareReleaseToRuntime } from "../../src/contracts/release-tokens.ts";
|
||||
|
||||
export type ReleaseRuntimeCoherenceInput = Readonly<{
|
||||
release: ReleaseArtifact;
|
||||
runtime: Readonly<{
|
||||
BUILD_ID: string;
|
||||
CONFIG_SCHEMA_VERSION: string;
|
||||
API_CONTRACT_VERSION?: string;
|
||||
RELEASE_ID: string;
|
||||
}>;
|
||||
contractPackages: readonly InstalledContractPackageIdentity[];
|
||||
}>;
|
||||
|
||||
export type ReleaseRuntimeCoherence = Readonly<{
|
||||
compatible: boolean;
|
||||
mismatches: readonly string[];
|
||||
warnings: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Verifies the runtime identity using the release schema's own contract model.
|
||||
* V1 retains the scalar compatibility policy. V2 has no scalar projection: its
|
||||
* identity is the exact compiled package tuple set and canonical set digest.
|
||||
*/
|
||||
export async function verifyReleaseRuntimeCoherence(
|
||||
input: ReleaseRuntimeCoherenceInput,
|
||||
): Promise<ReleaseRuntimeCoherence> {
|
||||
if (input.release.schemaVersion === 1) {
|
||||
if (input.runtime.API_CONTRACT_VERSION === undefined) {
|
||||
return compareWithoutContractScalar(input, ["apiContractVersion"]);
|
||||
}
|
||||
return compareReleaseToRuntime(input.release, {
|
||||
...input.runtime,
|
||||
API_CONTRACT_VERSION: input.runtime.API_CONTRACT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
const comparison = compareWithoutContractScalar(input);
|
||||
const contractSet = await verifyContractSet({
|
||||
expected: input.contractPackages,
|
||||
manifest: input.release.contractSet,
|
||||
});
|
||||
if (contractSet.ok) return comparison;
|
||||
|
||||
const mismatches = Object.freeze([
|
||||
...comparison.mismatches,
|
||||
contractSet.code,
|
||||
]);
|
||||
return Object.freeze({
|
||||
compatible: false,
|
||||
mismatches,
|
||||
warnings: comparison.warnings,
|
||||
});
|
||||
}
|
||||
|
||||
function compareWithoutContractScalar(
|
||||
input: Pick<ReleaseRuntimeCoherenceInput, "release" | "runtime">,
|
||||
initialMismatches: readonly string[] = [],
|
||||
): ReleaseRuntimeCoherence {
|
||||
const mismatches = [...initialMismatches];
|
||||
if (input.release.buildId !== input.runtime.BUILD_ID) {
|
||||
mismatches.push("buildId");
|
||||
}
|
||||
if (
|
||||
!isVersionCompatible(
|
||||
input.release.configSchemaVersion,
|
||||
input.runtime.CONFIG_SCHEMA_VERSION,
|
||||
)
|
||||
) {
|
||||
mismatches.push("configSchemaVersion");
|
||||
}
|
||||
const warnings =
|
||||
input.release.releaseId === input.runtime.RELEASE_ID
|
||||
? []
|
||||
: ["releaseId"];
|
||||
return Object.freeze({
|
||||
compatible: mismatches.length === 0,
|
||||
mismatches: Object.freeze(mismatches),
|
||||
warnings: Object.freeze(warnings),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
loadCiGateContract,
|
||||
parseCiGateContract,
|
||||
} from "../contracts/ci-gates.ts";
|
||||
import { generateCiWorkflow } from "../generate-ci-workflow.ts";
|
||||
|
||||
export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([
|
||||
"src", "tests", "recipes", "scripts", "schemas", "config", "public",
|
||||
".gitea", ".storybook", "index.html", "package.json", "tsconfig.base.json",
|
||||
"tsconfig.json", "tsconfig.app.json", "tsconfig.node.json", "tsconfig.test.json",
|
||||
"tsconfig.recipes.json", "tsconfig.web-worker.json", "tsconfig.service-worker.json",
|
||||
"vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts",
|
||||
"playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts", "playwright.visual.config.ts", "eslint.config.ts",
|
||||
".dependency-cruiser.json", ".nvmrc",
|
||||
] as const);
|
||||
|
||||
export function requireRemovalFixtureEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required for removal verification`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function prepareRemovalFixture(
|
||||
root: string,
|
||||
copyTargets: readonly string[] = REMOVAL_FIXTURE_COPY_TARGETS,
|
||||
): Promise<void> {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(root, { recursive: true });
|
||||
for (const target of copyTargets) {
|
||||
await cp(target, path.join(root, target), { recursive: true });
|
||||
}
|
||||
await symlink(path.resolve("node_modules"), path.join(root, "node_modules"), "dir");
|
||||
}
|
||||
|
||||
export function runRemovalFixturePnpm(
|
||||
root: string,
|
||||
pnpmCli: string,
|
||||
script: string,
|
||||
extra: readonly string[] = [],
|
||||
): boolean {
|
||||
return spawnSync(process.execPath, [pnpmCli, script, ...extra], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, CI_CONTRACT_MODE: "removal-fixture" },
|
||||
}).status === 0;
|
||||
}
|
||||
|
||||
export async function filesBelow(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (await Promise.all(entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesBelow(target) : [target];
|
||||
}))).flat();
|
||||
}
|
||||
|
||||
function isWithin(target: string, root: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export async function runtimeImportGraph(
|
||||
root: string,
|
||||
runtimeSourceRoots: readonly string[],
|
||||
): Promise<Readonly<{ dependentTests: readonly string[]; importingFiles: readonly string[] }>> {
|
||||
const files = (await filesBelow(root))
|
||||
.filter((file) => /\.(?:[cm]?ts|tsx)$/u.test(file))
|
||||
.map((file) => path.resolve(file));
|
||||
if (files.length === 0) {
|
||||
throw new Error("removal fixture scanned module universe is empty");
|
||||
}
|
||||
const sourceSet = new Set(files);
|
||||
const runtimeRoots = runtimeSourceRoots.map((entry) => path.resolve(root, entry));
|
||||
const imports = new Map<string, readonly string[]>();
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
const specifiers = [...source.matchAll(/(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu)]
|
||||
.map((match) => match[1])
|
||||
.filter((specifier): specifier is string => typeof specifier === "string" && specifier.startsWith("."));
|
||||
imports.set(file, specifiers.map((specifier) => {
|
||||
const base = path.resolve(path.dirname(file), specifier);
|
||||
return [base, `${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.cts`, path.join(base, "index.ts"), path.join(base, "index.tsx")]
|
||||
.find((candidate) => sourceSet.has(candidate)) ?? base;
|
||||
}));
|
||||
}
|
||||
const memo = new Map<string, boolean>();
|
||||
const reachesRuntime = (file: string, visiting = new Set<string>()): boolean => {
|
||||
if (runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot))) return true;
|
||||
const known = memo.get(file);
|
||||
if (known !== undefined) return known;
|
||||
if (visiting.has(file)) return false;
|
||||
visiting.add(file);
|
||||
const reaches = (imports.get(file) ?? []).some((dependency) =>
|
||||
runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot)) ||
|
||||
(sourceSet.has(dependency) && reachesRuntime(dependency, visiting))
|
||||
);
|
||||
visiting.delete(file);
|
||||
memo.set(file, reaches);
|
||||
return reaches;
|
||||
};
|
||||
const testsRoot = path.resolve(root, "tests");
|
||||
return Object.freeze({
|
||||
dependentTests: Object.freeze(files.filter((file) => isWithin(file, testsRoot) && reachesRuntime(file))),
|
||||
importingFiles: Object.freeze(files.filter((file) =>
|
||||
!runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot)) &&
|
||||
(imports.get(file) ?? []).some((dependency) =>
|
||||
runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot))
|
||||
)
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeRuntimeDependentTests(
|
||||
root: string,
|
||||
runtimeSourceRoots: readonly string[],
|
||||
): Promise<number> {
|
||||
const graph = await runtimeImportGraph(root, runtimeSourceRoots);
|
||||
if (graph.dependentTests.length === 0) {
|
||||
throw new Error("removal fixture: no runtime-dependent tests discovered");
|
||||
}
|
||||
await Promise.all(graph.dependentTests.map((file) => rm(file, { force: true })));
|
||||
return graph.dependentTests.length;
|
||||
}
|
||||
|
||||
export async function assertNoRuntimeImports(
|
||||
root: string,
|
||||
runtimeSourceRoots: readonly string[],
|
||||
capability: string,
|
||||
): Promise<void> {
|
||||
const graph = await runtimeImportGraph(root, runtimeSourceRoots);
|
||||
if (graph.importingFiles.length > 0) {
|
||||
throw new Error(`Removed ${capability} runtime is still imported by: ${graph.importingFiles.map((file) => path.relative(root, file)).join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneScriptOrchestration(
|
||||
scripts: Record<string, string>,
|
||||
orchestrationScript: string,
|
||||
removedScripts: ReadonlySet<string>,
|
||||
): void {
|
||||
const command = scripts[orchestrationScript];
|
||||
if (!command) return;
|
||||
scripts[orchestrationScript] = command.split(" && ").filter((segment) =>
|
||||
![...removedScripts].some((removed) =>
|
||||
new RegExp(`(?:^|\\s)(?:corepack\\s+)?pnpm\\s+${removed.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?:\\s|$)`, "u").test(segment)
|
||||
)
|
||||
).join(" && ");
|
||||
}
|
||||
|
||||
export async function regenerateRemovalFixtureWorkflow(root: string): Promise<void> {
|
||||
const contract = await loadCiGateContract(root, { mode: "removal-fixture" });
|
||||
await generateCiWorkflow({ root, contract, check: false });
|
||||
}
|
||||
|
||||
export async function pruneRemovalFixtureCiContract(options: Readonly<{
|
||||
root: string;
|
||||
removedScripts: ReadonlySet<string>;
|
||||
removedEvidencePathFragments: readonly string[];
|
||||
}>): Promise<void> {
|
||||
const packagePath = path.join(options.root, "package.json");
|
||||
const gatesPath = path.join(options.root, "config/ci/gates.json");
|
||||
const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
for (const script of options.removedScripts) delete packageDocument.scripts[script];
|
||||
packageDocument.scripts["check:ci-workflow"] =
|
||||
"node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture";
|
||||
packageDocument.scripts["check:ci"] =
|
||||
"corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow";
|
||||
|
||||
const contract = structuredClone(
|
||||
parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))),
|
||||
);
|
||||
const removedCommandIds = new Set(
|
||||
contract.commands
|
||||
.filter(({ script }) => options.removedScripts.has(script))
|
||||
.map(({ id }) => id),
|
||||
);
|
||||
const missing = [...options.removedScripts].filter(
|
||||
(script) => !contract.commands.some((command) => command.script === script),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`removal fixture CI command set is incomplete: ${missing.join(", ")}`);
|
||||
}
|
||||
const removedArtifactIds = new Set(
|
||||
contract.artifacts
|
||||
.filter(({ path: artifactPath }) =>
|
||||
options.removedEvidencePathFragments.some((fragment) => artifactPath.includes(fragment))
|
||||
)
|
||||
.map(({ id }) => id),
|
||||
);
|
||||
for (const fragment of options.removedEvidencePathFragments) {
|
||||
if (!contract.artifacts.some(({ path: artifactPath }) => artifactPath.includes(fragment))) {
|
||||
throw new Error(`removal fixture CI evidence is missing: ${fragment}`);
|
||||
}
|
||||
}
|
||||
contract.commands = contract.commands.filter(({ id }) => !removedCommandIds.has(id));
|
||||
contract.artifacts = contract.artifacts
|
||||
.filter(({ id }) => !removedArtifactIds.has(id))
|
||||
.map((artifact) => artifact.production === "command-generated"
|
||||
? {
|
||||
...artifact,
|
||||
producerCommandIds: artifact.producerCommandIds.filter(
|
||||
(commandId) => !removedCommandIds.has(commandId),
|
||||
),
|
||||
}
|
||||
: artifact)
|
||||
.filter((artifact) =>
|
||||
artifact.production !== "command-generated" || artifact.producerCommandIds.length > 0
|
||||
);
|
||||
const retainedArtifactIds = new Set(contract.artifacts.map(({ id }) => id));
|
||||
for (const gate of contract.gates) {
|
||||
gate.commandIds = gate.commandIds.filter((commandId) => !removedCommandIds.has(commandId));
|
||||
gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter((artifactId) =>
|
||||
retainedArtifactIds.has(artifactId)
|
||||
);
|
||||
}
|
||||
const referencedSchemaIds = new Set(contract.artifacts.map(({ schemaId }) => schemaId));
|
||||
contract.artifactSchemas = contract.artifactSchemas.filter(({ id }) =>
|
||||
referencedSchemaIds.has(id)
|
||||
);
|
||||
const validated = parseCiGateContract(contract, { mode: "removal-fixture" });
|
||||
await Promise.all([
|
||||
writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`),
|
||||
writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type GitFileListResult = Readonly<{
|
||||
error?: Error;
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: Buffer;
|
||||
stderr: Buffer;
|
||||
}>;
|
||||
|
||||
export type RepositoryFileInventory = Readonly<{
|
||||
trackedFiles: readonly string[];
|
||||
generatedFiles: readonly string[];
|
||||
files: readonly string[];
|
||||
}>;
|
||||
|
||||
export type RepositoryFileInventoryPolicy = Readonly<{
|
||||
trackedRoots: readonly string[];
|
||||
generatedRoots: readonly string[];
|
||||
optionalRoots: readonly string[];
|
||||
}>;
|
||||
|
||||
type InventoryOptions = Readonly<{
|
||||
repositoryRoot?: string;
|
||||
trackedRoots: readonly string[];
|
||||
generatedRoots?: readonly string[];
|
||||
optionalRoots?: readonly string[];
|
||||
runGit?: (repositoryRoot: string) => GitFileListResult;
|
||||
lstatPath?: (target: string) => Promise<Stats>;
|
||||
realpathPath?: (target: string) => Promise<string>;
|
||||
assertReadable?: (target: string) => Promise<void>;
|
||||
}>;
|
||||
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requiredStringArray(
|
||||
value: unknown,
|
||||
label: string,
|
||||
options: Readonly<{ allowEmpty: boolean }>,
|
||||
): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
(!options.allowEmpty && value.length === 0) ||
|
||||
value.some((entry) => typeof entry !== "string" || entry.length === 0)
|
||||
) {
|
||||
throw new TypeError(`${label} must be an array of non-empty strings`);
|
||||
}
|
||||
if (new Set(value).size !== value.length) {
|
||||
throw new TypeError(`${label} must not contain duplicate roots`);
|
||||
}
|
||||
return Object.freeze([...value] as string[]);
|
||||
}
|
||||
|
||||
export function parseRepositoryFileInventoryPolicy(
|
||||
value: unknown,
|
||||
): RepositoryFileInventoryPolicy {
|
||||
if (!isRecord(value)) {
|
||||
throw new TypeError("repository inventory policy must be an object");
|
||||
}
|
||||
return Object.freeze({
|
||||
trackedRoots: requiredStringArray(value.trackedRoots, "trackedRoots", {
|
||||
allowEmpty: false,
|
||||
}),
|
||||
generatedRoots: requiredStringArray(
|
||||
value.generatedRoots,
|
||||
"generatedRoots",
|
||||
{ allowEmpty: true },
|
||||
),
|
||||
optionalRoots:
|
||||
value.optionalRoots === undefined
|
||||
? Object.freeze([])
|
||||
: requiredStringArray(value.optionalRoots, "optionalRoots", {
|
||||
allowEmpty: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function defaultGitFileList(repositoryRoot: string): GitFileListResult {
|
||||
const result = spawnSync("git", ["ls-files", "-z"], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: "buffer",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return {
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout ?? Buffer.alloc(0),
|
||||
stderr: result.stderr ?? Buffer.alloc(0),
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultAssertReadable(target: string): Promise<void> {
|
||||
const handle = await open(target, "r");
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
export function normalizeRepositoryRelativePath(
|
||||
value: string,
|
||||
label = "repository path",
|
||||
): string {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
path.posix.isAbsolute(value) ||
|
||||
path.win32.isAbsolute(value) ||
|
||||
value.includes("\\") ||
|
||||
value.includes("\0") ||
|
||||
value.endsWith("/")
|
||||
) {
|
||||
throw new TypeError(`${label} must be a repository-relative POSIX path`);
|
||||
}
|
||||
const normalized = path.posix.normalize(value);
|
||||
if (
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized !== value
|
||||
) {
|
||||
throw new TypeError(`${label} must be a repository-relative POSIX path`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isWithinRoot(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function parseGitFileList(result: GitFileListResult): string[] {
|
||||
if (
|
||||
result.error ||
|
||||
result.status !== 0 ||
|
||||
result.signal !== null ||
|
||||
result.stderr.byteLength > 0
|
||||
) {
|
||||
const detail = result.error?.message ?? result.stderr.toString("utf8").trim();
|
||||
throw new Error(
|
||||
`git ls-files failed${result.signal ? ` (${result.signal})` : ""}${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
if (result.stdout.byteLength === 0) return [];
|
||||
if (result.stdout.at(-1) !== 0) {
|
||||
throw new TypeError("git ls-files returned output without a terminal NUL");
|
||||
}
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = utf8Decoder.decode(result.stdout);
|
||||
} catch (error) {
|
||||
throw new TypeError("git ls-files returned malformed UTF-8", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const rows = decoded.slice(0, -1).split("\0");
|
||||
if (rows.some((row) => row.length === 0)) {
|
||||
throw new TypeError("git ls-files returned an empty NUL-delimited path");
|
||||
}
|
||||
const normalized = rows.map((row) =>
|
||||
normalizeRepositoryRelativePath(row, "git ls-files path"),
|
||||
);
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new TypeError("git ls-files returned a duplicate path");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === code
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildRepositoryFileInventory(
|
||||
options: InventoryOptions,
|
||||
): Promise<RepositoryFileInventory> {
|
||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
||||
const lstatPath = options.lstatPath ?? lstat;
|
||||
const realpathPath = options.realpathPath ?? realpath;
|
||||
const assertReadable = options.assertReadable ?? defaultAssertReadable;
|
||||
const trackedRoots = options.trackedRoots.map((root) =>
|
||||
normalizeRepositoryRelativePath(root, "tracked root"),
|
||||
);
|
||||
const generatedRoots = (options.generatedRoots ?? []).map((root) =>
|
||||
normalizeRepositoryRelativePath(root, "generated root"),
|
||||
);
|
||||
const optionalRoots = new Set(
|
||||
(options.optionalRoots ?? []).map((root) =>
|
||||
normalizeRepositoryRelativePath(root, "optional root"),
|
||||
),
|
||||
);
|
||||
for (const root of optionalRoots) {
|
||||
if (!generatedRoots.includes(root)) {
|
||||
throw new TypeError(`optional root is not generated: ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedRepositoryRoot = await realpathPath(repositoryRoot);
|
||||
|
||||
async function validateRegularFile(relativePath: string): Promise<void> {
|
||||
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
||||
if (!isWithinRoot(repositoryRoot, absolutePath)) {
|
||||
throw new TypeError(`repository inventory path escapes root: ${relativePath}`);
|
||||
}
|
||||
const metadata = await lstatPath(absolutePath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`repository inventory path is not a regular file: ${relativePath}`);
|
||||
}
|
||||
const resolvedPath = await realpathPath(absolutePath);
|
||||
if (!isWithinRoot(resolvedRepositoryRoot, resolvedPath)) {
|
||||
throw new TypeError(`repository inventory symlink escapes root: ${relativePath}`);
|
||||
}
|
||||
try {
|
||||
await assertReadable(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(`repository inventory file is unreadable: ${relativePath}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function validateRoot(
|
||||
relativeRoot: string,
|
||||
optional: boolean,
|
||||
): Promise<Stats | null> {
|
||||
const absoluteRoot = path.resolve(repositoryRoot, relativeRoot);
|
||||
try {
|
||||
const metadata = await lstatPath(absoluteRoot);
|
||||
const resolvedRoot = await realpathPath(absoluteRoot);
|
||||
if (!isWithinRoot(resolvedRepositoryRoot, resolvedRoot)) {
|
||||
throw new TypeError(`repository root escapes repository: ${relativeRoot}`);
|
||||
}
|
||||
if (metadata.isSymbolicLink() || (!metadata.isFile() && !metadata.isDirectory())) {
|
||||
throw new TypeError(`repository root is not a regular file or directory: ${relativeRoot}`);
|
||||
}
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
if (optional && hasErrorCode(error, "ENOENT")) return null;
|
||||
throw new Error(`required repository root is unavailable: ${relativeRoot}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of trackedRoots) {
|
||||
await validateRoot(root, false);
|
||||
}
|
||||
|
||||
const trackedFiles = parseGitFileList(
|
||||
(options.runGit ?? defaultGitFileList)(repositoryRoot),
|
||||
).sort();
|
||||
for (const root of trackedRoots) {
|
||||
if (!trackedFiles.some((file) => file === root || file.startsWith(`${root}/`))) {
|
||||
throw new Error(`required tracked file inventory is empty: ${root}`);
|
||||
}
|
||||
}
|
||||
for (const file of trackedFiles) {
|
||||
await validateRegularFile(file);
|
||||
}
|
||||
|
||||
const generatedFiles: string[] = [];
|
||||
async function collectGenerated(relativeTarget: string): Promise<void> {
|
||||
const absoluteTarget = path.resolve(repositoryRoot, relativeTarget);
|
||||
const metadata = await lstatPath(absoluteTarget);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`generated inventory path is a symlink: ${relativeTarget}`);
|
||||
}
|
||||
if (metadata.isFile()) {
|
||||
await validateRegularFile(relativeTarget);
|
||||
generatedFiles.push(relativeTarget);
|
||||
return;
|
||||
}
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new TypeError(`generated inventory path is not regular: ${relativeTarget}`);
|
||||
}
|
||||
const entries = await readdir(absoluteTarget, { withFileTypes: true });
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
await collectGenerated(
|
||||
normalizeRepositoryRelativePath(
|
||||
`${relativeTarget}/${entry.name}`,
|
||||
"generated inventory path",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of generatedRoots) {
|
||||
const metadata = await validateRoot(root, optionalRoots.has(root));
|
||||
if (metadata) await collectGenerated(root);
|
||||
}
|
||||
|
||||
if (new Set(generatedFiles).size !== generatedFiles.length) {
|
||||
throw new TypeError("generated inventory contains duplicate paths");
|
||||
}
|
||||
const uniqueTracked = [...trackedFiles].sort();
|
||||
const uniqueGenerated = [...generatedFiles].sort();
|
||||
const trackedSet = new Set(uniqueTracked);
|
||||
const collisions = uniqueGenerated.filter((file) => trackedSet.has(file));
|
||||
if (collisions.length > 0) {
|
||||
throw new TypeError(
|
||||
`tracked and generated inventory paths collide: ${collisions.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
trackedFiles: Object.freeze(uniqueTracked),
|
||||
generatedFiles: Object.freeze(uniqueGenerated),
|
||||
files: Object.freeze([...uniqueTracked, ...uniqueGenerated].sort()),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
|
||||
|
||||
type WritableHandle = Readonly<{
|
||||
writeFile(data: string): Promise<unknown>;
|
||||
sync(): Promise<unknown>;
|
||||
close(): Promise<unknown>;
|
||||
}>;
|
||||
|
||||
type DirectoryHandle = Readonly<{
|
||||
sync(): Promise<unknown>;
|
||||
close(): Promise<unknown>;
|
||||
}>;
|
||||
|
||||
export type RiskCoverageArtifactFileSystem = Readonly<{
|
||||
openFile(target: string, flags: number, mode: number): Promise<WritableHandle>;
|
||||
openDirectory(target: string): Promise<DirectoryHandle>;
|
||||
rename(source: string, destination: string): Promise<unknown>;
|
||||
rm(target: string, options: Readonly<{ force: true }>): Promise<unknown>;
|
||||
}>;
|
||||
|
||||
type WriterDependencies = Readonly<{
|
||||
createNonce?: () => string;
|
||||
fileSystem?: RiskCoverageArtifactFileSystem;
|
||||
}>;
|
||||
|
||||
type ReadableHandle = Readonly<{
|
||||
stat(): Promise<Stats>;
|
||||
readFile(encoding: "utf8"): Promise<string>;
|
||||
close(): Promise<unknown>;
|
||||
}>;
|
||||
|
||||
type InputDependencies = Readonly<{
|
||||
lstatPath?: (target: string) => Promise<Stats>;
|
||||
realpathPath?: (target: string) => Promise<string>;
|
||||
openFile?: (target: string, flags: number) => Promise<ReadableHandle>;
|
||||
}>;
|
||||
|
||||
const defaultFileSystem: RiskCoverageArtifactFileSystem = Object.freeze({
|
||||
openFile: async (target, flags, mode) => {
|
||||
const handle = await open(target, flags, mode);
|
||||
return {
|
||||
writeFile: async (data) => handle.writeFile(data, "utf8"),
|
||||
sync: async () => handle.sync(),
|
||||
close: async () => handle.close(),
|
||||
};
|
||||
},
|
||||
openDirectory: async (target) => {
|
||||
const handle = await open(target, constants.O_RDONLY);
|
||||
return {
|
||||
sync: async () => handle.sync(),
|
||||
close: async () => handle.close(),
|
||||
};
|
||||
},
|
||||
rename,
|
||||
rm,
|
||||
});
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === code
|
||||
);
|
||||
}
|
||||
|
||||
function isWithin(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function hasStableIdentity(metadata: Stats): boolean {
|
||||
return (
|
||||
Number.isSafeInteger(metadata.dev) &&
|
||||
Number.isSafeInteger(metadata.ino) &&
|
||||
metadata.dev > 0 &&
|
||||
metadata.ino > 0
|
||||
);
|
||||
}
|
||||
|
||||
function sameFileIdentity(before: Stats, after: Stats): boolean {
|
||||
if (!hasStableIdentity(before) || !hasStableIdentity(after)) {
|
||||
throw new TypeError("stable file identity unavailable");
|
||||
}
|
||||
return before.dev === after.dev && before.ino === after.ino;
|
||||
}
|
||||
|
||||
async function rejectSymlinkAncestors(
|
||||
repositoryRoot: string,
|
||||
relativePath: string,
|
||||
label: string,
|
||||
lstatPath: (target: string) => Promise<Stats>,
|
||||
): Promise<void> {
|
||||
let current = repositoryRoot;
|
||||
let currentRelative = "";
|
||||
const directory = path.posix.dirname(relativePath);
|
||||
if (directory === ".") return;
|
||||
for (const segment of directory.split("/")) {
|
||||
current = path.join(current, segment);
|
||||
currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment;
|
||||
const metadata = await lstatPath(current);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`${label} ancestor is a symlink: ${currentRelative}`);
|
||||
}
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new TypeError(`${label} ancestor is not a directory: ${currentRelative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertDirectory(metadata: Stats, relativePath: string): void {
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`artifact ancestor is a symlink: ${relativePath}`);
|
||||
}
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new TypeError(`artifact ancestor is not a directory: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRiskCoverageInput(input: Readonly<{
|
||||
repositoryRoot: string;
|
||||
relativePath: string;
|
||||
label: string;
|
||||
}>, dependencies: InputDependencies = {}): Promise<Readonly<{
|
||||
relativePath: string;
|
||||
absolutePath: string;
|
||||
text: string;
|
||||
}>> {
|
||||
const lstatPath = dependencies.lstatPath ?? lstat;
|
||||
const realpathPath = dependencies.realpathPath ?? realpath;
|
||||
const openFile = dependencies.openFile ??
|
||||
((target: string, flags: number) => open(target, flags));
|
||||
const relativePath = normalizeRepositoryRelativePath(
|
||||
input.relativePath,
|
||||
`${input.label} path`,
|
||||
);
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot);
|
||||
const repositoryRealpath = await realpathPath(repositoryRoot);
|
||||
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
||||
await rejectSymlinkAncestors(
|
||||
repositoryRoot,
|
||||
relativePath,
|
||||
input.label,
|
||||
lstatPath,
|
||||
);
|
||||
const metadata = await lstatPath(absolutePath);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`${input.label} path is a symlink: ${relativePath}`);
|
||||
}
|
||||
if (!metadata.isFile()) {
|
||||
throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`);
|
||||
}
|
||||
const resolvedPath = await realpathPath(absolutePath);
|
||||
if (!isWithin(repositoryRealpath, resolvedPath)) {
|
||||
throw new TypeError(`${input.label} path is outside repository: ${relativePath}`);
|
||||
}
|
||||
|
||||
const handle = await openFile(
|
||||
absolutePath,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const openedMetadata = await handle.stat();
|
||||
if (!openedMetadata.isFile()) {
|
||||
throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`);
|
||||
}
|
||||
if (!sameFileIdentity(metadata, openedMetadata)) {
|
||||
throw new TypeError(`${input.label} path changed during validation: ${relativePath}`);
|
||||
}
|
||||
const text = await handle.readFile("utf8");
|
||||
return Object.freeze({ relativePath, absolutePath, text });
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRiskCoverageArtifactPath(input: Readonly<{
|
||||
repositoryRoot: string;
|
||||
relativePath: string;
|
||||
inputPaths: readonly string[];
|
||||
}>): Promise<string> {
|
||||
const relativePath = normalizeRepositoryRelativePath(
|
||||
input.relativePath,
|
||||
"artifact path",
|
||||
);
|
||||
if (!relativePath.startsWith("artifacts/quality/")) {
|
||||
throw new TypeError("artifact path must be below artifacts/quality");
|
||||
}
|
||||
const normalizedInputs = input.inputPaths.map((inputPath) =>
|
||||
normalizeRepositoryRelativePath(inputPath, "input path"),
|
||||
);
|
||||
if (normalizedInputs.includes(relativePath)) {
|
||||
throw new TypeError(`artifact path must not overwrite an input: ${relativePath}`);
|
||||
}
|
||||
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot);
|
||||
const repositoryRealpath = await realpath(repositoryRoot);
|
||||
const relativeDirectory = path.posix.dirname(relativePath);
|
||||
let currentDirectory = repositoryRoot;
|
||||
let currentRelative = "";
|
||||
for (const segment of relativeDirectory.split("/")) {
|
||||
currentDirectory = path.join(currentDirectory, segment);
|
||||
currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment;
|
||||
let metadata: Stats;
|
||||
try {
|
||||
metadata = await lstat(currentDirectory);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
try {
|
||||
await mkdir(currentDirectory);
|
||||
} catch (mkdirError) {
|
||||
if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError;
|
||||
}
|
||||
metadata = await lstat(currentDirectory);
|
||||
}
|
||||
assertDirectory(metadata, currentRelative);
|
||||
const resolvedDirectory = await realpath(currentDirectory);
|
||||
if (!isWithin(repositoryRealpath, resolvedDirectory)) {
|
||||
throw new TypeError(`artifact ancestor is outside repository: ${currentRelative}`);
|
||||
}
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
||||
let destinationMetadata: Stats | undefined;
|
||||
try {
|
||||
destinationMetadata = await lstat(absolutePath);
|
||||
if (destinationMetadata.isSymbolicLink()) {
|
||||
throw new TypeError(`artifact path is a symlink: ${relativePath}`);
|
||||
}
|
||||
if (!destinationMetadata.isFile()) {
|
||||
throw new TypeError(`artifact path is not a regular file: ${relativePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
if (destinationMetadata) {
|
||||
if (!hasStableIdentity(destinationMetadata)) {
|
||||
throw new TypeError("artifact stable file identity unavailable");
|
||||
}
|
||||
const destinationRealpath = await realpath(absolutePath);
|
||||
for (const inputPath of normalizedInputs) {
|
||||
const inputAbsolutePath = path.resolve(repositoryRoot, inputPath);
|
||||
const inputRealpath = await realpath(inputAbsolutePath);
|
||||
const inputMetadata = await lstat(inputAbsolutePath);
|
||||
if (!hasStableIdentity(inputMetadata)) {
|
||||
throw new TypeError(`input stable file identity unavailable: ${inputPath}`);
|
||||
}
|
||||
if (
|
||||
inputRealpath === destinationRealpath ||
|
||||
(inputMetadata.dev === destinationMetadata.dev &&
|
||||
inputMetadata.ino === destinationMetadata.ino)
|
||||
) {
|
||||
throw new TypeError(`artifact path is the same file as an input: ${inputPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
export async function writeRiskCoverageArtifactAtomic(
|
||||
input: Readonly<{
|
||||
repositoryRoot: string;
|
||||
relativePath: string;
|
||||
inputPaths: readonly string[];
|
||||
value: unknown;
|
||||
}>,
|
||||
dependencies: WriterDependencies = {},
|
||||
): Promise<void> {
|
||||
const serialized = JSON.stringify(input.value, null, 2);
|
||||
if (serialized === undefined) {
|
||||
throw new TypeError("risk coverage artifact is not JSON serializable");
|
||||
}
|
||||
const destination = await resolveRiskCoverageArtifactPath(input);
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(destination),
|
||||
`.${path.basename(destination)}.${(dependencies.createNonce ?? randomUUID)()}.tmp`,
|
||||
);
|
||||
const fileSystem = dependencies.fileSystem ?? defaultFileSystem;
|
||||
let ownsTemporaryFile = false;
|
||||
try {
|
||||
const handle = await fileSystem.openFile(
|
||||
temporaryPath,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
ownsTemporaryFile = true;
|
||||
let primaryFailure: unknown;
|
||||
try {
|
||||
await handle.writeFile(`${serialized}\n`);
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
primaryFailure = error;
|
||||
}
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
primaryFailure ??= error;
|
||||
}
|
||||
if (primaryFailure !== undefined) throw primaryFailure;
|
||||
|
||||
await fileSystem.rename(temporaryPath, destination);
|
||||
ownsTemporaryFile = false;
|
||||
const directoryHandle = await fileSystem.openDirectory(path.dirname(destination));
|
||||
try {
|
||||
try {
|
||||
await directoryHandle.sync();
|
||||
} catch (error) {
|
||||
// Windows and some filesystems do not support fsync on directory handles.
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await directoryHandle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (ownsTemporaryFile) {
|
||||
try {
|
||||
await fileSystem.rm(temporaryPath, { force: true });
|
||||
} catch {
|
||||
// Preserve the publication failure and clean only our nonce-owned path.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
import { constants, type Dirent, type Stats } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import babelParser from "@babel/eslint-parser";
|
||||
|
||||
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
|
||||
|
||||
const coverageMetrics = [
|
||||
"lines",
|
||||
"statements",
|
||||
"functions",
|
||||
"branches",
|
||||
] as const;
|
||||
const teamIdPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
const maximumWaiverDurationMs = 90 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
export const REQUIRED_HIGH_RISK_PATHS = Object.freeze([
|
||||
"src/adapters/http/http-execution-v3.ts",
|
||||
"src/adapters/http/request-builder.ts",
|
||||
"src/adapters/http/bounded-body-reader.ts",
|
||||
"src/adapters/http/bounded-json.ts",
|
||||
"src/bootstrap/read-bounded-boot-json.ts",
|
||||
"src/adapters/service-worker/service-worker-lifecycle.ts",
|
||||
"src/adapters/query-cache/server-state-scope-runtime.ts",
|
||||
"src/bootstrap/load-release-manifest.ts",
|
||||
] as const);
|
||||
|
||||
type CoverageMetric = (typeof coverageMetrics)[number];
|
||||
type Thresholds = Readonly<Record<CoverageMetric, number>>;
|
||||
type CoverageCounter = Readonly<{
|
||||
total: number;
|
||||
covered: number;
|
||||
skipped: number;
|
||||
pct: number;
|
||||
}>;
|
||||
type CoverageMetrics = Readonly<Record<CoverageMetric, CoverageCounter>>;
|
||||
|
||||
export type RiskCoveragePolicy = Readonly<{
|
||||
schemaVersion: 2;
|
||||
repositoryBaseline: number;
|
||||
generatedPaths: readonly string[];
|
||||
summary: Thresholds;
|
||||
criticalModules: readonly Readonly<{
|
||||
path: string;
|
||||
owner: string;
|
||||
minimum: Thresholds;
|
||||
}>[];
|
||||
highRiskPaths: readonly string[];
|
||||
waivers: readonly Readonly<{
|
||||
path: string;
|
||||
owner: string;
|
||||
reason: string;
|
||||
expiresAt: string;
|
||||
}>[];
|
||||
}>;
|
||||
|
||||
export type ProductionModuleInventory = Readonly<{
|
||||
files: readonly string[];
|
||||
preExclusionTotal: number;
|
||||
generatedExclusions: readonly string[];
|
||||
counterBearingModules: readonly string[];
|
||||
counterlessModules: readonly string[];
|
||||
}>;
|
||||
|
||||
export type RiskCoverageResult = Readonly<{
|
||||
status: "PASS" | "FAIL";
|
||||
selectedTotal: number;
|
||||
repositoryTotal: number;
|
||||
counterBearingTotal: number;
|
||||
instrumentedCounterBearingTotal: number;
|
||||
counterlessTotal: number;
|
||||
counterlessModules: readonly string[];
|
||||
preExclusionTotal: number;
|
||||
generatedExclusionCount: number;
|
||||
generatedExclusions: readonly string[];
|
||||
ownershipScope: "ALL_POLICY_HIGH_RISK";
|
||||
ownedHighRiskPaths: readonly string[];
|
||||
waivedHighRiskPaths: readonly string[];
|
||||
uncoveredModules: readonly string[];
|
||||
results: readonly Readonly<{
|
||||
scope: string;
|
||||
metric: CoverageMetric;
|
||||
threshold: number;
|
||||
received: number;
|
||||
passed: boolean;
|
||||
}>[];
|
||||
failures: readonly string[];
|
||||
}>;
|
||||
|
||||
type ReadableFileHandle = Readonly<{
|
||||
stat(): Promise<Stats>;
|
||||
readFile(encoding: "utf8"): Promise<string>;
|
||||
close(): Promise<unknown>;
|
||||
}>;
|
||||
type InventoryOptions = Readonly<{
|
||||
repositoryRoot?: string;
|
||||
generatedPaths?: readonly string[];
|
||||
readDirectory?: (target: string) => Promise<Dirent[]>;
|
||||
lstatPath?: (target: string) => Promise<Stats>;
|
||||
realpathPath?: (target: string) => Promise<string>;
|
||||
openFile?: (target: string, flags: number) => Promise<ReadableFileHandle>;
|
||||
}>;
|
||||
|
||||
class FileIdentityChangedError extends Error {}
|
||||
class StableFileIdentityUnavailableError extends Error {}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new TypeError(`${label} has unknown fields: ${unknown.sort().join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function exactSourcePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
["*", "?", "[", "]", "{", "}"].some((character) => value.includes(character))
|
||||
) {
|
||||
throw new TypeError(`${label} must be an exact repository-relative POSIX path`);
|
||||
}
|
||||
const normalized = normalizeRepositoryRelativePath(value, label);
|
||||
if (!normalized.startsWith("src/") || !/\.tsx?$/u.test(normalized)) {
|
||||
throw new TypeError(`${label} must identify a TypeScript module below src`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function uniquePaths(
|
||||
value: unknown,
|
||||
label: string,
|
||||
options: Readonly<{ allowEmpty: boolean }> = { allowEmpty: true },
|
||||
): readonly string[] {
|
||||
if (!Array.isArray(value) || (!options.allowEmpty && value.length === 0)) {
|
||||
throw new TypeError(
|
||||
`${label} must be an array${options.allowEmpty ? "" : " with at least one path"}`,
|
||||
);
|
||||
}
|
||||
const paths = value.map((entry) => exactSourcePath(entry, `${label} entry`));
|
||||
if (new Set(paths).size !== paths.length) {
|
||||
throw new TypeError(`${label} contains a duplicate path`);
|
||||
}
|
||||
return Object.freeze(paths);
|
||||
}
|
||||
|
||||
function teamId(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !teamIdPattern.test(value)) {
|
||||
throw new TypeError(`${label} must be a canonical team id`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function thresholds(value: unknown, label: string): Thresholds {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
||||
assertExactKeys(value, coverageMetrics, label);
|
||||
if (coverageMetrics.some((metric) => !(metric in value))) {
|
||||
throw new TypeError(`${label} must define all coverage metrics`);
|
||||
}
|
||||
const parsed = {} as Record<CoverageMetric, number>;
|
||||
for (const metric of coverageMetrics) {
|
||||
const threshold = value[metric];
|
||||
if (
|
||||
typeof threshold !== "number" ||
|
||||
!Number.isFinite(threshold) ||
|
||||
threshold <= 0 ||
|
||||
threshold > 100
|
||||
) {
|
||||
throw new TypeError(
|
||||
`${label}.${metric} minimum must be a finite number greater than 0 and at most 100`,
|
||||
);
|
||||
}
|
||||
parsed[metric] = threshold;
|
||||
}
|
||||
return Object.freeze(parsed);
|
||||
}
|
||||
|
||||
function waiverReason(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length < 12 || value.length > 240) {
|
||||
throw new TypeError(`${label} must contain 12 to 240 characters`);
|
||||
}
|
||||
if (value !== value.trim()) {
|
||||
throw new TypeError(`${label} must not contain surrounding whitespace`);
|
||||
}
|
||||
if (
|
||||
[...value].some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint <= 31 || codePoint === 127;
|
||||
})
|
||||
) {
|
||||
throw new TypeError(`${label} must not contain control characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseRiskCoveragePolicy(
|
||||
value: unknown,
|
||||
options: Readonly<{ now?: number }> = {},
|
||||
): RiskCoveragePolicy {
|
||||
if (!isRecord(value)) throw new TypeError("risk coverage policy must be an object");
|
||||
assertExactKeys(
|
||||
value,
|
||||
[
|
||||
"schemaVersion",
|
||||
"repositoryBaseline",
|
||||
"generatedPaths",
|
||||
"summary",
|
||||
"criticalModules",
|
||||
"highRiskPaths",
|
||||
"waivers",
|
||||
],
|
||||
"risk coverage policy",
|
||||
);
|
||||
if (value.schemaVersion !== 2) {
|
||||
throw new TypeError("risk coverage policy schemaVersion must be 2");
|
||||
}
|
||||
if (
|
||||
typeof value.repositoryBaseline !== "number" ||
|
||||
!Number.isSafeInteger(value.repositoryBaseline) ||
|
||||
value.repositoryBaseline <= 0
|
||||
) {
|
||||
throw new TypeError("repositoryBaseline must be a positive safe integer");
|
||||
}
|
||||
|
||||
const generatedPaths = uniquePaths(value.generatedPaths, "generatedPaths");
|
||||
const highRiskPaths = uniquePaths(value.highRiskPaths, "highRiskPaths", {
|
||||
allowEmpty: false,
|
||||
});
|
||||
if (!Array.isArray(value.criticalModules) || value.criticalModules.length === 0) {
|
||||
throw new TypeError("criticalModules must be a non-empty array");
|
||||
}
|
||||
const criticalModules = value.criticalModules.map((candidate, index) => {
|
||||
if (!isRecord(candidate)) {
|
||||
throw new TypeError(`criticalModules[${index}] must be an object`);
|
||||
}
|
||||
assertExactKeys(candidate, ["path", "owner", "minimum"], `criticalModules[${index}]`);
|
||||
return Object.freeze({
|
||||
path: exactSourcePath(candidate.path, `criticalModules[${index}].path`),
|
||||
owner: teamId(candidate.owner, `criticalModules[${index}].owner`),
|
||||
minimum: thresholds(candidate.minimum, `criticalModules[${index}].minimum`),
|
||||
});
|
||||
});
|
||||
if (new Set(criticalModules.map((entry) => entry.path)).size !== criticalModules.length) {
|
||||
throw new TypeError("criticalModules contains a duplicate path");
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.waivers)) throw new TypeError("waivers must be an array");
|
||||
const currentTime = options.now ?? Date.now();
|
||||
if (!Number.isFinite(currentTime)) throw new TypeError("policy time must be finite");
|
||||
const waivers = value.waivers.map((candidate, index) => {
|
||||
if (!isRecord(candidate)) throw new TypeError(`waivers[${index}] must be an object`);
|
||||
assertExactKeys(candidate, ["path", "owner", "reason", "expiresAt"], `waivers[${index}]`);
|
||||
const waiverPath = exactSourcePath(candidate.path, `waivers[${index}].path`);
|
||||
const expiresAt = candidate.expiresAt;
|
||||
if (typeof expiresAt !== "string") {
|
||||
throw new TypeError(`waivers[${index}].expiresAt must be a canonical UTC ISO timestamp`);
|
||||
}
|
||||
const expiry = Date.parse(expiresAt);
|
||||
if (!Number.isFinite(expiry) || new Date(expiry).toISOString() !== expiresAt) {
|
||||
throw new TypeError(`waivers[${index}].expiresAt must be a canonical UTC ISO timestamp`);
|
||||
}
|
||||
if (expiry <= currentTime) throw new TypeError(`waivers[${index}] is expired`);
|
||||
if (expiry - currentTime > maximumWaiverDurationMs) {
|
||||
throw new TypeError(`waivers[${index}] expiry must be within 90 days`);
|
||||
}
|
||||
if (!highRiskPaths.includes(waiverPath)) {
|
||||
throw new TypeError(`waivers[${index}] is stale because ${waiverPath} is not high-risk`);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: waiverPath,
|
||||
owner: teamId(candidate.owner, `waivers[${index}].owner`),
|
||||
reason: waiverReason(candidate.reason, `waivers[${index}].reason`),
|
||||
expiresAt,
|
||||
});
|
||||
});
|
||||
if (new Set(waivers.map((entry) => entry.path)).size !== waivers.length) {
|
||||
throw new TypeError("waivers contains a duplicate path");
|
||||
}
|
||||
|
||||
const criticalPaths = new Set(criticalModules.map((entry) => entry.path));
|
||||
const waiverPaths = new Set(waivers.map((entry) => entry.path));
|
||||
for (const highRiskPath of highRiskPaths) {
|
||||
if (criticalPaths.has(highRiskPath) && waiverPaths.has(highRiskPath)) {
|
||||
throw new TypeError(
|
||||
`high-risk module cannot have both a critical owner and waiver: ${highRiskPath}`,
|
||||
);
|
||||
}
|
||||
if (!criticalPaths.has(highRiskPath) && !waiverPaths.has(highRiskPath)) {
|
||||
throw new TypeError(`high-risk module has no owner or waiver: ${highRiskPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 2,
|
||||
repositoryBaseline: value.repositoryBaseline,
|
||||
generatedPaths,
|
||||
summary: thresholds(value.summary, "summary"),
|
||||
criticalModules: Object.freeze(criticalModules),
|
||||
highRiskPaths,
|
||||
waivers: Object.freeze(waivers),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseRepositoryRiskCoveragePolicy(
|
||||
value: unknown,
|
||||
options: Readonly<{ now?: number }> = {},
|
||||
): RiskCoveragePolicy {
|
||||
const policy = parseRiskCoveragePolicy(value, options);
|
||||
for (const requiredPath of REQUIRED_HIGH_RISK_PATHS) {
|
||||
if (!policy.highRiskPaths.includes(requiredPath)) {
|
||||
throw new TypeError(`required high-risk path is missing: ${requiredPath}`);
|
||||
}
|
||||
if (policy.generatedPaths.includes(requiredPath)) {
|
||||
throw new TypeError(
|
||||
`required high-risk path cannot be generated-excluded: ${requiredPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
function isWithin(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function hasStableIdentity(metadata: Stats): boolean {
|
||||
return (
|
||||
Number.isSafeInteger(metadata.dev) &&
|
||||
Number.isSafeInteger(metadata.ino) &&
|
||||
metadata.dev > 0 &&
|
||||
metadata.ino > 0
|
||||
);
|
||||
}
|
||||
|
||||
function sameFileIdentity(before: Stats, after: Stats): boolean {
|
||||
if (!hasStableIdentity(before) || !hasStableIdentity(after)) {
|
||||
throw new StableFileIdentityUnavailableError(
|
||||
"stable file identity unavailable",
|
||||
);
|
||||
}
|
||||
return before.dev === after.dev && before.ino === after.ino;
|
||||
}
|
||||
|
||||
export function isProductionModulePath(relativePath: string): boolean {
|
||||
return (
|
||||
/\.tsx?$/u.test(relativePath) &&
|
||||
!/\.d\.ts$/u.test(relativePath) &&
|
||||
!/\.stories\.tsx?$/u.test(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
type TypeScriptAstNode = Readonly<{
|
||||
type?: unknown;
|
||||
body?: unknown;
|
||||
declaration?: unknown;
|
||||
declare?: unknown;
|
||||
const?: unknown;
|
||||
importKind?: unknown;
|
||||
}>;
|
||||
|
||||
function statementIsCoverageCounterBearing(value: unknown): boolean {
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
throw new TypeError("TypeScript parser returned an invalid statement");
|
||||
}
|
||||
const statement = value as TypeScriptAstNode;
|
||||
if (
|
||||
[
|
||||
"EmptyStatement",
|
||||
"TSDeclareFunction",
|
||||
"TSInterfaceDeclaration",
|
||||
"TSNamespaceExportDeclaration",
|
||||
"TSTypeAliasDeclaration",
|
||||
].includes(statement.type as string)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (statement.type === "ImportDeclaration") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
statement.type === "ExportAllDeclaration" ||
|
||||
statement.type === "TSExportAssignment"
|
||||
) {
|
||||
return statement.type === "TSExportAssignment";
|
||||
}
|
||||
if (
|
||||
statement.type === "ExportNamedDeclaration" ||
|
||||
statement.type === "ExportDefaultDeclaration"
|
||||
) {
|
||||
return statement.declaration !== null && statement.declaration !== undefined
|
||||
? statementIsCoverageCounterBearing(statement.declaration)
|
||||
: false;
|
||||
}
|
||||
if (
|
||||
statement.type === "FunctionDeclaration" ||
|
||||
statement.type === "VariableDeclaration" ||
|
||||
statement.type === "ClassDeclaration" ||
|
||||
statement.type === "TSModuleDeclaration"
|
||||
) {
|
||||
return statement.declare !== true &&
|
||||
(statement.type !== "FunctionDeclaration" || statement.body !== null);
|
||||
}
|
||||
if (statement.type === "TSEnumDeclaration") {
|
||||
return statement.declare !== true && statement.const !== true;
|
||||
}
|
||||
if (statement.type === "TSImportEqualsDeclaration") {
|
||||
return statement.importKind !== "type" && statement.declare !== true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasCoverageCounterBearingStatements(
|
||||
source: string,
|
||||
relativePath: string,
|
||||
): boolean {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = babelParser.parse(source, {
|
||||
sourceType: "module",
|
||||
requireConfigFile: false,
|
||||
filePath: relativePath,
|
||||
babelOptions: {
|
||||
parserOpts: {
|
||||
plugins: [
|
||||
"typescript",
|
||||
...(relativePath.endsWith(".tsx") ? ["jsx"] : []),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TypeError(`production module has invalid TypeScript syntax: ${relativePath}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.body)) {
|
||||
throw new TypeError("TypeScript parser returned an invalid program");
|
||||
}
|
||||
return parsed.body.some(statementIsCoverageCounterBearing);
|
||||
}
|
||||
|
||||
export async function buildProductionModuleInventory(
|
||||
options: InventoryOptions = {},
|
||||
): Promise<ProductionModuleInventory> {
|
||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
||||
const sourceRoot = path.join(repositoryRoot, "src");
|
||||
const readDirectory = options.readDirectory ??
|
||||
((target: string) => readdir(target, { withFileTypes: true }));
|
||||
const lstatPath = options.lstatPath ?? lstat;
|
||||
const realpathPath = options.realpathPath ?? realpath;
|
||||
const openFile = options.openFile ??
|
||||
((target: string, flags: number) => open(target, flags));
|
||||
const generatedPaths = uniquePaths(options.generatedPaths ?? [], "generatedPaths");
|
||||
const generated = new Set(generatedPaths);
|
||||
const repositoryRealpath = await realpathPath(repositoryRoot);
|
||||
const sourceMetadata = await lstatPath(sourceRoot);
|
||||
if (!sourceMetadata.isDirectory() || sourceMetadata.isSymbolicLink()) {
|
||||
throw new TypeError("production source root is not a regular directory: src");
|
||||
}
|
||||
const sourceRealpath = await realpathPath(sourceRoot);
|
||||
if (!isWithin(repositoryRealpath, sourceRealpath)) {
|
||||
throw new TypeError("production source root is outside repository");
|
||||
}
|
||||
|
||||
const allModules: string[] = [];
|
||||
const counterBearingModules: string[] = [];
|
||||
const counterlessModules: string[] = [];
|
||||
async function visit(relativeDirectory: string): Promise<void> {
|
||||
const absoluteDirectory = path.join(repositoryRoot, relativeDirectory);
|
||||
const entries = await readDirectory(absoluteDirectory);
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const relativeTarget = normalizeRepositoryRelativePath(
|
||||
`${relativeDirectory}/${entry.name}`,
|
||||
"production inventory path",
|
||||
);
|
||||
const absoluteTarget = path.join(repositoryRoot, relativeTarget);
|
||||
const metadata = await lstatPath(absoluteTarget);
|
||||
if (metadata.isSymbolicLink() || entry.isSymbolicLink()) {
|
||||
throw new TypeError(`production inventory path is a symlink: ${relativeTarget}`);
|
||||
}
|
||||
if (metadata.isDirectory()) {
|
||||
await visit(relativeTarget);
|
||||
continue;
|
||||
}
|
||||
if (!isProductionModulePath(relativeTarget)) continue;
|
||||
if (!metadata.isFile()) {
|
||||
throw new TypeError(`production inventory path is not a regular file: ${relativeTarget}`);
|
||||
}
|
||||
const resolvedTarget = await realpathPath(absoluteTarget);
|
||||
if (!isWithin(repositoryRealpath, resolvedTarget)) {
|
||||
throw new TypeError(`production inventory path is outside repository: ${relativeTarget}`);
|
||||
}
|
||||
let handle: ReadableFileHandle | undefined;
|
||||
let source: string;
|
||||
try {
|
||||
handle = await openFile(
|
||||
absoluteTarget,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
const openedMetadata = await handle.stat();
|
||||
if (!openedMetadata.isFile()) {
|
||||
throw new TypeError("opened target is not a regular file");
|
||||
}
|
||||
if (!sameFileIdentity(metadata, openedMetadata)) {
|
||||
throw new FileIdentityChangedError("opened file identity changed");
|
||||
}
|
||||
source = await handle.readFile("utf8");
|
||||
} catch (error) {
|
||||
if (error instanceof StableFileIdentityUnavailableError) {
|
||||
throw new Error(
|
||||
`production inventory stable file identity unavailable: ${relativeTarget}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (error instanceof FileIdentityChangedError) {
|
||||
throw new Error(
|
||||
`production inventory file changed during validation: ${relativeTarget}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw new Error(`production inventory file is unreadable: ${relativeTarget}`, {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
allModules.push(relativeTarget);
|
||||
if (hasCoverageCounterBearingStatements(source, relativeTarget)) {
|
||||
counterBearingModules.push(relativeTarget);
|
||||
} else {
|
||||
counterlessModules.push(relativeTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit("src");
|
||||
for (const generatedPath of generatedPaths) {
|
||||
if (!allModules.includes(generatedPath)) {
|
||||
throw new TypeError(`generated path is stale or not a production module: ${generatedPath}`);
|
||||
}
|
||||
}
|
||||
const inventory = allModules.filter((file) => !generated.has(file)).sort();
|
||||
if (inventory.length === 0) throw new Error("production module inventory is empty");
|
||||
if (new Set(inventory).size !== inventory.length) {
|
||||
throw new TypeError("production module inventory contains a duplicate path");
|
||||
}
|
||||
return Object.freeze({
|
||||
files: Object.freeze(inventory),
|
||||
preExclusionTotal: allModules.length,
|
||||
generatedExclusions: Object.freeze([...generatedPaths].sort()),
|
||||
counterBearingModules: Object.freeze(
|
||||
counterBearingModules.filter((file) => !generated.has(file)).sort(),
|
||||
),
|
||||
counterlessModules: Object.freeze(
|
||||
counterlessModules.filter((file) => !generated.has(file)).sort(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCoverageProducerPath(input: Readonly<{
|
||||
repositoryRoot: string;
|
||||
rawPath: string;
|
||||
platform?: "posix" | "win32";
|
||||
}>): string {
|
||||
const platform = input.platform ?? (process.platform === "win32" ? "win32" : "posix");
|
||||
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
||||
const { rawPath } = input;
|
||||
if (rawPath.includes("\0")) throw new TypeError("coverage path contains NUL");
|
||||
if (platform === "posix" && rawPath.includes("\\")) {
|
||||
throw new TypeError("coverage path must use POSIX separators");
|
||||
}
|
||||
if (platform === "win32" && rawPath.includes("\\") && !pathApi.isAbsolute(rawPath)) {
|
||||
throw new TypeError("relative coverage path must use POSIX separators");
|
||||
}
|
||||
if (pathApi.isAbsolute(rawPath)) {
|
||||
const root = pathApi.resolve(input.repositoryRoot);
|
||||
const relative = pathApi.relative(root, pathApi.resolve(rawPath));
|
||||
if (
|
||||
!relative ||
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${pathApi.sep}`) ||
|
||||
pathApi.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError(`coverage path is outside repository: ${rawPath}`);
|
||||
}
|
||||
return normalizeRepositoryRelativePath(
|
||||
relative.split(pathApi.sep).join("/"),
|
||||
"coverage path",
|
||||
);
|
||||
}
|
||||
return normalizeRepositoryRelativePath(rawPath, "coverage path");
|
||||
}
|
||||
|
||||
function expectedPct(total: number, covered: number): number {
|
||||
return total === 0 ? 100 : Math.floor((covered / total) * 10_000) / 100;
|
||||
}
|
||||
|
||||
function parseCoverageCounter(value: unknown, label: string): CoverageCounter {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
||||
const fields = ["total", "covered", "skipped", "pct"] as const;
|
||||
assertExactKeys(value, fields, label);
|
||||
if (fields.some((field) => !(field in value))) {
|
||||
throw new TypeError(`${label} must define total, covered, skipped, and pct`);
|
||||
}
|
||||
for (const count of ["total", "covered", "skipped"] as const) {
|
||||
if (
|
||||
typeof value[count] !== "number" ||
|
||||
!Number.isSafeInteger(value[count]) ||
|
||||
value[count] < 0
|
||||
) {
|
||||
throw new TypeError(`${label}.${count} must be a nonnegative safe integer`);
|
||||
}
|
||||
}
|
||||
const total = value.total as number;
|
||||
const covered = value.covered as number;
|
||||
const skipped = value.skipped as number;
|
||||
if (covered + skipped > total) {
|
||||
throw new TypeError(`${label} covered plus skipped must not exceed total`);
|
||||
}
|
||||
const pct = value.pct;
|
||||
const calculatedPct = expectedPct(total, covered);
|
||||
if (typeof pct !== "number" || !Number.isFinite(pct) || pct !== calculatedPct) {
|
||||
throw new TypeError(`${label}.pct must equal ${calculatedPct}`);
|
||||
}
|
||||
return Object.freeze({ total, covered, skipped, pct });
|
||||
}
|
||||
|
||||
function parseCoverageMetrics(value: unknown, label: string): CoverageMetrics {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
||||
const unknownMetrics = Object.keys(value).filter(
|
||||
(metric) => metric !== "branchesTrue" && !coverageMetrics.includes(metric as CoverageMetric),
|
||||
);
|
||||
if (unknownMetrics.length > 0) {
|
||||
throw new TypeError(
|
||||
`${label} has unknown coverage metric keys: ${unknownMetrics.sort().join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (value.branchesTrue !== undefined) {
|
||||
parseCoverageCounter(value.branchesTrue, `${label}.branchesTrue`);
|
||||
}
|
||||
const parsed = {} as Record<CoverageMetric, CoverageCounter>;
|
||||
for (const metric of coverageMetrics) {
|
||||
parsed[metric] = parseCoverageCounter(value[metric], `${label}.${metric}`);
|
||||
}
|
||||
return Object.freeze(parsed);
|
||||
}
|
||||
|
||||
function aggregateCoverage(selected: readonly CoverageMetrics[]): CoverageMetrics {
|
||||
const aggregate = {} as Record<CoverageMetric, CoverageCounter>;
|
||||
for (const metric of coverageMetrics) {
|
||||
let total = 0;
|
||||
let covered = 0;
|
||||
let skipped = 0;
|
||||
for (const metrics of selected) {
|
||||
total += metrics[metric].total;
|
||||
covered += metrics[metric].covered;
|
||||
skipped += metrics[metric].skipped;
|
||||
if (![total, covered, skipped].every(Number.isSafeInteger)) {
|
||||
throw new TypeError(`recomputed coverage ${metric} count exceeds safe integer range`);
|
||||
}
|
||||
}
|
||||
aggregate[metric] = Object.freeze({
|
||||
total,
|
||||
covered,
|
||||
skipped,
|
||||
pct: expectedPct(total, covered),
|
||||
});
|
||||
}
|
||||
return Object.freeze(aggregate);
|
||||
}
|
||||
|
||||
function assertMatchingTotal(
|
||||
producer: CoverageMetrics,
|
||||
recomputed: CoverageMetrics,
|
||||
): void {
|
||||
for (const metric of coverageMetrics) {
|
||||
const actual = producer[metric];
|
||||
const expected = recomputed[metric];
|
||||
if (
|
||||
actual.total !== expected.total ||
|
||||
actual.covered !== expected.covered ||
|
||||
actual.skipped !== expected.skipped ||
|
||||
actual.pct !== expected.pct
|
||||
) {
|
||||
throw new TypeError(
|
||||
`coverage total.${metric} does not match recomputed inventory total`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateRiskCoverage(input: Readonly<{
|
||||
repositoryRoot?: string;
|
||||
inventory: ProductionModuleInventory;
|
||||
policy: RiskCoveragePolicy;
|
||||
summary: unknown;
|
||||
}>): RiskCoverageResult {
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd());
|
||||
const inventory = input.inventory.files.map((file) => exactSourcePath(file, "inventory path"));
|
||||
if (inventory.length === 0) throw new TypeError("production module inventory is empty");
|
||||
if (new Set(inventory).size !== inventory.length) {
|
||||
throw new TypeError("production module inventory contains a duplicate path");
|
||||
}
|
||||
const counterBearingModules = input.inventory.counterBearingModules.map((file) =>
|
||||
exactSourcePath(file, "counter-bearing inventory path"),
|
||||
);
|
||||
const counterlessModules = input.inventory.counterlessModules.map((file) =>
|
||||
exactSourcePath(file, "counterless inventory path"),
|
||||
);
|
||||
const counterBearingSet = new Set(counterBearingModules);
|
||||
const counterlessSet = new Set(counterlessModules);
|
||||
const partition = [...counterBearingModules, ...counterlessModules].sort();
|
||||
if (
|
||||
counterBearingSet.size !== counterBearingModules.length ||
|
||||
counterlessSet.size !== counterlessModules.length ||
|
||||
counterBearingModules.some((file) => counterlessSet.has(file)) ||
|
||||
partition.join("\n") !== [...inventory].sort().join("\n")
|
||||
) {
|
||||
throw new TypeError("production inventory counter-bearing provenance is inconsistent");
|
||||
}
|
||||
const generatedExclusions = input.inventory.generatedExclusions.map((file) =>
|
||||
exactSourcePath(file, "generated exclusion"),
|
||||
);
|
||||
if (
|
||||
new Set(generatedExclusions).size !== generatedExclusions.length ||
|
||||
input.inventory.preExclusionTotal !== inventory.length + generatedExclusions.length ||
|
||||
!Number.isSafeInteger(input.inventory.preExclusionTotal)
|
||||
) {
|
||||
throw new TypeError("production inventory provenance is inconsistent");
|
||||
}
|
||||
const policyGenerated = [...input.policy.generatedPaths].sort();
|
||||
if (generatedExclusions.join("\n") !== policyGenerated.join("\n")) {
|
||||
throw new TypeError("production inventory generated exclusions do not match policy");
|
||||
}
|
||||
if (!isRecord(input.summary) || !("total" in input.summary)) {
|
||||
throw new TypeError("coverage summary must contain total metrics");
|
||||
}
|
||||
const totalMetrics = parseCoverageMetrics(input.summary.total, "coverage total");
|
||||
const selected = new Map<string, CoverageMetrics>();
|
||||
for (const [rawPath, rawMetrics] of Object.entries(input.summary)) {
|
||||
if (rawPath === "total") continue;
|
||||
const normalized = normalizeCoverageProducerPath({ repositoryRoot, rawPath });
|
||||
if (selected.has(normalized)) throw new TypeError(`duplicate coverage path: ${normalized}`);
|
||||
selected.set(normalized, parseCoverageMetrics(rawMetrics, `coverage ${normalized}`));
|
||||
}
|
||||
|
||||
const inventorySet = new Set(inventory);
|
||||
const generatedSet = new Set(generatedExclusions);
|
||||
for (const selectedPath of selected.keys()) {
|
||||
if (!inventorySet.has(selectedPath) && !generatedSet.has(selectedPath)) {
|
||||
throw new TypeError(`unexpected coverage path outside inventory: ${selectedPath}`);
|
||||
}
|
||||
}
|
||||
const inventoryMetrics = inventory.flatMap((file) => {
|
||||
const metrics = selected.get(file);
|
||||
return metrics ? [metrics] : [];
|
||||
});
|
||||
const zeroCoverageModules = inventory.filter((file) => {
|
||||
const metrics = selected.get(file);
|
||||
return (
|
||||
metrics !== undefined &&
|
||||
coverageMetrics.every((metric) => metrics[metric].total === 0)
|
||||
);
|
||||
});
|
||||
const zeroCoverageSet = new Set(zeroCoverageModules);
|
||||
const zeroCounterBearingModules = zeroCoverageModules.filter((file) =>
|
||||
counterBearingSet.has(file),
|
||||
);
|
||||
const counterlessWithCounters = counterlessModules.filter((file) => {
|
||||
const metrics = selected.get(file);
|
||||
return metrics !== undefined && !zeroCoverageSet.has(file);
|
||||
});
|
||||
const instrumentedCounterBearingTotal = counterBearingModules.filter((file) => {
|
||||
const metrics = selected.get(file);
|
||||
return metrics !== undefined && !zeroCoverageSet.has(file);
|
||||
}).length;
|
||||
assertMatchingTotal(totalMetrics, aggregateCoverage([...selected.values()]));
|
||||
const recomputedInventoryMetrics = aggregateCoverage(inventoryMetrics);
|
||||
|
||||
const failures: string[] = [];
|
||||
const results: Array<{
|
||||
scope: string;
|
||||
metric: CoverageMetric;
|
||||
threshold: number;
|
||||
received: number;
|
||||
passed: boolean;
|
||||
}> = [];
|
||||
function evaluate(scope: string, actual: CoverageMetrics, minimum: Thresholds): void {
|
||||
for (const metric of coverageMetrics) {
|
||||
const threshold = minimum[metric];
|
||||
const received = actual[metric].pct;
|
||||
const hasCoverageTotal = actual[metric].total > 0;
|
||||
const passed = hasCoverageTotal && received >= threshold;
|
||||
results.push({ scope, metric, threshold, received, passed });
|
||||
if (!hasCoverageTotal) {
|
||||
failures.push(`${scope}.${metric} coverage total must be greater than 0`);
|
||||
} else if (!passed) {
|
||||
failures.push(`${scope}.${metric} expected >= ${threshold}, received ${received}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
evaluate("total", recomputedInventoryMetrics, input.policy.summary);
|
||||
if (inventory.length < input.policy.repositoryBaseline) {
|
||||
failures.push(
|
||||
`repository module baseline expected >= ${input.policy.repositoryBaseline}, received ${inventory.length}`,
|
||||
);
|
||||
}
|
||||
const uncoveredModules = inventory
|
||||
.filter(
|
||||
(file) =>
|
||||
!selected.has(file) ||
|
||||
(counterBearingSet.has(file) && zeroCoverageSet.has(file)),
|
||||
)
|
||||
.sort();
|
||||
failures.push(
|
||||
...inventory
|
||||
.filter((file) => !selected.has(file))
|
||||
.map((file) => `production module missing from coverage: ${file}`),
|
||||
...zeroCounterBearingModules.map(
|
||||
(file) => `counter-bearing module has zero coverage totals: ${file}`,
|
||||
),
|
||||
...counterlessWithCounters.map(
|
||||
(file) => `counterless module has coverage counters: ${file}`,
|
||||
),
|
||||
);
|
||||
for (const modulePolicy of input.policy.criticalModules) {
|
||||
if (!inventorySet.has(modulePolicy.path)) {
|
||||
failures.push(`critical module is outside production inventory: ${modulePolicy.path}`);
|
||||
continue;
|
||||
}
|
||||
if (counterlessSet.has(modulePolicy.path)) {
|
||||
failures.push(
|
||||
`critical policy-sensitive module cannot be counterless: ${modulePolicy.path}`,
|
||||
);
|
||||
}
|
||||
const actual = selected.get(modulePolicy.path);
|
||||
if (!actual) {
|
||||
failures.push(`critical module missing from coverage: ${modulePolicy.path}`);
|
||||
continue;
|
||||
}
|
||||
evaluate(modulePolicy.path, actual, modulePolicy.minimum);
|
||||
}
|
||||
|
||||
const criticalPaths = new Set(input.policy.criticalModules.map((entry) => entry.path));
|
||||
const waiverPaths = new Set(input.policy.waivers.map((entry) => entry.path));
|
||||
for (const highRiskPath of input.policy.highRiskPaths) {
|
||||
if (!inventorySet.has(highRiskPath)) {
|
||||
failures.push(`high-risk module is outside production inventory: ${highRiskPath}`);
|
||||
}
|
||||
if (counterlessSet.has(highRiskPath)) {
|
||||
failures.push(
|
||||
`high-risk policy-sensitive module cannot be counterless: ${highRiskPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const waiver of input.policy.waivers) {
|
||||
if (!inventorySet.has(waiver.path)) failures.push(`coverage waiver is stale: ${waiver.path}`);
|
||||
}
|
||||
const ownedHighRiskPaths = input.policy.highRiskPaths
|
||||
.filter((modulePath) => criticalPaths.has(modulePath))
|
||||
.sort();
|
||||
const waivedHighRiskPaths = input.policy.highRiskPaths
|
||||
.filter((modulePath) => waiverPaths.has(modulePath))
|
||||
.sort();
|
||||
|
||||
return Object.freeze({
|
||||
status: failures.length === 0 ? "PASS" : "FAIL",
|
||||
selectedTotal: inventory.length - uncoveredModules.length,
|
||||
repositoryTotal: inventory.length,
|
||||
counterBearingTotal: counterBearingModules.length,
|
||||
instrumentedCounterBearingTotal,
|
||||
counterlessTotal: counterlessModules.length,
|
||||
counterlessModules: Object.freeze([...counterlessModules].sort()),
|
||||
preExclusionTotal: input.inventory.preExclusionTotal,
|
||||
generatedExclusionCount: generatedExclusions.length,
|
||||
generatedExclusions: Object.freeze([...generatedExclusions].sort()),
|
||||
ownershipScope: "ALL_POLICY_HIGH_RISK",
|
||||
ownedHighRiskPaths: Object.freeze(ownedHighRiskPaths),
|
||||
waivedHighRiskPaths: Object.freeze(waivedHighRiskPaths),
|
||||
uncoveredModules: Object.freeze(uncoveredModules),
|
||||
results: Object.freeze(results),
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
buildRepositoryFileInventory,
|
||||
parseRepositoryFileInventoryPolicy,
|
||||
} from "./repository-file-inventory.ts";
|
||||
import {
|
||||
findSecretMatches,
|
||||
secretScanRules,
|
||||
type SecretAllowlistEntry,
|
||||
type SecretFinding,
|
||||
} from "./secret-scan.ts";
|
||||
import {
|
||||
parseSecretScanIncludedPaths,
|
||||
selectIncludedInventoryFiles,
|
||||
} from "./secret-scan-policy.ts";
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
|
||||
const nonEmptyString = z.string().min(1);
|
||||
const sarifRuleSchema = z
|
||||
.object({
|
||||
id: nonEmptyString,
|
||||
shortDescription: z.object({ text: nonEmptyString }).strict(),
|
||||
})
|
||||
.strict();
|
||||
const sarifResultSchema = z
|
||||
.object({
|
||||
ruleId: nonEmptyString,
|
||||
message: z.object({ text: nonEmptyString }).strict(),
|
||||
partialFingerprints: z
|
||||
.object({ primaryLocationLineHash: nonEmptyString })
|
||||
.strict()
|
||||
.optional(),
|
||||
locations: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
physicalLocation: z
|
||||
.object({
|
||||
artifactLocation: z.object({ uri: nonEmptyString }).strict(),
|
||||
region: z.object({ startLine: z.int().positive() }).strict(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const secretScanSarifSchema = z
|
||||
.object({
|
||||
version: z.literal("2.1.0"),
|
||||
$schema: z.literal("https://json.schemastore.org/sarif-2.1.0.json"),
|
||||
runs: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
tool: z
|
||||
.object({
|
||||
driver: z
|
||||
.object({
|
||||
name: z.literal("ca-frontend-secret-scan"),
|
||||
rules: z.array(sarifRuleSchema),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
results: z.array(sarifResultSchema),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.length(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type AllowlistEntry = SecretAllowlistEntry &
|
||||
Readonly<{ owner: string; reason: string }>;
|
||||
export type SecretScanPolicy = Readonly<{
|
||||
excludedPaths: readonly string[];
|
||||
trackedRoots: readonly string[];
|
||||
generatedRoots: readonly string[];
|
||||
optionalRoots: readonly string[];
|
||||
includedPaths: readonly string[] | null;
|
||||
allowlist: readonly AllowlistEntry[];
|
||||
}>;
|
||||
|
||||
export function parseSecretScanPolicy(value: unknown): SecretScanPolicy {
|
||||
const document = isRecord(value) ? value : {};
|
||||
const inventoryPolicy = parseRepositoryFileInventoryPolicy(value);
|
||||
const allowlist = Array.isArray(document.allowlist)
|
||||
? document.allowlist.map((rawEntry) => {
|
||||
const entry = isRecord(rawEntry) ? rawEntry : {};
|
||||
return Object.freeze({
|
||||
path: typeof entry.path === "string" ? entry.path : "",
|
||||
ruleId: typeof entry.ruleId === "string" ? entry.ruleId : "",
|
||||
owner: typeof entry.owner === "string" ? entry.owner : "",
|
||||
reason: typeof entry.reason === "string" ? entry.reason : "",
|
||||
expiresAt:
|
||||
typeof entry.expiresAt === "string" ? entry.expiresAt : "",
|
||||
});
|
||||
})
|
||||
: [];
|
||||
return Object.freeze({
|
||||
excludedPaths: Object.freeze(strings(document.excludedPaths)),
|
||||
trackedRoots: inventoryPolicy.trackedRoots,
|
||||
generatedRoots: inventoryPolicy.generatedRoots,
|
||||
optionalRoots: inventoryPolicy.optionalRoots,
|
||||
includedPaths: parseSecretScanIncludedPaths(document.includedPaths),
|
||||
allowlist: Object.freeze(allowlist),
|
||||
});
|
||||
}
|
||||
|
||||
export async function evaluateRepositorySecretScan(input: Readonly<{
|
||||
repositoryRoot?: string;
|
||||
policyPath?: string;
|
||||
now?: number;
|
||||
}>) {
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd());
|
||||
const policy = parseSecretScanPolicy(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
path.resolve(
|
||||
repositoryRoot,
|
||||
input.policyPath ?? "config/security/secret-scan-policy.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
) as unknown,
|
||||
);
|
||||
const inventory = await buildRepositoryFileInventory({
|
||||
repositoryRoot,
|
||||
trackedRoots: policy.trackedRoots,
|
||||
generatedRoots: policy.generatedRoots,
|
||||
optionalRoots: policy.optionalRoots,
|
||||
});
|
||||
return evaluateSecretScan({
|
||||
policy,
|
||||
inventoryFiles: inventory.files,
|
||||
readText: (file) => readFile(path.join(repositoryRoot, file), "utf8"),
|
||||
now: input.now,
|
||||
});
|
||||
}
|
||||
|
||||
export async function evaluateSecretScan(input: Readonly<{
|
||||
policy: SecretScanPolicy;
|
||||
inventoryFiles: readonly string[];
|
||||
readText: (file: string) => Promise<string>;
|
||||
now?: number;
|
||||
}>) {
|
||||
const now = input.now ?? Date.now();
|
||||
const findings: SecretFinding[] = [];
|
||||
const policyFailures: string[] = [];
|
||||
for (const entry of input.policy.allowlist) {
|
||||
const expiry = Date.parse(entry.expiresAt);
|
||||
if (
|
||||
!entry.path.startsWith("tests/") ||
|
||||
!entry.owner.trim() ||
|
||||
!entry.reason.trim() ||
|
||||
!Number.isFinite(expiry) ||
|
||||
expiry <= now
|
||||
) {
|
||||
policyFailures.push(
|
||||
`invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const scanFiles = selectIncludedInventoryFiles(
|
||||
input.inventoryFiles,
|
||||
input.policy.includedPaths,
|
||||
);
|
||||
const excluded = new Set(
|
||||
input.policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
|
||||
);
|
||||
const scanInputs: Readonly<{ path: string; bytes: number; sha256: string }>[] = [];
|
||||
for (const scanFile of [...new Set(scanFiles)].sort()) {
|
||||
const normalized = scanFile.replaceAll("\\", "/");
|
||||
if (
|
||||
[...excluded].some(
|
||||
(entry) =>
|
||||
normalized === entry || normalized.startsWith(`${entry}/`),
|
||||
) ||
|
||||
/\.(?:png|jpe?g|gif|webp|woff2?|zip|gz|sarif)$/iu.test(normalized)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = await input.readText(scanFile);
|
||||
const bytes = Buffer.from(content, "utf8");
|
||||
scanInputs.push(Object.freeze({
|
||||
path: normalized,
|
||||
bytes: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
}));
|
||||
findings.push(
|
||||
...findSecretMatches(normalized, content, {
|
||||
allowlist: input.policy.allowlist,
|
||||
now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const sarif = secretScanSarifSchema.parse({
|
||||
version: "2.1.0",
|
||||
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
runs: [
|
||||
{
|
||||
tool: {
|
||||
driver: {
|
||||
name: "ca-frontend-secret-scan",
|
||||
rules: secretScanRules().map((pattern) => ({
|
||||
id: pattern.id,
|
||||
shortDescription: { text: "Potential credential material" },
|
||||
})),
|
||||
},
|
||||
},
|
||||
results: [
|
||||
...findings.map((finding) => ({
|
||||
ruleId: finding.ruleId,
|
||||
message: { text: "Potential secret material must be removed." },
|
||||
partialFingerprints: {
|
||||
primaryLocationLineHash: finding.fingerprint,
|
||||
},
|
||||
locations: [
|
||||
{
|
||||
physicalLocation: {
|
||||
artifactLocation: { uri: finding.file },
|
||||
region: { startLine: finding.line },
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
...policyFailures.map((failure) => ({
|
||||
ruleId: "invalid-allowlist",
|
||||
message: { text: failure },
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
return Object.freeze({
|
||||
findings: Object.freeze(findings),
|
||||
policyFailures: Object.freeze(policyFailures),
|
||||
scanFiles: Object.freeze([...scanFiles]),
|
||||
scanInputs: Object.freeze(scanInputs),
|
||||
scanInputSha256: supplyChainDigest(scanInputs),
|
||||
sarif,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyStoredSecretScan(
|
||||
evaluation: Awaited<ReturnType<typeof evaluateSecretScan>>,
|
||||
stored: unknown,
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
const blockingCount =
|
||||
evaluation.findings.length + evaluation.policyFailures.length;
|
||||
if (blockingCount > 0) {
|
||||
failures.push(
|
||||
`recomputed secret scan contains ${blockingCount} blocking result(s)`,
|
||||
);
|
||||
}
|
||||
const parsed = secretScanSarifSchema.safeParse(stored);
|
||||
if (
|
||||
!parsed.success ||
|
||||
supplyChainDigest(parsed.data) !== supplyChainDigest(evaluation.sarif)
|
||||
) {
|
||||
failures.push("stored secret scan SARIF does not match recomputed results");
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
|
||||
|
||||
export function parseSecretScanIncludedPaths(
|
||||
value: unknown,
|
||||
): readonly string[] | null {
|
||||
if (value === undefined) return null;
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
value.some(
|
||||
(entry) =>
|
||||
typeof entry !== "string" ||
|
||||
entry.length === 0 ||
|
||||
entry.trim() !== entry,
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
"includedPaths must be a non-empty array of repository-relative POSIX paths",
|
||||
);
|
||||
}
|
||||
const normalized = value.map((entry) =>
|
||||
normalizeRepositoryRelativePath(entry as string, "included path"),
|
||||
);
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new TypeError("includedPaths must not contain duplicate paths");
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
export function selectIncludedInventoryFiles(
|
||||
inventoryFiles: readonly string[],
|
||||
includedPaths: readonly string[] | null,
|
||||
): readonly string[] {
|
||||
if (includedPaths === null) return Object.freeze([...inventoryFiles]);
|
||||
const validatedIncludedPaths = parseSecretScanIncludedPaths(includedPaths);
|
||||
if (validatedIncludedPaths === null) {
|
||||
throw new TypeError("includedPaths unexpectedly omitted");
|
||||
}
|
||||
for (const includedPath of validatedIncludedPaths) {
|
||||
if (
|
||||
!inventoryFiles.some(
|
||||
(file) =>
|
||||
file === includedPath || file.startsWith(`${includedPath}/`),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`secret scan included path matches no inventory file: ${includedPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze(
|
||||
inventoryFiles.filter((file) =>
|
||||
validatedIncludedPaths.some(
|
||||
(includedPath) =>
|
||||
file === includedPath || file.startsWith(`${includedPath}/`),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export type SecretFinding = Readonly<{
|
||||
ruleId: string;
|
||||
file: string;
|
||||
line: number;
|
||||
fingerprint: string;
|
||||
}>;
|
||||
|
||||
export type SecretAllowlistEntry = Readonly<{
|
||||
path: string;
|
||||
ruleId: string;
|
||||
expiresAt: string;
|
||||
}>;
|
||||
|
||||
const secretPatterns: readonly Readonly<{
|
||||
id: string;
|
||||
expression: RegExp;
|
||||
}>[] = [
|
||||
{
|
||||
id: "private-key",
|
||||
expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g,
|
||||
},
|
||||
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
|
||||
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
|
||||
{
|
||||
id: "assigned-secret",
|
||||
expression:
|
||||
/(?<![\w])(["']?)(?:client_secret|password|private_key)\1(?![\w])\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
|
||||
},
|
||||
];
|
||||
|
||||
export function secretScanRules(): readonly Readonly<{
|
||||
id: string;
|
||||
expression: RegExp;
|
||||
}>[] {
|
||||
return secretPatterns;
|
||||
}
|
||||
|
||||
export function findSecretMatches(
|
||||
file: string,
|
||||
content: string,
|
||||
options: Readonly<{
|
||||
allowlist?: readonly SecretAllowlistEntry[];
|
||||
now?: number;
|
||||
}> = {},
|
||||
): SecretFinding[] {
|
||||
const allowlist = options.allowlist ?? [];
|
||||
const now = options.now ?? Date.now();
|
||||
const findings: SecretFinding[] = [];
|
||||
for (const pattern of secretPatterns) {
|
||||
pattern.expression.lastIndex = 0;
|
||||
for (const match of content.matchAll(pattern.expression)) {
|
||||
const isAllowed = allowlist.some(
|
||||
(entry) =>
|
||||
entry.path === file &&
|
||||
entry.ruleId === pattern.id &&
|
||||
Date.parse(entry.expiresAt) > now,
|
||||
);
|
||||
if (isAllowed) continue;
|
||||
const matchIndex = match.index ?? 0;
|
||||
findings.push({
|
||||
ruleId: pattern.id,
|
||||
file,
|
||||
line: content.slice(0, matchIndex).split(/\r?\n/u).length,
|
||||
fingerprint: createHash("sha256")
|
||||
.update(`${pattern.id}:${file}:${String(matchIndex)}`)
|
||||
.digest("hex"),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type ScanResult = Readonly<{
|
||||
error?: Error;
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
|
||||
type SecurityFixtureCheckDependencies = Readonly<{
|
||||
createTempDirectory?: () => Promise<string>;
|
||||
runScan?: (artifactPath: string, policyPath: string) => ScanResult;
|
||||
readArtifact?: (artifactPath: string) => Promise<string>;
|
||||
cleanup?: (directory: string) => Promise<void>;
|
||||
}>;
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
function record(value: unknown, label: string): Document {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label} must be an object`);
|
||||
}
|
||||
return value as Document;
|
||||
}
|
||||
|
||||
function defaultScan(artifactPath: string, policyPath: string): ScanResult {
|
||||
const scan = spawnSync(
|
||||
"node",
|
||||
[
|
||||
"scripts/security-scan.ts",
|
||||
"--policy",
|
||||
policyPath,
|
||||
"--artifact",
|
||||
artifactPath,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return {
|
||||
...(scan.error ? { error: scan.error } : {}),
|
||||
status: scan.status,
|
||||
signal: scan.signal,
|
||||
stdout: scan.stdout ?? "",
|
||||
stderr: scan.stderr ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function assertExactFindings(rawArtifact: string): void {
|
||||
const sarif = record(JSON.parse(rawArtifact), "security fixture SARIF");
|
||||
const runs = Array.isArray(sarif.runs) ? sarif.runs : [];
|
||||
const run = record(runs[0], "security fixture SARIF run");
|
||||
const results = Array.isArray(run.results) ? run.results : [];
|
||||
const actual = results
|
||||
.map((rawResult) => {
|
||||
const result = record(rawResult, "security fixture result");
|
||||
const locations = Array.isArray(result.locations) ? result.locations : [];
|
||||
const location = record(locations[0], "security fixture location");
|
||||
const physical = record(
|
||||
location.physicalLocation,
|
||||
"security fixture physical location",
|
||||
);
|
||||
const artifactLocation = record(
|
||||
physical.artifactLocation,
|
||||
"security fixture artifact location",
|
||||
);
|
||||
return `${String(artifactLocation.uri)}:${String(result.ruleId)}`;
|
||||
})
|
||||
.sort();
|
||||
const root = "tests/fixtures/security/secret-detection/forbidden";
|
||||
const expected = [
|
||||
`${root}/config.json:assigned-secret`,
|
||||
`${root}/dist.ts:assigned-secret`,
|
||||
`${root}/source.ts:aws-access-key`,
|
||||
].sort();
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`security fixture findings mismatch: expected ${expected.join(", ")}; received ${actual.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSecurityFixtures(
|
||||
dependencies: SecurityFixtureCheckDependencies = {},
|
||||
): Promise<void> {
|
||||
const createTempDirectory =
|
||||
dependencies.createTempDirectory ??
|
||||
(() => mkdtemp(path.join(tmpdir(), "ca-security-fixture-")));
|
||||
const runScan = dependencies.runScan ?? defaultScan;
|
||||
const readArtifact = dependencies.readArtifact ?? ((target) => readFile(target, "utf8"));
|
||||
const cleanup =
|
||||
dependencies.cleanup ??
|
||||
((directory) => rm(directory, { recursive: true, force: true }));
|
||||
const directory = await createTempDirectory();
|
||||
const artifactPath = path.join(directory, "scan-fixture.sarif");
|
||||
try {
|
||||
const scan = runScan(
|
||||
artifactPath,
|
||||
"tests/fixtures/security/secret-detection/forbidden-policy.json",
|
||||
);
|
||||
const expectedDiagnostic = "Security scan found 3 blocking result(s).";
|
||||
if (
|
||||
scan.error ||
|
||||
scan.status !== 1 ||
|
||||
scan.signal !== null ||
|
||||
scan.stderr !== `${expectedDiagnostic}\n`
|
||||
) {
|
||||
throw new Error(
|
||||
`forbidden security fixture did not fail exactly: ${scan.error?.message ?? scan.stderr}`,
|
||||
);
|
||||
}
|
||||
assertExactFindings(await readArtifact(artifactPath));
|
||||
} finally {
|
||||
await cleanup(directory);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkNonmatchingSecurityIncludeFixture(
|
||||
dependencies: SecurityFixtureCheckDependencies = {},
|
||||
): Promise<void> {
|
||||
const createTempDirectory =
|
||||
dependencies.createTempDirectory ??
|
||||
(() => mkdtemp(path.join(tmpdir(), "ca-security-include-fixture-")));
|
||||
const runScan = dependencies.runScan ?? defaultScan;
|
||||
const readArtifact =
|
||||
dependencies.readArtifact ?? ((target) => readFile(target, "utf8"));
|
||||
const cleanup =
|
||||
dependencies.cleanup ??
|
||||
((directory) => rm(directory, { recursive: true, force: true }));
|
||||
const directory = await createTempDirectory();
|
||||
const artifactPath = path.join(directory, "scan-fixture.sarif");
|
||||
try {
|
||||
const includedPath =
|
||||
"tests/fixtures/security/secret-detection/misspelled";
|
||||
const scan = runScan(
|
||||
artifactPath,
|
||||
"tests/fixtures/security/secret-detection/nonmatching-policy.json",
|
||||
);
|
||||
if (
|
||||
scan.error ||
|
||||
scan.status !== 1 ||
|
||||
scan.signal !== null ||
|
||||
!scan.stderr.includes(
|
||||
`secret scan included path matches no inventory file: ${includedPath}`,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`nonmatching security include fixture did not fail closed: ${scan.error?.message ?? scan.stderr}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await readArtifact(artifactPath);
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("nonmatching security include fixture wrote an artifact");
|
||||
} finally {
|
||||
await cleanup(directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
InstalledServiceWorkerSelection,
|
||||
ServiceWorkerHandlerId,
|
||||
StaticAssetManifestV1,
|
||||
} from "../../src/contracts/service-worker.ts";
|
||||
|
||||
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
||||
|
||||
export type ServiceWorkerBuildInput = Readonly<{
|
||||
assets: StaticAssetManifestV1;
|
||||
handlers: readonly ServiceWorkerHandlerId[];
|
||||
contractSetDigest: string;
|
||||
releaseManifestUrl: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* ACTIVE worker compilation is a release-integrity boundary. Missing generated
|
||||
* modules, stale identities and placeholder digests are fatal build defects;
|
||||
* they must never be converted into a worker that merely degrades at runtime.
|
||||
*/
|
||||
export function resolveServiceWorkerBuildInput(input: Readonly<{
|
||||
selection: InstalledServiceWorkerSelection | null;
|
||||
assets: unknown;
|
||||
contractSet: unknown;
|
||||
runtimeConfig: unknown;
|
||||
buildId: string;
|
||||
releaseId: string;
|
||||
}>): ServiceWorkerBuildInput {
|
||||
if (input.selection?.mode !== "ACTIVE") {
|
||||
throw new TypeError(
|
||||
"Service Worker build requires an ACTIVE static selection.",
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(input.selection.handlers)) {
|
||||
throw new TypeError("Service Worker handlers must be an array.");
|
||||
}
|
||||
const handlers = new Set<ServiceWorkerHandlerId>();
|
||||
for (const handler of input.selection.handlers) {
|
||||
if (
|
||||
handler !== "PWA_STATIC_ASSETS" &&
|
||||
handler !== "OFFLINE_SYNC_WAKEUP" &&
|
||||
handler !== "WEB_PUSH"
|
||||
) {
|
||||
throw new TypeError(`Unknown Service Worker handler: ${String(handler)}.`);
|
||||
}
|
||||
if (handlers.has(handler)) {
|
||||
throw new TypeError(`Duplicate Service Worker handler: ${handler}.`);
|
||||
}
|
||||
handlers.add(handler);
|
||||
}
|
||||
if (handlers.has("WEB_PUSH")) {
|
||||
throw new TypeError(
|
||||
"WEB_PUSH requires an installed product-owned worker contribution.",
|
||||
);
|
||||
}
|
||||
const assets = parseAssets(input.assets);
|
||||
if (assets.buildId !== input.buildId || assets.releaseId !== input.releaseId) {
|
||||
throw new TypeError("Generated Service Worker asset identity is stale.");
|
||||
}
|
||||
const contractSet = record(input.contractSet);
|
||||
const contractSetDigest = contractSet?.setDigest;
|
||||
if (typeof contractSetDigest !== "string" || !DIGEST.test(contractSetDigest)) {
|
||||
throw new TypeError("Generated contract set digest is invalid.");
|
||||
}
|
||||
const runtimeConfig = record(input.runtimeConfig);
|
||||
const releaseManifestUrl = runtimeConfig?.RELEASE_MANIFEST_URL;
|
||||
if (
|
||||
typeof releaseManifestUrl !== "string" ||
|
||||
releaseManifestUrl.length === 0 ||
|
||||
releaseManifestUrl.length > 2_048
|
||||
) {
|
||||
throw new TypeError("Runtime release manifest URL is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
assets,
|
||||
handlers: Object.freeze([...handlers]),
|
||||
contractSetDigest,
|
||||
releaseManifestUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function parseAssets(value: unknown): StaticAssetManifestV1 {
|
||||
const candidate = record(value);
|
||||
if (
|
||||
candidate?.schemaVersion !== 1 ||
|
||||
typeof candidate.buildId !== "string" ||
|
||||
typeof candidate.releaseId !== "string" ||
|
||||
typeof candidate.setDigest !== "string" ||
|
||||
!DIGEST.test(candidate.setDigest) ||
|
||||
!Array.isArray(candidate.assets)
|
||||
) {
|
||||
throw new TypeError("Generated Service Worker asset manifest is invalid.");
|
||||
}
|
||||
return candidate as unknown as StaticAssetManifestV1;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
cleanupFinalizedPromotion,
|
||||
finalizeVerifiedPromotion,
|
||||
} from "./promotion-stager.ts";
|
||||
|
||||
export async function runStageVerifiedPromotionCli(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
dependencies: Readonly<{
|
||||
cwd?: () => string;
|
||||
finalize?: typeof finalizeVerifiedPromotion;
|
||||
cleanup?: typeof cleanupFinalizedPromotion;
|
||||
appendOutput?: (path: string, content: string) => Promise<void>;
|
||||
writeStdout?: (content: string) => void;
|
||||
}> = {},
|
||||
): Promise<void> {
|
||||
const required = (name: string): string => {
|
||||
const value = environment[name];
|
||||
if (!value) throw new TypeError(`promotion staging environment is missing ${name}`);
|
||||
return value;
|
||||
};
|
||||
const attempt = Number(
|
||||
environment.GITEA_RUN_ATTEMPT ??
|
||||
environment.GITHUB_RUN_ATTEMPT ??
|
||||
required("CI_RUN_ATTEMPT"),
|
||||
);
|
||||
if (!Number.isInteger(attempt) || attempt < 1 || attempt > 1_000) {
|
||||
throw new TypeError("promotion staging run attempt is invalid");
|
||||
}
|
||||
const runnerTempRoot = required("RUNNER_TEMP");
|
||||
const staged = await (dependencies.finalize ?? finalizeVerifiedPromotion)({
|
||||
repositoryRoot: (dependencies.cwd ?? process.cwd)(),
|
||||
archivePath: required("CANDIDATE_ARCHIVE_PATH"),
|
||||
expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"),
|
||||
vulnerabilityReportPath: required("VULNERABILITY_REPORT_PATH"),
|
||||
provenanceAttestationPath: required("PROVENANCE_ATTESTATION_PATH"),
|
||||
vulnerabilityPublicKeyPath: required("VULNERABILITY_PUBLIC_KEY_PATH"),
|
||||
vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"),
|
||||
provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"),
|
||||
provenanceKeyId: required("PROVENANCE_KEY_ID"),
|
||||
expectedRun: {
|
||||
id:
|
||||
environment.GITEA_RUN_ID ??
|
||||
environment.GITHUB_RUN_ID ??
|
||||
required("CI_RUN_ID"),
|
||||
attempt,
|
||||
sourceRevision:
|
||||
environment.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"),
|
||||
},
|
||||
vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"),
|
||||
provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"),
|
||||
runnerTempRoot,
|
||||
});
|
||||
try {
|
||||
const output = required("GITHUB_OUTPUT");
|
||||
const content = [
|
||||
`staging_root=${staged.stagingRoot}`,
|
||||
`cleanup_token=${staged.cleanupToken}`,
|
||||
`runner_temp_dev=${staged.runnerTempIdentity.dev}`,
|
||||
`runner_temp_ino=${staged.runnerTempIdentity.ino}`,
|
||||
`staging_dev=${staged.stagingIdentity.dev}`,
|
||||
`staging_ino=${staged.stagingIdentity.ino}`,
|
||||
"",
|
||||
].join("\n");
|
||||
await (dependencies.appendOutput ?? defaultAppendOutput)(output, content);
|
||||
} catch (error) {
|
||||
try {
|
||||
await (dependencies.cleanup ?? cleanupFinalizedPromotion)({
|
||||
runnerTempRoot,
|
||||
stagingRoot: staged.stagingRoot,
|
||||
cleanupToken: staged.cleanupToken,
|
||||
runnerTempIdentity: staged.runnerTempIdentity,
|
||||
stagingIdentity: staged.stagingIdentity,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
"promotion output publication and direct staging cleanup both failed",
|
||||
{ cause: cleanupError },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
(dependencies.writeStdout ?? process.stdout.write.bind(process.stdout))(
|
||||
`Promotion staging: ${staged.files
|
||||
.map(({ name, sha256 }) => `${name}=${sha256}`)
|
||||
.join(", ")} PASS\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function defaultAppendOutput(path: string, content: string): Promise<void> {
|
||||
await appendFile(path, content, { encoding: "utf8" });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function deterministicSupplyChainGeneratedAt(input: Readonly<{
|
||||
generatedAt: string;
|
||||
sourceDateEpoch: string | null;
|
||||
}>): string {
|
||||
const generatedAtMs = Date.parse(input.generatedAt);
|
||||
if (!Number.isFinite(generatedAtMs)) {
|
||||
throw new TypeError("build manifest generatedAt must be an ISO timestamp");
|
||||
}
|
||||
if (input.sourceDateEpoch !== null) {
|
||||
if (!/^(?:0|[1-9]\d*)$/u.test(input.sourceDateEpoch)) {
|
||||
throw new TypeError("SOURCE_DATE_EPOCH must be whole seconds");
|
||||
}
|
||||
const epoch = Number(input.sourceDateEpoch);
|
||||
if (
|
||||
!Number.isSafeInteger(epoch) ||
|
||||
new Date(epoch * 1000).toISOString() !== input.generatedAt
|
||||
) {
|
||||
throw new TypeError(
|
||||
"build manifest generatedAt must match SOURCE_DATE_EPOCH",
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Date(generatedAtMs).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export type DependencyScope = "production" | "development";
|
||||
export type LockfilePackage = Readonly<{
|
||||
name: string;
|
||||
version: string;
|
||||
integrity: string;
|
||||
}>;
|
||||
export type DependencyInventoryRow = Readonly<{
|
||||
name: string;
|
||||
version: string;
|
||||
direct: boolean;
|
||||
scope: DependencyScope;
|
||||
optional: boolean;
|
||||
license: string;
|
||||
dependencies: readonly string[];
|
||||
}>;
|
||||
export type DependencyUpgrade = Readonly<{
|
||||
name: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}>;
|
||||
export type DependencyInventoryDiff = Readonly<{
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
changed: readonly string[];
|
||||
upgrades: readonly DependencyUpgrade[];
|
||||
}>;
|
||||
|
||||
type MutableDependencyRecord = {
|
||||
name: string;
|
||||
version: string;
|
||||
direct: boolean;
|
||||
scope: DependencyScope;
|
||||
optional: boolean;
|
||||
packagePath: string;
|
||||
dependencies: Set<string>;
|
||||
};
|
||||
|
||||
type Document = Readonly<Record<string, unknown>>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function recordRows(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function stringRows(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function dependencyIdentity(row: Readonly<Record<string, unknown>>): string {
|
||||
return `${String(row.name ?? "")}@${String(row.version ?? "")}`;
|
||||
}
|
||||
|
||||
export function canonicalizeSupplyChainValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(canonicalizeSupplyChainValue)
|
||||
.sort((left, right) =>
|
||||
String(JSON.stringify(left)).localeCompare(String(JSON.stringify(right))),
|
||||
);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function supplyChainDigest(value: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function parsePnpmLockfilePackages(
|
||||
lockfile: string,
|
||||
): LockfilePackage[] {
|
||||
const entries: LockfilePackage[] = [];
|
||||
let inPackages = false;
|
||||
let current: { name: string; version: string; integrity: string } | null = null;
|
||||
|
||||
for (const line of lockfile.split(/\r?\n/)) {
|
||||
if (line === "packages:") {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "snapshots:") {
|
||||
if (current) entries.push(current);
|
||||
break;
|
||||
}
|
||||
if (!inPackages) continue;
|
||||
const packageMatch = line.match(/^ {2}(\S.*):$/);
|
||||
if (packageMatch?.[1]) {
|
||||
if (current) entries.push(current);
|
||||
const key = packageMatch[1].replace(/^['"]|['"]$/g, "");
|
||||
const separator = key.lastIndexOf("@");
|
||||
current = {
|
||||
name: key.slice(0, separator),
|
||||
version: key.slice(separator + 1),
|
||||
integrity: "",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/);
|
||||
if (current && integrityMatch?.[1]) {
|
||||
current.integrity = integrityMatch[1];
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidSha512Integrity(integrity: string): boolean {
|
||||
if (!integrity.startsWith("sha512-")) return false;
|
||||
try {
|
||||
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLicense(raw: unknown): string {
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
if (isRecord(raw) && typeof raw.type === "string") return raw.type;
|
||||
if (Array.isArray(raw)) {
|
||||
const licenses = raw
|
||||
.map(normalizeLicense)
|
||||
.filter((license) => license !== "NOASSERTION");
|
||||
return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION";
|
||||
}
|
||||
return "NOASSERTION";
|
||||
}
|
||||
|
||||
export async function flattenPnpmDependencyTree(
|
||||
root: Record<string, unknown>,
|
||||
directProduction: Readonly<Record<string, string>>,
|
||||
directDevelopment: Readonly<Record<string, string>>,
|
||||
): Promise<DependencyInventoryRow[]> {
|
||||
const records = new Map<string, MutableDependencyRecord>();
|
||||
const directIds = new Set<string>();
|
||||
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
recordValue(root.dependencies),
|
||||
)) {
|
||||
if (Object.hasOwn(directProduction, name) && isRecord(rawDependency)) {
|
||||
directIds.add(`${name}@${String(rawDependency.version ?? "")}`);
|
||||
}
|
||||
}
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
recordValue(root.devDependencies),
|
||||
)) {
|
||||
if (Object.hasOwn(directDevelopment, name) && isRecord(rawDependency)) {
|
||||
directIds.add(`${name}@${String(rawDependency.version ?? "")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function visit(
|
||||
node: Record<string, unknown>,
|
||||
scope: DependencyScope,
|
||||
optionalPath: boolean,
|
||||
): void {
|
||||
const groups = {
|
||||
dependencies: node.dependencies,
|
||||
devDependencies: node.devDependencies,
|
||||
optionalDependencies: node.optionalDependencies,
|
||||
};
|
||||
for (const [groupName, group] of Object.entries(groups)) {
|
||||
for (const [name, rawDependency] of Object.entries(recordValue(group))) {
|
||||
if (!isRecord(rawDependency)) continue;
|
||||
const version = String(rawDependency.version ?? "");
|
||||
const packagePath = String(rawDependency.path ?? "");
|
||||
const identity = `${name}@${version}`;
|
||||
const childScope: DependencyScope =
|
||||
scope === "production" && groupName !== "devDependencies"
|
||||
? "production"
|
||||
: "development";
|
||||
const childOptional =
|
||||
optionalPath || groupName === "optionalDependencies";
|
||||
const previous = records.get(identity);
|
||||
const dependencies = previous?.dependencies ?? new Set<string>();
|
||||
for (const childGroup of [
|
||||
rawDependency.dependencies,
|
||||
rawDependency.optionalDependencies,
|
||||
]) {
|
||||
for (const [childName, rawChild] of Object.entries(
|
||||
recordValue(childGroup),
|
||||
)) {
|
||||
if (isRecord(rawChild)) {
|
||||
dependencies.add(
|
||||
`${childName}@${String(rawChild.version ?? "")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
records.set(identity, {
|
||||
name,
|
||||
version,
|
||||
direct: directIds.has(identity),
|
||||
scope:
|
||||
previous?.scope === "production" || childScope === "production"
|
||||
? "production"
|
||||
: "development",
|
||||
optional: previous
|
||||
? previous.optional && childOptional
|
||||
: childOptional,
|
||||
packagePath: previous?.packagePath || packagePath,
|
||||
dependencies,
|
||||
});
|
||||
visit(rawDependency, childScope, childOptional);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const productionRoot: Record<string, unknown> = {
|
||||
dependencies: Object.fromEntries(
|
||||
Object.entries(recordValue(root.dependencies)).filter(([name]) =>
|
||||
Object.hasOwn(directProduction, name),
|
||||
),
|
||||
),
|
||||
};
|
||||
const developmentRoot: Record<string, unknown> = {
|
||||
devDependencies: Object.fromEntries(
|
||||
Object.entries(recordValue(root.devDependencies)).filter(([name]) =>
|
||||
Object.hasOwn(directDevelopment, name),
|
||||
),
|
||||
),
|
||||
};
|
||||
visit(productionRoot, "production", false);
|
||||
visit(developmentRoot, "development", false);
|
||||
|
||||
const result: DependencyInventoryRow[] = [];
|
||||
for (const record of records.values()) {
|
||||
let license = "NOASSERTION";
|
||||
let optional = record.optional;
|
||||
if (record.packagePath) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
await readFile(`${record.packagePath}/package.json`, "utf8"),
|
||||
);
|
||||
const manifest = recordValue(parsed);
|
||||
license = normalizeLicense(manifest.license ?? manifest.licenses);
|
||||
} catch {
|
||||
// Platform-specific optional packages may not be materialized locally.
|
||||
optional = true;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
direct: record.direct,
|
||||
scope: record.scope,
|
||||
optional,
|
||||
license,
|
||||
dependencies: [...record.dependencies].sort(),
|
||||
});
|
||||
}
|
||||
return result.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function diffDependencyInventories(
|
||||
before: Document,
|
||||
after: Document,
|
||||
): DependencyInventoryDiff {
|
||||
const beforeRows = recordRows(before.dependencies);
|
||||
const afterRows = recordRows(after.dependencies);
|
||||
const beforeMap = new Map(
|
||||
beforeRows.map((row) => [dependencyIdentity(row), row] as const),
|
||||
);
|
||||
const afterMap = new Map(
|
||||
afterRows.map((row) => [dependencyIdentity(row), row] as const),
|
||||
);
|
||||
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
|
||||
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
|
||||
const changed: string[] = [];
|
||||
for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) {
|
||||
if (supplyChainDigest(beforeMap.get(key)) !== supplyChainDigest(afterMap.get(key))) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
const upgrades: DependencyUpgrade[] = [];
|
||||
for (const removedKey of removed) {
|
||||
const previous = beforeMap.get(removedKey);
|
||||
if (!previous) continue;
|
||||
const replacement = added.find(
|
||||
(addedKey) => afterMap.get(addedKey)?.name === previous.name,
|
||||
);
|
||||
const next = replacement ? afterMap.get(replacement) : undefined;
|
||||
if (next) {
|
||||
upgrades.push({
|
||||
name: String(previous.name ?? ""),
|
||||
from: String(previous.version ?? ""),
|
||||
to: String(next.version ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
added: Object.freeze(added.sort()),
|
||||
removed: Object.freeze(removed.sort()),
|
||||
changed: Object.freeze(changed.sort()),
|
||||
upgrades: Object.freeze(
|
||||
upgrades.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function validateLicensePolicy(
|
||||
inventory: Document,
|
||||
policy: Document,
|
||||
) {
|
||||
const allowed = new Set(stringRows(policy.allowedLicenses));
|
||||
const denied = stringRows(policy.deniedLicensePatterns);
|
||||
const failures: string[] = [];
|
||||
const results: Array<Readonly<{
|
||||
package: string;
|
||||
license: string;
|
||||
passed: boolean;
|
||||
reason: string | null;
|
||||
}>> = [];
|
||||
for (const dependency of recordRows(inventory.dependencies)) {
|
||||
const license = String(dependency.license ?? "NOASSERTION");
|
||||
const explicitlyDenied = denied.some((pattern) =>
|
||||
new RegExp(pattern, "i").test(license),
|
||||
);
|
||||
const unknownAccepted =
|
||||
license === "NOASSERTION" && dependency.optional === true;
|
||||
const passed =
|
||||
!explicitlyDenied && (allowed.has(license) || unknownAccepted);
|
||||
results.push({
|
||||
package: dependencyIdentity(dependency),
|
||||
license,
|
||||
passed,
|
||||
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
|
||||
});
|
||||
if (!passed) {
|
||||
failures.push(
|
||||
`${dependencyIdentity(dependency)} has disallowed license ${license}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
results: Object.freeze(results),
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDependencyReview(
|
||||
diff: DependencyInventoryDiff,
|
||||
inventory: Document,
|
||||
evidenceFile: Document,
|
||||
) {
|
||||
const byIdentity = new Map(
|
||||
recordRows(inventory.dependencies).map(
|
||||
(row) => [dependencyIdentity(row), row] as const,
|
||||
),
|
||||
);
|
||||
const evidence = new Map<string, Record<string, unknown>>();
|
||||
for (const entry of recordRows(evidenceFile.changes)) {
|
||||
if (typeof entry.changeId === "string") evidence.set(entry.changeId, entry);
|
||||
}
|
||||
const highRisk = diff.added.filter((identity) => {
|
||||
const row = byIdentity.get(identity);
|
||||
return row?.direct === true && row.scope === "production";
|
||||
});
|
||||
const failures: string[] = [];
|
||||
for (const identity of highRisk) {
|
||||
const changeId = `add:${identity}`;
|
||||
const entry = evidence.get(changeId);
|
||||
if (!entry) {
|
||||
failures.push(`high-risk dependency missing review: ${changeId}`);
|
||||
continue;
|
||||
}
|
||||
for (const field of ["owner", "reviewer", "reason", "rollback"] as const) {
|
||||
const value = entry[field];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
failures.push(`${changeId} missing ${field}`);
|
||||
}
|
||||
}
|
||||
if (entry.owner === entry.reviewer) {
|
||||
failures.push(`${changeId} may not be self-approved`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
highRisk: Object.freeze(highRisk),
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
const severityRank: ReadonlyMap<string, number> = new Map([
|
||||
["unknown", 0],
|
||||
["low", 1],
|
||||
["moderate", 2],
|
||||
["high", 3],
|
||||
["critical", 4],
|
||||
]);
|
||||
|
||||
export function validateVulnerabilityReport(
|
||||
report: Document,
|
||||
policy: Document,
|
||||
exceptionFile: Document,
|
||||
lockfileSha256: string,
|
||||
now: Date = new Date(),
|
||||
) {
|
||||
const failures: string[] = [];
|
||||
if (report.scannedLockfileSha256 !== lockfileSha256) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (typeof report.provider !== "string" || !report.provider.trim()) {
|
||||
failures.push("vulnerability report provider missing");
|
||||
}
|
||||
const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3;
|
||||
const exceptions = recordRows(exceptionFile.exceptions);
|
||||
const blocking: string[] = [];
|
||||
for (const finding of recordRows(report.findings)) {
|
||||
const severity = String(finding.severity ?? "unknown").toLowerCase();
|
||||
if ((severityRank.get(severity) ?? 0) < threshold) continue;
|
||||
const exception = exceptions.find(
|
||||
(entry) =>
|
||||
entry.vulnerabilityId === finding.id &&
|
||||
entry.packageName === finding.packageName,
|
||||
);
|
||||
const expiry =
|
||||
typeof exception?.expiresAt === "string"
|
||||
? Date.parse(exception.expiresAt)
|
||||
: Number.NaN;
|
||||
const validException = Boolean(
|
||||
exception &&
|
||||
typeof exception.owner === "string" &&
|
||||
exception.owner.trim() &&
|
||||
typeof exception.reviewer === "string" &&
|
||||
exception.reviewer.trim() &&
|
||||
exception.owner !== exception.reviewer &&
|
||||
typeof exception.reason === "string" &&
|
||||
exception.reason.trim() &&
|
||||
Number.isFinite(expiry) &&
|
||||
expiry > now.getTime(),
|
||||
);
|
||||
if (!validException) {
|
||||
blocking.push(
|
||||
`${String(finding.id)}:${String(finding.packageName)}@${String(finding.version)}:${severity}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0 && blocking.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
blocking: Object.freeze(blocking),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifySupplyChainCoherence(
|
||||
sbom: Document,
|
||||
inventory: Document,
|
||||
provenance: Document,
|
||||
distDigest: string,
|
||||
) {
|
||||
const failures: string[] = [];
|
||||
const componentCount = Array.isArray(sbom.components)
|
||||
? sbom.components.length
|
||||
: -1;
|
||||
const dependencyCount = Array.isArray(inventory.dependencies)
|
||||
? inventory.dependencies.length
|
||||
: -2;
|
||||
if (componentCount !== dependencyCount) {
|
||||
failures.push("SBOM component count does not match inventory");
|
||||
}
|
||||
const metadata = recordValue(sbom.metadata);
|
||||
const properties = recordRows(metadata.properties);
|
||||
if (
|
||||
properties.find(
|
||||
(property) =>
|
||||
property.name === "ca:lockfileSha256" &&
|
||||
property.value === inventory.lockfileSha256,
|
||||
) === undefined
|
||||
) {
|
||||
failures.push("SBOM lockfile digest does not match inventory");
|
||||
}
|
||||
const subject = recordRows(provenance.subject)[0];
|
||||
const subjectDigest = recordValue(subject?.digest);
|
||||
if (subjectDigest.sha256 !== distDigest) {
|
||||
failures.push("provenance subject does not match built dist digest");
|
||||
}
|
||||
const predicate = recordValue(provenance.predicate);
|
||||
const materials = recordValue(predicate.materials);
|
||||
if (materials.lockfileSha256 !== inventory.lockfileSha256) {
|
||||
failures.push("provenance lockfile material does not match inventory");
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const testEvidenceReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
sourceRoot: z.string().min(1),
|
||||
status: z.enum(["PASS", "FAIL"]),
|
||||
facts: z
|
||||
.object({
|
||||
scannedFiles: z.number().int().nonnegative(),
|
||||
visualBaselines: z.number().int().nonnegative(),
|
||||
sharedScenarios: z.number().int().nonnegative(),
|
||||
declaredScenarioExecutions: z.number().int().nonnegative(),
|
||||
executedScenarioExecutions: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((report, context) => {
|
||||
const passed = report.status === "PASS";
|
||||
if (passed !== (report.failures.length === 0)) {
|
||||
context.addIssue({ code: "custom", path: ["status"], message: "status must agree with failures" });
|
||||
}
|
||||
if (
|
||||
passed &&
|
||||
report.facts.declaredScenarioExecutions !==
|
||||
report.facts.executedScenarioExecutions
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["facts", "executedScenarioExecutions"],
|
||||
message: "PASS requires exact declared/executed scenario agreement",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const coverageMetrics = [
|
||||
"lines",
|
||||
"statements",
|
||||
"functions",
|
||||
"branches",
|
||||
] as const;
|
||||
const runtimeModule = "src/runtime.ts";
|
||||
const counterlessModules = Object.freeze([
|
||||
"src/import-empty.ts",
|
||||
"src/import-side-effect.ts",
|
||||
"src/import-type-empty.ts",
|
||||
"src/import-value.ts",
|
||||
"src/reexport-named.ts",
|
||||
"src/reexport-star.ts",
|
||||
"src/type-only.ts",
|
||||
] as const);
|
||||
const expectedModules = Object.freeze(
|
||||
[runtimeModule, ...counterlessModules].sort(),
|
||||
);
|
||||
const childOutputLimit = 1_800;
|
||||
|
||||
type ChildOutput = Readonly<{ stdout: string; stderr: string }>;
|
||||
type ChildInput = Readonly<{
|
||||
repositoryRoot: string;
|
||||
ownedRoot: string;
|
||||
configPath: string;
|
||||
}>;
|
||||
type CheckerOptions = Readonly<{
|
||||
repositoryRoot?: string;
|
||||
createOwnedRoot?: () => Promise<string>;
|
||||
runVitest?: (input: ChildInput) => Promise<ChildOutput>;
|
||||
}>;
|
||||
|
||||
export type V8CoverageCounterSemantics = Readonly<{
|
||||
counterBearingModules: readonly string[];
|
||||
counterlessModules: readonly string[];
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return isRecord(error) && error.code === code;
|
||||
}
|
||||
|
||||
function normalizeSummaryPath(value: string, fixtureRoot: string): string {
|
||||
const relative = path.isAbsolute(value) ? path.relative(fixtureRoot, value) : value;
|
||||
const normalized = relative.split(path.sep).join("/");
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
path.posix.isAbsolute(normalized)
|
||||
) {
|
||||
throw new TypeError(`V8 coverage row is outside the owned fixture: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function coverageCounter(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<{ total: number; covered: number; skipped: number; pct: number }> {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
||||
const { total, covered, skipped, pct } = value;
|
||||
if (
|
||||
typeof total !== "number" ||
|
||||
!Number.isSafeInteger(total) ||
|
||||
typeof covered !== "number" ||
|
||||
!Number.isSafeInteger(covered) ||
|
||||
typeof skipped !== "number" ||
|
||||
!Number.isSafeInteger(skipped) ||
|
||||
typeof pct !== "number" ||
|
||||
!Number.isFinite(pct) ||
|
||||
total < 0 ||
|
||||
covered < 0 ||
|
||||
skipped < 0 ||
|
||||
covered + skipped > total
|
||||
) {
|
||||
throw new TypeError(`${label} contains invalid coverage counters`);
|
||||
}
|
||||
const expectedPercentage =
|
||||
total === 0 ? 100 : Math.floor((covered / total) * 10_000) / 100;
|
||||
if (pct !== expectedPercentage) {
|
||||
throw new TypeError(`${label} contains invalid coverage counters`);
|
||||
}
|
||||
return { total, covered, skipped, pct };
|
||||
}
|
||||
|
||||
function coverageRow(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<(typeof coverageMetrics)[number], ReturnType<typeof coverageCounter>>> {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
||||
return Object.fromEntries(
|
||||
coverageMetrics.map((metric) => [
|
||||
metric,
|
||||
coverageCounter(value[metric], `${label}.${metric}`),
|
||||
]),
|
||||
) as Readonly<
|
||||
Record<(typeof coverageMetrics)[number], ReturnType<typeof coverageCounter>>
|
||||
>;
|
||||
}
|
||||
|
||||
export function assertV8CoverageCounterSemantics(
|
||||
value: unknown,
|
||||
fixtureRoot: string,
|
||||
): V8CoverageCounterSemantics {
|
||||
if (!isRecord(value)) throw new TypeError("V8 coverage summary must be an object");
|
||||
coverageRow(value.total, "V8 coverage total");
|
||||
const rows = new Map<string, ReturnType<typeof coverageRow>>();
|
||||
for (const [producerPath, producerRow] of Object.entries(value)) {
|
||||
if (producerPath === "total") continue;
|
||||
const normalizedPath = normalizeSummaryPath(producerPath, fixtureRoot);
|
||||
if (rows.has(normalizedPath)) {
|
||||
throw new TypeError(`V8 coverage row is duplicated: ${normalizedPath}`);
|
||||
}
|
||||
rows.set(
|
||||
normalizedPath,
|
||||
coverageRow(producerRow, `V8 coverage row ${normalizedPath}`),
|
||||
);
|
||||
}
|
||||
const actualModules = [...rows.keys()].sort();
|
||||
const missing = expectedModules.filter((modulePath) => !rows.has(modulePath));
|
||||
const additional = actualModules.filter(
|
||||
(modulePath) => !expectedModules.includes(modulePath),
|
||||
);
|
||||
if (missing.length > 0 || additional.length > 0) {
|
||||
throw new Error(
|
||||
`V8 coverage row set mismatch; missing: ${missing.join(", ") || "none"}; additional: ${additional.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
const runtimeRow = rows.get(runtimeModule)!;
|
||||
if (!coverageMetrics.some((metric) => runtimeRow[metric].total > 0)) {
|
||||
throw new Error(`V8 runtime module is not counter-bearing: ${runtimeModule}`);
|
||||
}
|
||||
for (const modulePath of counterlessModules) {
|
||||
const row = rows.get(modulePath)!;
|
||||
const exactAllZero = coverageMetrics.every((metric) => {
|
||||
const counter = row[metric];
|
||||
return (
|
||||
counter.total === 0 &&
|
||||
counter.covered === 0 &&
|
||||
counter.skipped === 0 &&
|
||||
counter.pct === 100
|
||||
);
|
||||
});
|
||||
if (!exactAllZero) {
|
||||
throw new Error(
|
||||
`V8 counterless module must have exact all-zero counters: ${modulePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
counterBearingModules: Object.freeze([runtimeModule]),
|
||||
counterlessModules: Object.freeze([...counterlessModules]),
|
||||
});
|
||||
}
|
||||
|
||||
function boundedChildOutput(value: unknown): string {
|
||||
const output =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value instanceof Uint8Array
|
||||
? new TextDecoder().decode(value)
|
||||
: "";
|
||||
if (output.length <= childOutputLimit) return output;
|
||||
return `${output.slice(0, childOutputLimit)}\n[truncated ${output.length - childOutputLimit} characters]`;
|
||||
}
|
||||
|
||||
async function defaultRunVitest(input: ChildInput): Promise<ChildOutput> {
|
||||
const result = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(input.repositoryRoot, "node_modules/vitest/vitest.mjs"),
|
||||
"run",
|
||||
"--config",
|
||||
input.configPath,
|
||||
"--coverage",
|
||||
"--reporter=dot",
|
||||
"--no-color",
|
||||
],
|
||||
{
|
||||
cwd: input.ownedRoot,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
},
|
||||
);
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
function assertOwnedTemporaryRoot(value: string): string {
|
||||
const temporaryRoot = path.resolve(tmpdir());
|
||||
const ownedRoot = path.resolve(value);
|
||||
const relative = path.relative(temporaryRoot, ownedRoot);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError(`V8 coverage fixture root is not an owned temp path: ${ownedRoot}`);
|
||||
}
|
||||
return ownedRoot;
|
||||
}
|
||||
|
||||
export async function checkV8CoverageCounterSemantics(
|
||||
options: CheckerOptions = {},
|
||||
): Promise<V8CoverageCounterSemantics> {
|
||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
||||
const createOwnedRoot =
|
||||
options.createOwnedRoot ??
|
||||
(() => mkdtemp(path.join(tmpdir(), "v8-counter-semantics-")));
|
||||
const runVitest = options.runVitest ?? defaultRunVitest;
|
||||
const ownedRoot = assertOwnedTemporaryRoot(await createOwnedRoot());
|
||||
const fixtureSource = path.join(
|
||||
repositoryRoot,
|
||||
"tests/fixtures/v8-coverage-counter-semantics",
|
||||
);
|
||||
const configPath = path.join(ownedRoot, "vitest.config.mjs");
|
||||
const reportsDirectory = path.join(ownedRoot, "coverage");
|
||||
try {
|
||||
await cp(fixtureSource, ownedRoot, { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
`export default ${JSON.stringify(
|
||||
{
|
||||
root: ownedRoot,
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["counter-semantics.fixture.ts"],
|
||||
setupFiles: [],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reportsDirectory,
|
||||
reporter: ["json-summary"],
|
||||
include: ["src/**/*.ts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)};\n`,
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
await runVitest({ repositoryRoot, ownedRoot, configPath });
|
||||
} catch (error) {
|
||||
const record = isRecord(error) ? error : {};
|
||||
throw new Error(
|
||||
`V8 coverage counter semantics child Vitest failed\nstdout:\n${boundedChildOutput(record.stdout)}\nstderr:\n${boundedChildOutput(record.stderr)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const summaryPath = path.join(reportsDirectory, "coverage-summary.json");
|
||||
let summaryText: string;
|
||||
try {
|
||||
summaryText = await readFile(summaryPath, "utf8");
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
throw new Error("V8 coverage counter semantics coverage summary is missing", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let summary: unknown;
|
||||
try {
|
||||
summary = JSON.parse(summaryText) as unknown;
|
||||
} catch (error) {
|
||||
throw new TypeError("V8 coverage counter semantics summary is invalid JSON", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return assertV8CoverageCounterSemantics(summary, ownedRoot);
|
||||
} finally {
|
||||
await rm(ownedRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
open as openFile,
|
||||
rename as renameFile,
|
||||
rm as removeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { z } from "zod";
|
||||
|
||||
export type ValidatedJsonArtifactInput = Readonly<{
|
||||
path: string;
|
||||
schema: z.ZodType;
|
||||
value: unknown;
|
||||
}>;
|
||||
|
||||
export function serializeValidatedJsonArtifact(
|
||||
input: ValidatedJsonArtifactInput,
|
||||
): Buffer {
|
||||
const parsed = input.schema.parse(input.value);
|
||||
const serialized = JSON.stringify(parsed, null, 2);
|
||||
if (serialized === undefined) {
|
||||
throw new TypeError("Validated JSON artifact is not serializable");
|
||||
}
|
||||
return Buffer.from(`${serialized}\n`, "utf8");
|
||||
}
|
||||
|
||||
export type ValidatedJsonArtifactFileSystem = Readonly<{
|
||||
open: (path: string, flags: number, mode: number) => Promise<{
|
||||
writeFile(data: string, encoding: "utf8"): Promise<unknown>;
|
||||
sync(): Promise<unknown>;
|
||||
close(): Promise<unknown>;
|
||||
}>;
|
||||
openDirectory: (path: string) => Promise<{
|
||||
sync(): Promise<unknown>;
|
||||
close(): Promise<unknown>;
|
||||
}>;
|
||||
rename: (source: string, destination: string) => Promise<unknown>;
|
||||
rm: (path: string, options: Readonly<{ force: true }>) => Promise<unknown>;
|
||||
}>;
|
||||
|
||||
type ValidatedJsonArtifactWriterDependencies = Readonly<{
|
||||
createNonce?: () => string;
|
||||
fileSystem?: ValidatedJsonArtifactFileSystem;
|
||||
}>;
|
||||
|
||||
const defaultFileSystem: ValidatedJsonArtifactFileSystem = Object.freeze({
|
||||
open: async (target, flags, mode) => openFile(target, flags, mode),
|
||||
openDirectory: async (target) => openFile(target, constants.O_RDONLY),
|
||||
rename: async (source, destination) => renameFile(source, destination),
|
||||
rm: async (target, options) => removeFile(target, options),
|
||||
});
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === code
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a writer whose only publish operation is an atomic sibling rename.
|
||||
* Dependency injection is limited to the file-system boundary so failure
|
||||
* ownership can be verified without exposing a caller-selected cleanup path.
|
||||
*/
|
||||
export function createValidatedJsonArtifactWriter(
|
||||
dependencies: ValidatedJsonArtifactWriterDependencies = {},
|
||||
) {
|
||||
const createNonce = dependencies.createNonce ?? randomUUID;
|
||||
const fileSystem = dependencies.fileSystem ?? defaultFileSystem;
|
||||
|
||||
return async function writeArtifact(
|
||||
input: ValidatedJsonArtifactInput,
|
||||
): Promise<void> {
|
||||
const serialized = serializeValidatedJsonArtifact(input);
|
||||
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(input.path),
|
||||
`.${path.basename(input.path)}.${createNonce()}.tmp`,
|
||||
);
|
||||
let ownsTemporaryFile = false;
|
||||
try {
|
||||
const handle = await fileSystem.open(
|
||||
temporaryPath,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
ownsTemporaryFile = true;
|
||||
let writeFailed = false;
|
||||
let writeFailure: unknown;
|
||||
try {
|
||||
await handle.writeFile(serialized.toString("utf8"), "utf8");
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
writeFailed = true;
|
||||
writeFailure = error;
|
||||
}
|
||||
let closeFailed = false;
|
||||
let closeFailure: unknown;
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
closeFailed = true;
|
||||
closeFailure = error;
|
||||
}
|
||||
if (writeFailed) throw writeFailure;
|
||||
if (closeFailed) throw closeFailure;
|
||||
await fileSystem.rename(temporaryPath, input.path);
|
||||
ownsTemporaryFile = false;
|
||||
const directoryHandle = await fileSystem.openDirectory(
|
||||
path.dirname(input.path),
|
||||
);
|
||||
try {
|
||||
try {
|
||||
await directoryHandle.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await directoryHandle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (ownsTemporaryFile) {
|
||||
try {
|
||||
await fileSystem.rm(temporaryPath, { force: true });
|
||||
} catch {
|
||||
// Preserve the publishing failure; cleanup is confined to our nonce.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const writeValidatedJsonArtifact =
|
||||
createValidatedJsonArtifactWriter();
|
||||
@@ -0,0 +1,63 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
type ModuleInventoryChunk = Readonly<{
|
||||
fileName: string;
|
||||
modules: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Rollup knows the exact source-module set for every emitted chunk. Persisting
|
||||
* that graph makes optional-runtime exclusion verifiable without relying on
|
||||
* minified names, error strings, or source maps.
|
||||
*/
|
||||
export function viteModuleInventoryPlugin(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Plugin {
|
||||
return {
|
||||
name: "frontend-module-inventory",
|
||||
generateBundle(_options, bundle) {
|
||||
const chunks: ModuleInventoryChunk[] = Object.values(bundle)
|
||||
.filter((output) => output.type === "chunk")
|
||||
.map((chunk) => ({
|
||||
fileName: chunk.fileName,
|
||||
modules: Object.freeze(
|
||||
[...new Set(
|
||||
Object.keys(chunk.modules).map((moduleId) =>
|
||||
normalizeModuleId(moduleId, repositoryRoot),
|
||||
),
|
||||
)].sort(),
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => left.fileName.localeCompare(right.fileName));
|
||||
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: ".vite/module-inventory.json",
|
||||
source: `${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
chunks,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModuleId(
|
||||
moduleId: string,
|
||||
repositoryRoot: string,
|
||||
): string {
|
||||
const withoutQuery = moduleId.replace(/^\0/u, "").split("?", 1)[0] ?? "";
|
||||
if (!path.isAbsolute(withoutQuery)) {
|
||||
return withoutQuery.replaceAll("\\", "/");
|
||||
}
|
||||
const relative = path.relative(repositoryRoot, withoutQuery);
|
||||
return relative.startsWith("..")
|
||||
? `external:${path.basename(withoutQuery)}`
|
||||
: relative.replaceAll("\\", "/");
|
||||
}
|
||||
Reference in New Issue
Block a user