diff --git a/src/javascript/recovery/RecoveryFiles.test.ts b/src/javascript/recovery/RecoveryFiles.test.ts index 574699abd5..10c2c5d689 100644 --- a/src/javascript/recovery/RecoveryFiles.test.ts +++ b/src/javascript/recovery/RecoveryFiles.test.ts @@ -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(); + return { + ...actual, + mkdir: async (...args: Parameters) => { + if (snapshotFault.mkdir !== undefined) throw snapshotFault.mkdir; + return actual.mkdir(...args); + }, + writeFile: async (...args: Parameters) => { + 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, +): Promise => { + 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, + ); +}); diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index cfc4c7ef32..28571a7345 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -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,