189 lines
6.0 KiB
TypeScript
189 lines
6.0 KiB
TypeScript
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;
|
|
}
|