fix: close coverage evidence races
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
declaredContentLength,
|
||||
decodeJsonBytes,
|
||||
isEffectivelyEmpty,
|
||||
isJsonMediaType,
|
||||
probeForbiddenBody,
|
||||
readBoundedBytes,
|
||||
} from "../../src/adapters/http/bounded-body-reader.ts";
|
||||
|
||||
function responseWithBody(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
contentLength?: string,
|
||||
): Response {
|
||||
return new Response(body, {
|
||||
headers:
|
||||
contentLength === undefined ? undefined : { "content-length": contentLength },
|
||||
});
|
||||
}
|
||||
|
||||
describe("bounded body reader", () => {
|
||||
it.each([
|
||||
[null, false],
|
||||
["", false],
|
||||
[" application/json ; charset=utf-8 ", true],
|
||||
["APPLICATION/PROBLEM+JSON", true],
|
||||
["text/json", false],
|
||||
["application/jsonp", false],
|
||||
] as const)("classifies JSON media type %j", (header, expected) => {
|
||||
expect(isJsonMediaType(header)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, null],
|
||||
["0", 0],
|
||||
["12", 12],
|
||||
["-1", null],
|
||||
["NaN", null],
|
||||
["Infinity", null],
|
||||
] as const)("parses declared content length %s", (header, expected) => {
|
||||
expect(declaredContentLength(responseWithBody(null, header))).toBe(expected);
|
||||
});
|
||||
|
||||
it("rejects an oversized declared body and tolerates cancellation failure", async () => {
|
||||
const cancel = vi.fn(async () => {
|
||||
throw new Error("already settled");
|
||||
});
|
||||
const response = {
|
||||
headers: new Headers({ "content-length": "9" }),
|
||||
body: { cancel },
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(readBoundedBytes(response, 8)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_TOO_LARGE",
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns empty bytes when a successful response has no body", async () => {
|
||||
const result = await readBoundedBytes(responseWithBody(null), 8);
|
||||
expect(result).toEqual({ ok: true, bytes: new Uint8Array(0) });
|
||||
});
|
||||
|
||||
it("joins chunks without retaining empty chunks", async () => {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(0));
|
||||
controller.enqueue(Uint8Array.of(1, 2));
|
||||
controller.enqueue(Uint8Array.of(3));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readBoundedBytes(responseWithBody(body), 3)).resolves.toEqual({
|
||||
ok: true,
|
||||
bytes: Uint8Array.of(1, 2, 3),
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels streaming input as soon as the accumulated limit is exceeded", async () => {
|
||||
const cancel = vi.fn().mockRejectedValue(new Error("cancel failed"));
|
||||
const reader = {
|
||||
read: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: Uint8Array.of(1, 2) })
|
||||
.mockResolvedValueOnce({ done: false, value: Uint8Array.of(3, 4) }),
|
||||
cancel,
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(readBoundedBytes(response, 3)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_TOO_LARGE",
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("maps reader failure and cancellation failure to a stream failure", async () => {
|
||||
const reader = {
|
||||
read: vi.fn().mockRejectedValue(new Error("stream failed")),
|
||||
cancel: vi.fn().mockRejectedValue(new Error("cancel failed")),
|
||||
releaseLock: vi.fn(() => {
|
||||
throw new Error("already released");
|
||||
}),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(readBoundedBytes(response, 3)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_STREAM_FAILURE",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a forbidden body from declared length without reading it", async () => {
|
||||
const cancel = vi.fn();
|
||||
const response = {
|
||||
headers: new Headers({ "content-length": "1" }),
|
||||
body: { cancel },
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(probeForbiddenBody(response)).resolves.toEqual({
|
||||
ok: true,
|
||||
present: true,
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("accepts an absent, completed, or zero-byte forbidden body", async () => {
|
||||
await expect(probeForbiddenBody(responseWithBody(null))).resolves.toEqual({
|
||||
ok: true,
|
||||
present: false,
|
||||
});
|
||||
|
||||
for (const next of [
|
||||
{ done: true, value: undefined },
|
||||
{ done: false, value: new Uint8Array(0) },
|
||||
]) {
|
||||
const reader = {
|
||||
read: vi.fn().mockResolvedValue(next),
|
||||
cancel: vi.fn(),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
await expect(probeForbiddenBody(response)).resolves.toEqual({
|
||||
ok: true,
|
||||
present: false,
|
||||
});
|
||||
expect(reader.cancel).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("probes only one present byte and cancels the remaining body", async () => {
|
||||
const reader = {
|
||||
read: vi.fn().mockResolvedValue({ done: false, value: Uint8Array.of(1) }),
|
||||
cancel: vi.fn().mockRejectedValue(new Error("cancel failed")),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(probeForbiddenBody(response)).resolves.toEqual({
|
||||
ok: true,
|
||||
present: true,
|
||||
});
|
||||
expect(reader.read).toHaveBeenCalledOnce();
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("maps a forbidden-body probe error even when cleanup also fails", async () => {
|
||||
const reader = {
|
||||
read: vi.fn().mockRejectedValue(new Error("probe failed")),
|
||||
cancel: vi.fn().mockRejectedValue(new Error("cancel failed")),
|
||||
releaseLock: vi.fn(() => {
|
||||
throw new Error("released");
|
||||
}),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(probeForbiddenBody(response)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_STREAM_FAILURE",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => {
|
||||
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
|
||||
ok: true,
|
||||
value: { ok: true },
|
||||
});
|
||||
expect(decodeJsonBytes(Uint8Array.of(0xc3, 0x28))).toEqual({
|
||||
ok: false,
|
||||
code: "UTF8_INVALID",
|
||||
});
|
||||
expect(decodeJsonBytes(new TextEncoder().encode("{"))).toEqual({
|
||||
ok: false,
|
||||
code: "JSON_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new Uint8Array(0), true],
|
||||
[Uint8Array.of(0x20, 0x09, 0x0a, 0x0d), true],
|
||||
[Uint8Array.of(0x20, 0x00), false],
|
||||
])("classifies effective emptiness %#", (bytes, expected) => {
|
||||
expect(isEffectivelyEmpty(bytes)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -186,6 +186,34 @@ describe("repository-aware risk coverage", () => {
|
||||
).toThrow(/unexpected coverage path.*tests\/unit\/a\.test\.ts/u);
|
||||
});
|
||||
|
||||
it("rejects outside and duplicate normalized producer paths", () => {
|
||||
const parsedPolicy = parseRiskCoveragePolicy(
|
||||
policy({ repositoryBaseline: 1, generatedPaths: [] }),
|
||||
{ now },
|
||||
);
|
||||
const base = {
|
||||
repositoryRoot: "/repository",
|
||||
inventory: inventory(["src/a.ts"]),
|
||||
policy: parsedPolicy,
|
||||
};
|
||||
expect(() =>
|
||||
evaluateRiskCoverage({
|
||||
...base,
|
||||
summary: { total: fullMetrics, "/outside/src/a.ts": fullMetrics },
|
||||
}),
|
||||
).toThrow(/outside repository/u);
|
||||
expect(() =>
|
||||
evaluateRiskCoverage({
|
||||
...base,
|
||||
summary: {
|
||||
total: metrics(2),
|
||||
"src/a.ts": fullMetrics,
|
||||
"/repository/src/a.ts": fullMetrics,
|
||||
},
|
||||
}),
|
||||
).toThrow(/duplicate coverage path/u);
|
||||
});
|
||||
|
||||
it("accepts only explicitly configured generated coverage paths", () => {
|
||||
const parsedPolicy = parseRiskCoveragePolicy(
|
||||
policy({ repositoryBaseline: 1 }),
|
||||
@@ -341,6 +369,22 @@ describe("repository-aware risk coverage", () => {
|
||||
expect(observedFlags & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW);
|
||||
});
|
||||
|
||||
it("rejects a post-lstat file identity swap even without relying on O_NOFOLLOW", async () => {
|
||||
const repositoryRoot = await repositoryFixture();
|
||||
const outside = await mkdtemp(path.join(tmpdir(), "risk-coverage-race-"));
|
||||
roots.push(outside);
|
||||
const outsideFile = path.join(outside, "replacement.ts");
|
||||
await writeFile(outsideFile, "export const replacement = true;\n");
|
||||
|
||||
await expect(
|
||||
buildProductionModuleInventory({
|
||||
repositoryRoot,
|
||||
openFile: async (target, flags) =>
|
||||
open(target.endsWith("src/a.ts") ? outsideFile : target, flags),
|
||||
}),
|
||||
).rejects.toThrow(/changed during validation/u);
|
||||
});
|
||||
|
||||
it("fails closed on empty, traversing, symlinked, or stale generated inventory", async () => {
|
||||
const repositoryRoot = await repositoryFixture();
|
||||
const emptyRoot = await mkdtemp(path.join(tmpdir(), "risk-coverage-empty-"));
|
||||
|
||||
Reference in New Issue
Block a user