fix: close coverage evidence races

This commit is contained in:
DongHyeonka
2026-08-02 08:43:44 +09:00
parent 67cd37659d
commit 8d6fbb97e9
7 changed files with 733 additions and 260 deletions
+102
View File
@@ -1,6 +1,7 @@
import { constants } from "node:fs";
import {
mkdir,
link,
mkdtemp,
open,
readFile,
@@ -78,6 +79,13 @@ describe("risk coverage CLI files", () => {
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({
@@ -93,6 +101,34 @@ describe("risk coverage CLI files", () => {
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("confines artifact output and rejects input overwrite or symlink ancestors", async () => {
@@ -132,6 +168,24 @@ describe("risk coverage CLI files", () => {
).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[] = [];
@@ -233,6 +287,54 @@ describe("risk coverage CLI files", () => {
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);