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; realpathPath?: (target: string) => Promise; assertReadable?: (target: string) => Promise; }>; const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); function isRecord(value: unknown): value is Record { 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 { 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 { 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 { 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 { 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 { 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()), }); }