fix: fail closed on release input discovery
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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();
|
||||
}
|
||||
|
||||
function normalizeRepositoryPath(value: string, label: string): string {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
path.posix.isAbsolute(value) ||
|
||||
path.win32.isAbsolute(value) ||
|
||||
value.includes("\\") ||
|
||||
value.includes("\0")
|
||||
) {
|
||||
throw new TypeError(`${label} must be a repository-relative POSIX path`);
|
||||
}
|
||||
const normalized = path.posix.normalize(value);
|
||||
if (
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized !== value.replace(/\/$/u, "")
|
||||
) {
|
||||
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.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function belongsToRoots(file: string, roots: readonly string[]): boolean {
|
||||
return roots.some((root) => file === root || file.startsWith(`${root}/`));
|
||||
}
|
||||
|
||||
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) =>
|
||||
normalizeRepositoryPath(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) =>
|
||||
normalizeRepositoryPath(root, "tracked root"),
|
||||
);
|
||||
const generatedRoots = (options.generatedRoots ?? []).map((root) =>
|
||||
normalizeRepositoryPath(root, "generated root"),
|
||||
);
|
||||
const optionalRoots = new Set(
|
||||
(options.optionalRoots ?? []).map((root) =>
|
||||
normalizeRepositoryPath(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),
|
||||
)
|
||||
.filter((file) => belongsToRoots(file, trackedRoots))
|
||||
.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(
|
||||
normalizeRepositoryPath(
|
||||
`${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();
|
||||
return Object.freeze({
|
||||
trackedFiles: Object.freeze(uniqueTracked),
|
||||
generatedFiles: Object.freeze(uniqueGenerated),
|
||||
files: Object.freeze([...new Set([...uniqueTracked, ...uniqueGenerated])].sort()),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user