mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 14:26:07 +00:00
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:
1 parent
28a33cae8f
commit
2944afa5b8
2 files changed
+143
-5
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user