fix(javascript): report colliding recovery inputs as input errors (#6520)

* fix(javascript): report recovery inputs that collide on a case-insensitive filesystem

* test(javascript): cover a recovery input selected twice on any filesystem

* test(javascript): cover snapshot collision causes and unrelated write failures
This commit is contained in:
Adolanium authored and GitHub committed 2026-10-23 15:53:49 +08:00
1 parent 28a33cae8f
commit 2944afa5b8
2 files changed
+143 -5

No files matched your search

+124 -3
View File
@@ -1,9 +1,12 @@
import { execFile } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it, vi } from "vitest";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import {
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
import {
openRegularFile,
@@ -11,7 +14,7 @@ import {
} from "../../filesystem/RegularFile.js";
import { readWithoutFifoWriter } from "../../../tests/fixtures/fifoInput.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { readRecoveryFile } from "./RecoveryFiles.js";
import { readRecoveryFile, snapshotRecoveryInputs } from "./RecoveryFiles.js";
vi.mock("../../filesystem/RegularFile.js", async (importOriginal) => {
const actual =
@@ -19,6 +22,49 @@ vi.mock("../../filesystem/RegularFile.js", async (importOriginal) => {
return { ...actual, openRegularFile: vi.fn(actual.openRegularFile) };
});
// Fault injection for the snapshot write; other calls use the real filesystem.
const snapshotFault = vi.hoisted(() => ({
mkdir: undefined as Error | undefined,
writeFile: undefined as Error | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
mkdir: async (...args: Parameters<typeof actual.mkdir>) => {
if (snapshotFault.mkdir !== undefined) throw snapshotFault.mkdir;
return actual.mkdir(...args);
},
writeFile: async (...args: Parameters<typeof actual.writeFile>) => {
if (snapshotFault.writeFile !== undefined) throw snapshotFault.writeFile;
return actual.writeFile(...args);
},
};
});
const filesystemError = (code: string): NodeJS.ErrnoException =>
Object.assign(new Error(`${code}: injected snapshot failure`), { code });
const snapshotWithFault = async (
fault: Partial<typeof snapshotFault>,
): Promise<unknown> => {
const root = await createTestTempDirectory("rea-recovery-fault-");
const selected = join(root, "bundle.js");
await writeFile(selected, "module.exports = 1;\n");
const snapshots = join(root, "snapshots");
await mkdir(snapshots);
Object.assign(snapshotFault, fault);
try {
return await snapshotRecoveryInputs([selected], snapshots).catch(
(cause: unknown) => cause,
);
} finally {
snapshotFault.mkdir = undefined;
snapshotFault.writeFile = undefined;
}
};
it.skipIf(process.platform === "win32")(
"rejects a named pipe without waiting for a writer",
async () => {
@@ -53,3 +99,78 @@ it("keeps a failed admission close owned when cancellation is also requested", a
readRecoveryFile("/selected.js", 1024, controller.signal),
).rejects.toBe(failure);
});
it("reports one file selected under two letter cases as an input error", async (context) => {
const root = await createTestTempDirectory("rea-recovery-case-");
const lower = join(root, "bundle.js");
const upper = join(root, "BUNDLE.js");
await writeFile(lower, "module.exports = 1;\n");
// Only a case-insensitive filesystem, such as the default Windows and macOS
// ones, resolves both spellings to the selected file.
if (
!(await stat(upper).then(
() => true,
() => false,
))
)
context.skip();
const snapshots = join(root, "snapshots");
await mkdir(snapshots);
const failure = await snapshotRecoveryInputs([lower, upper], snapshots).catch(
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(AnalysisInputError);
expect((failure as AnalysisInputError).issues).toEqual([
expect.objectContaining({
path: ["additional_paths"],
message: expect.stringContaining(upper),
}),
]);
});
it("reports one file selected twice as an input error", async () => {
const root = await createTestTempDirectory("rea-recovery-twice-");
const selected = join(root, "bundle.js");
await writeFile(selected, "module.exports = 1;\n");
const snapshots = join(root, "snapshots");
await mkdir(snapshots);
const failure = await snapshotRecoveryInputs(
[selected, selected],
snapshots,
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisInputError);
expect((failure as AnalysisInputError).issues).toEqual([
expect.objectContaining({
path: ["additional_paths"],
message: expect.stringContaining("collides with another selected input"),
}),
]);
// The filesystem's own report stays attached for diagnosis.
expect((failure as AnalysisInputError).cause).toMatchObject({
code: "EEXIST",
});
});
it("reports a file and directory name clash as an input error", async () => {
const clash = filesystemError("ENOTDIR");
const failure = await snapshotWithFault({ mkdir: clash });
expect(failure).toBeInstanceOf(AnalysisInputError);
expect((failure as AnalysisInputError).issues).toEqual([
expect.objectContaining({ path: ["additional_paths"] }),
]);
expect((failure as AnalysisInputError).cause).toBe(clash);
});
it("keeps an unrelated snapshot write failure unchanged", async () => {
const fullDisk = filesystemError("ENOSPC");
await expect(snapshotWithFault({ writeFile: fullDisk })).resolves.toBe(
fullDisk,
);
});
+19 -2
View File
@@ -194,8 +194,25 @@ const snapshotRecoveryInput = async (
},
]);
}
await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 });
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
try {
await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 });
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
} catch (cause: unknown) {
// The snapshot directory starts empty, so a collision means another
// selected input already took this name: the same file under another
// spelling, or a name that differs only in a way this filesystem ignores.
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "EEXIST" || cause.code === "ENOTDIR")
)
throw recoveryInputFailure(
"additional_paths",
`Selected input ${path} collides with another selected input at ${relativePath} on this filesystem. Select each file once.`,
cause,
);
throw cause;
}
return {
path,
snapshot_path: snapshotPath,