mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
1 parent
0cd7ef456b
commit
ef11f77836
3 files changed
+113
-23
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user