Files
clean-architecture-frontend…/scripts/lib/build-manifest-outputs.ts
T

178 lines
5.8 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>;
}>;
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.startsWith("..") && !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");
}
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;
}