373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { constants } from "node:fs";
|
|
import {
|
|
mkdir,
|
|
link,
|
|
mkdtemp,
|
|
open,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
readRiskCoverageInput,
|
|
resolveRiskCoverageArtifactPath,
|
|
writeRiskCoverageArtifactAtomic,
|
|
} from "../../scripts/lib/risk-coverage-files.ts";
|
|
|
|
const roots: string[] = [];
|
|
|
|
async function fixture(): Promise<string> {
|
|
const root = await mkdtemp(path.join(tmpdir(), "risk-coverage-files-"));
|
|
roots.push(root);
|
|
await mkdir(path.join(root, "config/testing"), { recursive: true });
|
|
await mkdir(path.join(root, "artifacts/tests/coverage"), { recursive: true });
|
|
await writeFile(path.join(root, "config/testing/policy.json"), "{\"policy\":true}\n");
|
|
await writeFile(path.join(root, "artifacts/tests/coverage/summary.json"), "{\"total\":{}}\n");
|
|
return root;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
describe("risk coverage CLI files", () => {
|
|
it("reads only exact contained regular input files", async () => {
|
|
const repositoryRoot = await fixture();
|
|
await expect(
|
|
readRiskCoverageInput({
|
|
repositoryRoot,
|
|
relativePath: "config/testing/policy.json",
|
|
label: "policy",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
relativePath: "config/testing/policy.json",
|
|
text: "{\"policy\":true}\n",
|
|
});
|
|
await expect(
|
|
readRiskCoverageInput({
|
|
repositoryRoot,
|
|
relativePath: path.join(repositoryRoot, "config/testing/policy.json"),
|
|
label: "policy",
|
|
}),
|
|
).rejects.toThrow(/repository-relative POSIX/u);
|
|
await expect(
|
|
readRiskCoverageInput({
|
|
repositoryRoot,
|
|
relativePath: "config\\testing\\policy.json",
|
|
label: "policy",
|
|
}),
|
|
).rejects.toThrow(/repository-relative POSIX/u);
|
|
});
|
|
|
|
it("rejects final and ancestor input symlinks", async () => {
|
|
const repositoryRoot = await fixture();
|
|
const outside = await mkdtemp(path.join(tmpdir(), "risk-coverage-input-outside-"));
|
|
roots.push(outside);
|
|
await writeFile(path.join(outside, "outside.json"), "{}\n");
|
|
await symlink(
|
|
path.join(outside, "outside.json"),
|
|
path.join(repositoryRoot, "config/testing/link.json"),
|
|
);
|
|
await symlink(outside, path.join(repositoryRoot, "linked-config"), "dir");
|
|
await mkdir(path.join(repositoryRoot, "real-config"));
|
|
await writeFile(path.join(repositoryRoot, "real-config/inside.json"), "{}\n");
|
|
await symlink(
|
|
path.join(repositoryRoot, "real-config"),
|
|
path.join(repositoryRoot, "inside-alias"),
|
|
"dir",
|
|
);
|
|
|
|
await expect(
|
|
readRiskCoverageInput({
|
|
repositoryRoot,
|
|
relativePath: "config/testing/link.json",
|
|
label: "policy",
|
|
}),
|
|
).rejects.toThrow(/symlink/u);
|
|
await expect(
|
|
readRiskCoverageInput({
|
|
repositoryRoot,
|
|
relativePath: "linked-config/outside.json",
|
|
label: "policy",
|
|
}),
|
|
).rejects.toThrow(/outside repository|symlink/u);
|
|
await expect(
|
|
readRiskCoverageInput({
|
|
repositoryRoot,
|
|
relativePath: "inside-alias/inside.json",
|
|
label: "policy",
|
|
}),
|
|
).rejects.toThrow(/ancestor is a symlink/u);
|
|
});
|
|
|
|
it("rejects an input identity swap between lstat and open", async () => {
|
|
const repositoryRoot = await fixture();
|
|
const outside = await mkdtemp(path.join(tmpdir(), "risk-coverage-input-race-"));
|
|
roots.push(outside);
|
|
const replacement = path.join(outside, "replacement.json");
|
|
await writeFile(replacement, "{\"replacement\":true}\n");
|
|
|
|
await expect(
|
|
readRiskCoverageInput(
|
|
{
|
|
repositoryRoot,
|
|
relativePath: "config/testing/policy.json",
|
|
label: "policy",
|
|
},
|
|
{
|
|
openFile: async (_target, flags) => open(replacement, flags),
|
|
},
|
|
),
|
|
).rejects.toThrow(/changed during validation/u);
|
|
});
|
|
|
|
it("fails closed when an opened input has no stable file identity", async () => {
|
|
const repositoryRoot = await fixture();
|
|
await expect(
|
|
readRiskCoverageInput(
|
|
{
|
|
repositoryRoot,
|
|
relativePath: "config/testing/policy.json",
|
|
label: "policy",
|
|
},
|
|
{
|
|
openFile: async (target, flags) => {
|
|
const handle = await open(target, flags);
|
|
return {
|
|
stat: async () => {
|
|
const metadata = await handle.stat();
|
|
Object.defineProperties(metadata, {
|
|
dev: { value: 0 },
|
|
ino: { value: 0 },
|
|
});
|
|
return metadata;
|
|
},
|
|
readFile: async (encoding) => handle.readFile(encoding),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow(/stable file identity unavailable/u);
|
|
});
|
|
|
|
it("confines artifact output and rejects input overwrite or symlink ancestors", async () => {
|
|
const repositoryRoot = await fixture();
|
|
await expect(
|
|
resolveRiskCoverageArtifactPath({
|
|
repositoryRoot,
|
|
relativePath: "artifacts/quality/risk-coverage.json",
|
|
inputPaths: ["config/testing/policy.json", "artifacts/tests/coverage/summary.json"],
|
|
}),
|
|
).resolves.toBe(path.join(repositoryRoot, "artifacts/quality/risk-coverage.json"));
|
|
await expect(
|
|
resolveRiskCoverageArtifactPath({
|
|
repositoryRoot,
|
|
relativePath: "config/testing/result.json",
|
|
inputPaths: [],
|
|
}),
|
|
).rejects.toThrow(/artifacts\/quality/u);
|
|
await expect(
|
|
resolveRiskCoverageArtifactPath({
|
|
repositoryRoot,
|
|
relativePath: "artifacts/quality/risk-coverage.json",
|
|
inputPaths: ["artifacts/quality/risk-coverage.json"],
|
|
}),
|
|
).rejects.toThrow(/must not overwrite an input/u);
|
|
|
|
const outside = await mkdtemp(path.join(tmpdir(), "risk-coverage-output-outside-"));
|
|
roots.push(outside);
|
|
await rm(path.join(repositoryRoot, "artifacts/quality"), { recursive: true, force: true });
|
|
await symlink(outside, path.join(repositoryRoot, "artifacts/quality"), "dir");
|
|
await expect(
|
|
resolveRiskCoverageArtifactPath({
|
|
repositoryRoot,
|
|
relativePath: "artifacts/quality/risk-coverage.json",
|
|
inputPaths: [],
|
|
}),
|
|
).rejects.toThrow(/symlink/u);
|
|
});
|
|
|
|
it("rejects input overwrite through a realpath or hard-link alias", async () => {
|
|
const repositoryRoot = await fixture();
|
|
const outputDirectory = path.join(repositoryRoot, "artifacts/quality");
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
const destination = path.join(outputDirectory, "risk-coverage.json");
|
|
await writeFile(destination, "{}\n");
|
|
const hardLinkInput = path.join(repositoryRoot, "config/testing/output-alias.json");
|
|
await link(destination, hardLinkInput);
|
|
|
|
await expect(
|
|
resolveRiskCoverageArtifactPath({
|
|
repositoryRoot,
|
|
relativePath: "artifacts/quality/risk-coverage.json",
|
|
inputPaths: ["config/testing/output-alias.json"],
|
|
}),
|
|
).rejects.toThrow(/same file as an input/u);
|
|
});
|
|
|
|
it("syncs an exclusive sibling temp before atomic rename", async () => {
|
|
const repositoryRoot = await fixture();
|
|
const observed: string[] = [];
|
|
let observedFlags = 0;
|
|
await writeRiskCoverageArtifactAtomic(
|
|
{
|
|
repositoryRoot,
|
|
relativePath: "artifacts/quality/risk-coverage.json",
|
|
inputPaths: ["config/testing/policy.json", "artifacts/tests/coverage/summary.json"],
|
|
value: { schemaVersion: 2, status: "PASS" },
|
|
},
|
|
{
|
|
createNonce: () => "owned",
|
|
fileSystem: {
|
|
openFile: async (target, flags, mode) => {
|
|
observedFlags = flags;
|
|
const handle = await open(target, flags, mode);
|
|
return {
|
|
writeFile: async (data) => handle.writeFile(data, "utf8"),
|
|
sync: async () => {
|
|
observed.push("file-sync");
|
|
await handle.sync();
|
|
},
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
openDirectory: async (target) => {
|
|
const handle = await open(target, constants.O_RDONLY);
|
|
return {
|
|
sync: async () => {
|
|
observed.push("directory-sync");
|
|
await handle.sync();
|
|
},
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
rename: async (source, destination) => {
|
|
observed.push("rename");
|
|
await rename(source, destination);
|
|
},
|
|
rm,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(observedFlags & constants.O_EXCL).toBe(constants.O_EXCL);
|
|
expect(observedFlags & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW);
|
|
expect(observed).toEqual(["file-sync", "rename", "directory-sync"]);
|
|
expect(
|
|
JSON.parse(
|
|
await readFile(
|
|
path.join(repositoryRoot, "artifacts/quality/risk-coverage.json"),
|
|
"utf8",
|
|
),
|
|
),
|
|
).toEqual({ schemaVersion: 2, status: "PASS" });
|
|
});
|
|
|
|
it("cleans its owned temp and preserves destination when publication fails", async () => {
|
|
const repositoryRoot = await fixture();
|
|
const outputDirectory = path.join(repositoryRoot, "artifacts/quality");
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
const destination = path.join(outputDirectory, "risk-coverage.json");
|
|
await writeFile(destination, "previous\n");
|
|
|
|
await expect(
|
|
writeRiskCoverageArtifactAtomic(
|
|
{
|
|
repositoryRoot,
|
|
relativePath: "artifacts/quality/risk-coverage.json",
|
|
inputPaths: [],
|
|
value: { schemaVersion: 2 },
|
|
},
|
|
{
|
|
createNonce: () => "owned",
|
|
fileSystem: {
|
|
openFile: async (target, flags, mode) => {
|
|
const handle = await open(target, flags, mode);
|
|
return {
|
|
writeFile: async (data) => handle.writeFile(data, "utf8"),
|
|
sync: async () => handle.sync(),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
openDirectory: async (target) => {
|
|
const handle = await open(target, constants.O_RDONLY);
|
|
return { sync: async () => handle.sync(), close: async () => handle.close() };
|
|
},
|
|
rename: async () => {
|
|
throw new Error("injected rename failure");
|
|
},
|
|
rm,
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow(/injected rename failure/u);
|
|
|
|
await expect(readFile(destination, "utf8")).resolves.toBe("previous\n");
|
|
expect(await readdir(outputDirectory)).toEqual(["risk-coverage.json"]);
|
|
});
|
|
|
|
it.each([
|
|
["EINVAL", true],
|
|
["ENOTSUP", true],
|
|
["EIO", false],
|
|
] as const)(
|
|
"handles directory sync error %s with an explicit portability fallback",
|
|
async (code, accepted) => {
|
|
const repositoryRoot = await fixture();
|
|
const operation = writeRiskCoverageArtifactAtomic(
|
|
{
|
|
repositoryRoot,
|
|
relativePath: `artifacts/quality/sync-${code}.json`,
|
|
inputPaths: [],
|
|
value: { schemaVersion: 2 },
|
|
},
|
|
{
|
|
createNonce: () => code,
|
|
fileSystem: {
|
|
openFile: async (target, flags, mode) => {
|
|
const handle = await open(target, flags, mode);
|
|
return {
|
|
writeFile: async (data) => handle.writeFile(data, "utf8"),
|
|
sync: async () => handle.sync(),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
openDirectory: async (target) => {
|
|
const handle = await open(target, constants.O_RDONLY);
|
|
return {
|
|
sync: async () => {
|
|
throw Object.assign(new Error(`sync ${code}`), { code });
|
|
},
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
rename,
|
|
rm,
|
|
},
|
|
},
|
|
);
|
|
if (accepted) {
|
|
await expect(operation).resolves.toBeUndefined();
|
|
} else {
|
|
await expect(operation).rejects.toMatchObject({ code });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("has no changed-files gate in the executable", async () => {
|
|
const source = await readFile("scripts/check-risk-coverage.ts", "utf8");
|
|
expect(source).not.toMatch(/changedFiles|changed-files/u);
|
|
});
|
|
});
|