import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildRepositoryFileInventory, parseRepositoryFileInventoryPolicy, type GitFileListResult, } from "../../scripts/lib/repository-file-inventory.ts"; function gitResult( stdout: Buffer | string, overrides: Partial = {}, ): GitFileListResult { return { status: 0, signal: null, stdout: typeof stdout === "string" ? Buffer.from(stdout) : stdout, stderr: Buffer.alloc(0), ...overrides, }; } async function repositoryFixture() { const root = await mkdtemp(path.join(tmpdir(), "repository-inventory-")); await mkdir(path.join(root, "src")); await mkdir(path.join(root, "docs")); await writeFile(path.join(root, "src", "tracked.ts"), "tracked\n"); await writeFile(path.join(root, "src", "untracked.ts"), "untracked\n"); await writeFile(path.join(root, "README.md"), "readme\n"); await writeFile(path.join(root, "docs", "outside-policy.md"), "docs\n"); return root; } describe("repository file inventory", () => { it("rejects malformed root policies instead of filtering invalid entries", () => { expect(() => parseRepositoryFileInventoryPolicy({ trackedRoots: ["src", 42], generatedRoots: [], }), ).toThrow(/trackedRoots/u); expect(() => parseRepositoryFileInventoryPolicy({ trackedRoots: [], generatedRoots: [], }), ).toThrow(/trackedRoots/u); }); it.each([ ["spawn failure", { error: new Error("spawn ENOENT") }], ["non-zero exit", { status: 2, stderr: Buffer.from("fatal") }], ["signal", { status: null, signal: "SIGTERM" }], ["stderr output", { stderr: Buffer.from("warning") }], ] as const)("fails closed on git %s", async (_name, failure) => { const repositoryRoot = await repositoryFixture(); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult("src/tracked.ts\0", failure), }), ).rejects.toThrow(/git ls-files/u); }); it.each([ ["malformed UTF-8", Buffer.from([0xc3, 0x28, 0])], ["embedded empty NUL row", Buffer.from("src/tracked.ts\0\0")], ["missing terminal NUL", Buffer.from("src/tracked.ts")], ])("rejects %s output", async (_name, stdout) => { const repositoryRoot = await repositoryFixture(); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult(stdout), }), ).rejects.toThrow(/git ls-files/u); }); it("uses only tracked files and sorts the normalized inventory", async () => { const repositoryRoot = await repositoryFixture(); const inventory = await buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult("src/tracked.ts\0"), }); expect(inventory.trackedFiles).toEqual(["src/tracked.ts"]); expect(inventory.files).not.toContain("src/untracked.ts"); }); it("includes every Git-tracked file outside mandatory policy roots", async () => { const repositoryRoot = await repositoryFixture(); const inventory = await buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult("src/tracked.ts\0README.md\0docs/outside-policy.md\0"), }); expect(inventory.trackedFiles).toEqual([ "README.md", "docs/outside-policy.md", "src/tracked.ts", ]); expect(inventory.files).toEqual(inventory.trackedFiles); }); it("rejects duplicate tracked paths instead of silently deduplicating", async () => { const repositoryRoot = await repositoryFixture(); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult("src/tracked.ts\0src/tracked.ts\0"), }), ).rejects.toThrow(/duplicate/u); }); it("fails when a required root is missing", async () => { const repositoryRoot = await repositoryFixture(); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["missing"], runGit: () => gitResult(""), }), ).rejects.toThrow(/required repository root.*missing/u); }); it("fails when an existing required root has no tracked match", async () => { const repositoryRoot = await repositoryFixture(); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult(""), }), ).rejects.toThrow(/tracked file.*src/u); }); it("ignores only exact ENOENT for configured optional generated roots", async () => { const repositoryRoot = await repositoryFixture(); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], generatedRoots: ["optional-output"], optionalRoots: ["optional-output"], runGit: () => gitResult("src/tracked.ts\0"), }), ).resolves.toMatchObject({ generatedFiles: [] }); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], generatedRoots: ["optional-output"], optionalRoots: ["optional-output"], runGit: () => gitResult("src/tracked.ts\0"), lstatPath: async (target) => { if (target.endsWith("optional-output")) { throw Object.assign(new Error("denied"), { code: "EACCES" }); } const { lstat } = await import("node:fs/promises"); return lstat(target); }, }), ).rejects.toThrow(/optional-output/u); }); it.each(["/absolute", "../escape", "src\\windows.ts"])( "rejects unsafe configured path %s", async (unsafePath) => { const repositoryRoot = await repositoryFixture(); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: [unsafePath], runGit: () => gitResult(""), }), ).rejects.toThrow(/repository-relative POSIX path/u); }, ); it("rejects tracked traversal and non-regular files", async () => { const repositoryRoot = await repositoryFixture(); for (const trackedPath of ["../escape", "src"]) { await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult(`${trackedPath}\0`), }), ).rejects.toThrow(/git ls-files|regular file/u); } }); it("rejects a tracked symlink whose real path escapes the repository", async () => { const repositoryRoot = await repositoryFixture(); const outside = await mkdtemp(path.join(tmpdir(), "inventory-outside-")); await writeFile(path.join(outside, "secret.ts"), "secret\n"); await symlink( path.join(outside, "secret.ts"), path.join(repositoryRoot, "src", "link.ts"), ); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult("src/link.ts\0"), }), ).rejects.toThrow(/symlink|regular file/u); }); it("adds only explicitly configured generated regular files", async () => { const repositoryRoot = await repositoryFixture(); await mkdir(path.join(repositoryRoot, "dist")); await writeFile(path.join(repositoryRoot, "dist", "asset.js"), "asset\n"); const inventory = await buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], generatedRoots: ["dist"], runGit: () => gitResult("src/tracked.ts\0"), }); expect(inventory.generatedFiles).toEqual(["dist/asset.js"]); expect(inventory.files).toEqual(["dist/asset.js", "src/tracked.ts"]); }); it("rejects an exact tracked and generated path collision", async () => { const repositoryRoot = await repositoryFixture(); await writeFile(path.join(repositoryRoot, "package.json"), "{}\n"); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["package.json"], generatedRoots: ["package.json"], runGit: () => gitResult("package.json\0"), }), ).rejects.toThrow(/tracked.*generated.*package\.json/u); }); it("accepts a legal child whose name begins with two dots", async () => { const repositoryRoot = await repositoryFixture(); await mkdir(path.join(repositoryRoot, "..assets")); await writeFile(path.join(repositoryRoot, "..assets", "legal.ts"), "legal\n"); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["..assets"], runGit: () => gitResult("..assets/legal.ts\0"), }), ).resolves.toMatchObject({ trackedFiles: ["..assets/legal.ts"] }); }); it("fails closed when an inventoried file cannot be read", async () => { const repositoryRoot = await repositoryFixture(); await expect( buildRepositoryFileInventory({ repositoryRoot, trackedRoots: ["src"], runGit: () => gitResult("src/tracked.ts\0"), assertReadable: async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); }, }), ).rejects.toThrow(/src\/tracked\.ts/u); }); });