fix(evidence): Reject non-file destinations before overwrite advice (#7495)

Check destination kind before overwrite policy and cover directory recovery,
regular-file controls, symlink modes and preservation.

Fixes #7444.

Independent review: APPROVE for patch SHA-256
3d65eb23d01da9b6df786ad0db337dcff79d85923f23678a5168babb88c16080,
relayed by the coordinating thread (reviewer identity not supplied in the
delegated packet). Triggers: file I/O, error handling, three shipping files.

Validation: baseline/candidate runtime builds and actual synthetic CLI probes;
initial focused run 70 passed, 2 Windows EPERM fixture failures, 3 skipped.
Final changed filesystem file 42 passed, 5 skipped; symlink semantics and
full aggregate checks remain unverified. Targeted format/lint/discovery passed.
This commit is contained in:
Wenyu Chiou authored and GitHub committed 2026-10-28 03:41:26 +08:00
1 parent 0cd7ef456b
commit ef11f77836
3 files changed
+113 -23

No files matched your search

+4 -4
View File
@@ -368,14 +368,14 @@ const writeTextPartsOwned = async (
throw cause;
});
if (existing !== undefined) {
if (!overwrite)
return err(
new EvidenceFileError("write", "exists", { path: requestedPath }),
);
if (!existing.isFile() || existing.isSymbolicLink())
return err(
new EvidenceFileError("write", "not-file", { path: requestedPath }),
);
if (!overwrite)
return err(
new EvidenceFileError("write", "exists", { path: requestedPath }),
);
}
assertWriteActive(cancellation);
const bytes = await publishFile(
@@ -1,6 +1,13 @@
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { PRE_66_NULL_COVERAGE_EVIDENCE } from "../../../src/domain/evidenceLegacy.fixture.js";
import { access, readFile, readdir, writeFile } from "node:fs/promises";
import {
access,
lstat,
mkdir,
readFile,
readdir,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
@@ -56,6 +63,71 @@ const revisionBundle = () => {
return createEvidenceBundle(records, [initial, revised]);
};
cliTest(
"rejects directory exports with file-path recovery in both overwrite modes",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-export-directory-cli-");
const source = join(root, "bundle.json");
const output = join(root, "output");
const sentinel = join(output, "keep.txt");
const emptyBundle = createEvidenceBundle([]);
const sourceBytes = JSON.stringify(emptyBundle);
await writeFile(source, sourceBytes);
await mkdir(output);
await writeFile(sentinel, "keep\n");
for (const flags of [[], ["--overwrite"]]) {
const result = await cli.run({
arguments: ["evidence-export", source, output, ...flags, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe("");
expect(result.json).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: { operation: "write", reason: "not-file", path: output },
message: expect.stringContaining("regular file"),
remediation: { action: expect.stringContaining("regular file") },
});
expect((await lstat(output)).isDirectory()).toBe(true);
expect(await readdir(output)).toEqual(["keep.txt"]);
expect(await readFile(sentinel, "utf8")).toBe("keep\n");
expect((await readdir(root)).sort()).toEqual(["bundle.json", "output"]);
}
const file = join(root, "fresh.json");
const arguments_ = ["evidence-export", source, file, "--json"];
const fresh = await cli.run({ arguments: arguments_ });
expect(fresh.exitCode, fresh.stderr).toBe(0);
const exported = await readFile(file, "utf8");
expect(JSON.parse(exported)).toEqual(emptyBundle);
const existingBytes = "previous output\n";
await writeFile(file, existingBytes);
const collision = await cli.run({ arguments: arguments_ });
expect(collision.exitCode).toBe(1);
expect(collision.json).toMatchObject({
code: "invalid_request",
details: { operation: "write", reason: "exists", path: file },
message: expect.stringContaining("allow overwrite"),
});
expect(await readFile(file, "utf8")).toBe(existingBytes);
const replacement = await cli.run({
arguments: [...arguments_, "--overwrite"],
});
expect(replacement.exitCode, replacement.stderr).toBe(0);
expect(JSON.parse(await readFile(file, "utf8"))).toEqual(emptyBundle);
expect(await readFile(source, "utf8")).toBe(sourceBytes);
expect((await readdir(root)).sort()).toEqual([
"bundle.json",
"fresh.json",
"output",
]);
},
);
cliTest.for([
"evidence-import",
"evidence-export",
@@ -1,4 +1,5 @@
import {
lstat,
mkdir,
readdir,
readFile,
@@ -886,6 +887,41 @@ describe("evidence bundle process-capture payloads", () => {
);
});
it.each([true, false])(
"refuses symlink exports in both overwrite modes (target exists: %s)",
async (targetExists) => {
const directory = await createTestTempDirectory("rea-evidence-link-");
const outside = join(directory, "outside");
await mkdir(outside);
const outsidePath = join(outside, "bundle.json");
const encoded = serializeEvidenceBundle(bundle());
if (targetExists) await writeFile(outsidePath, encoded);
const link = join(directory, "escaped.json");
await symlink(outsidePath, link);
if (targetExists)
expect(await readEvidenceBundle(link)).toMatchObject({
ok: true,
value: bundle(),
});
for (const overwrite of [false, true]) {
expect(await writeBundle(bundle(), link, overwrite)).toMatchObject({
ok: false,
error: { _tag: "EvidenceFileError", reason: "not-file", path: link },
});
expect((await lstat(link)).isSymbolicLink()).toBe(true);
expect((await readdir(directory)).sort()).toEqual([
"escaped.json",
"outside",
]);
expect(await readdir(outside)).toEqual(
targetExists ? ["bundle.json"] : [],
);
if (targetExists)
expect(await readFile(outsidePath, "utf8")).toBe(encoded);
}
},
);
describe("evidence bundle filesystem adapter", () => {
it.skipIf(process.platform === "win32").each(["/", "/."])(
"preserves the directory requirement of a missing output ending in %s",
@@ -936,24 +972,6 @@ describe("evidence bundle filesystem adapter", () => {
});
});
it("reads the caller-selected symlink target and refuses to replace a symlink", async () => {
const directory = await createTestTempDirectory("rea-evidence-");
const outside = join(directory, "outside");
await mkdir(outside);
const outsidePath = join(outside, "bundle.json");
await writeFile(outsidePath, serializeEvidenceBundle(bundle()));
const link = join(directory, "escaped.json");
await symlink(outsidePath, link);
expect(await readEvidenceBundle(link)).toMatchObject({
ok: true,
value: bundle(),
});
expect(await writeBundle(bundle(), link, true)).toMatchObject({
ok: false,
error: { _tag: "EvidenceFileError", reason: "not-file" },
});
});
it("reports a missing file or output directory with the selected path", async () => {
const directory = await createTestTempDirectory("rea-evidence-");
const absent = join(directory, "absent.json");