diff --git a/src/cliJsonInput.ts b/src/cliJsonInput.ts index 8bb2d105a5..9081b875e7 100644 --- a/src/cliJsonInput.ts +++ b/src/cliJsonInput.ts @@ -128,10 +128,26 @@ const jsonFileError = ( cause === undefined ? undefined : { cause }, reason === "invalid-json" ? [{ path: [], reason: "invalid_format", expected: "JSON" }] - : [], + : [readFailureIssue(path, cause)], ), ), ...(path === undefined ? {} : { input_path: path }), input_reason: reason, }, }); + +/** Name the system error so the caller can tell a typo from a permission denial. */ +const readFailureIssue = (path: string | undefined, cause: unknown) => { + const code = + typeof cause === "object" && + cause !== null && + "code" in cause && + typeof cause.code === "string" + ? ` (${cause.code})` + : ""; + return { + path: [], + reason: "invalid_value" as const, + message: `The JSON input file could not be read${code}: ${path ?? ""}`, + }; +}; diff --git a/tests/boundary/filesystem/cliJsonInput.test.ts b/tests/boundary/filesystem/cliJsonInput.test.ts index 406f14b667..1095840e0e 100644 --- a/tests/boundary/filesystem/cliJsonInput.test.ts +++ b/tests/boundary/filesystem/cliJsonInput.test.ts @@ -1,7 +1,7 @@ -import { writeFile } from "node:fs/promises"; +import { chmod, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { parseCliJsonInput } from "../../../src/cliJsonInput.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -50,4 +50,52 @@ describe("CLI JSON input", () => { error: { input_reason: "read-failed" }, }); }); + + it.each([ + ["a missing file", "ENOENT"], + ["a directory", "EISDIR"], + ] as const)( + "lists the system cause when %s cannot be read", + async (kind, code) => { + const root = await createTestTempDirectory("rea-json-input-cause-"); + const path = kind === "a directory" ? root : join(root, "missing.json"); + expect(await parseCliJsonInput(path, "test-input")).toMatchObject({ + ok: false, + error: { + code: "invalid_request", + input_path: path, + input_reason: "read-failed", + details: { + issues: [ + { + path: [], + reason: "invalid_value", + message: `The JSON input file could not be read (${code}): ${path}`, + }, + ], + }, + }, + }); + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "lists a permission denial as the cause of an unreadable file", + async () => { + const root = await createTestTempDirectory("rea-json-input-denied-"); + const path = join(root, "input.json"); + await writeFile(path, "{}"); + await chmod(path, 0o000); + onTestFinished(() => chmod(path, 0o600)); + expect(await parseCliJsonInput(path, "test-input")).toMatchObject({ + ok: false, + error: { + input_reason: "read-failed", + details: { + issues: [{ message: expect.stringContaining("(EACCES)") }], + }, + }, + }); + }, + ); });