279 lines
9.2 KiB
TypeScript
279 lines
9.2 KiB
TypeScript
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> = {},
|
|
): 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);
|
|
});
|
|
});
|