diff --git a/src/cliJsonInput.ts b/src/cliJsonInput.ts index 5c4397bf64..74dd30bc9c 100644 --- a/src/cliJsonInput.ts +++ b/src/cliJsonInput.ts @@ -1,10 +1,14 @@ +import { constants as bufferConstants } from "node:buffer"; import { resolve } from "node:path"; import { NonRegularFileReadError, readRegularFile, } from "./application/RegularFileRead.js"; -import { AnalysisInputError } from "./domain/analysisErrorCore.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "./domain/analysisErrorCore.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; import type { JsonValue } from "./domain/jsonValue.js"; import { safeParseJson } from "./domain/safeJson.js"; @@ -18,14 +22,16 @@ export const parseCliJsonInput = async ( | { readonly ok: false; readonly error: JsonValue } > => { const inline = parseJson(value); - if (inline !== undefined) return { ok: true, value: inline }; + if (inline.kind === "valid") return { ok: true, value: inline.value }; + if (inline.kind === "too-large") + return jsonTooLargeError(undefined, operation); try { // Read raw bytes so invalid UTF-8 is rejected by parseJson instead of // being silently replaced by lossy "utf8" decoding. const parsed = parseJson(await readRegularFile(value)); - return parsed === undefined - ? jsonFileError(value, operation, "invalid-json") - : { ok: true, value: parsed }; + if (parsed.kind === "valid") return { ok: true, value: parsed.value }; + if (parsed.kind === "too-large") return jsonTooLargeError(value, operation); + return jsonFileError(value, operation, "invalid-json"); } catch (cause: unknown) { if ( ["{", "["].includes(value.trimStart()[0] ?? "") && @@ -87,7 +93,12 @@ const cannotBeAnExistingFile = (cause: unknown): boolean => const hasExplicitJsonFileExtension = (value: string): boolean => value.toLowerCase().endsWith(".json"); -const parseJson = (value: string | Uint8Array): unknown => { +type JsonParseResult = + | { readonly kind: "valid"; readonly value: unknown } + | { readonly kind: "invalid" } + | { readonly kind: "too-large" }; + +const parseJson = (value: string | Uint8Array): JsonParseResult => { let text: string; if (typeof value === "string") { text = value; @@ -98,15 +109,24 @@ const parseJson = (value: string | Uint8Array): unknown => { ignoreBOM: true, }).decode(value); } catch (cause: unknown) { - // Decoding failure means the bytes are not valid UTF-8 JSON input. - void cause; - return undefined; + return isStringLengthLimit(cause) + ? { kind: "too-large" } + : { kind: "invalid" }; } } const parsed = safeParseJson(text); - return parsed.ok ? parsed.value : undefined; + if (parsed.ok) return { kind: "valid", value: parsed.value }; + return isStringLengthLimit(parsed.cause) + ? { kind: "too-large" } + : { kind: "invalid" }; }; +const isStringLengthLimit = (cause: unknown): boolean => + cause instanceof Error && + /cannot create a string longer than|invalid string length/i.test( + cause.message, + ); + const inputError = (operation: string): JsonValue => ({ error: "Application workflow failed", ...projectAnalysisError( @@ -157,3 +177,33 @@ const readFailureIssue = (path: string | undefined, cause: unknown) => { : `The JSON input file could not be read${code}: ${path ?? ""}`, }; }; + +const jsonTooLargeError = ( + path: string | undefined, + operation: string, +): { readonly ok: false; readonly error: JsonValue } => { + const projection = projectAnalysisError( + new AnalysisResourceConstraintError( + operation, + "memory", + `The JSON input exceeds this Node.js runtime's maximum string length (${bufferConstants.MAX_STRING_LENGTH} UTF-16 code units) and cannot be parsed as one value.`, + { + boundary: "cli-json-input", + max_string_code_units: bufferConstants.MAX_STRING_LENGTH, + }, + { + remediationAction: + "Split the JSON into smaller inputs or rerun its producer on a smaller subset, then retry.", + }, + ), + ); + return { + ok: false, + error: { + error: "Application workflow failed", + ...projection, + ...(path === undefined ? {} : { input_path: path }), + input_reason: "too-large", + }, + }; +}; diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts index a535f6abf2..812dc89d62 100644 --- a/src/contracts/errorSchemas.ts +++ b/src/contracts/errorSchemas.ts @@ -73,7 +73,9 @@ export const analysisCliErrorEnvelopeSchema = z .object({ error: z.string().min(1), input_path: z.string().optional(), - input_reason: z.enum(["invalid-json", "read-failed"]).optional(), + input_reason: z + .enum(["invalid-json", "read-failed", "too-large"]) + .optional(), }) .passthrough() .superRefine((value, context) => { diff --git a/tests/boundary/filesystem/cliJsonInput.test.ts b/tests/boundary/filesystem/cliJsonInput.test.ts index d75dac4e3d..273168fdbe 100644 --- a/tests/boundary/filesystem/cliJsonInput.test.ts +++ b/tests/boundary/filesystem/cliJsonInput.test.ts @@ -1,11 +1,20 @@ import { execFile } from "node:child_process"; -import { chmod, symlink, writeFile } from "node:fs/promises"; +import { constants as bufferConstants } from "node:buffer"; +import { + chmod, + open, + symlink, + type FileHandle, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it, onTestFinished } from "vitest"; +import { isCliOperationFailure } from "../../../src/cliLogging.js"; import { parseCliJsonInput } from "../../../src/cliJsonInput.js"; +import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js"; import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -85,6 +94,51 @@ describe("CLI JSON file selection", () => { }); describe("CLI JSON input", () => { + it("reports a valid file beyond the runtime string limit as too large", async () => { + const root = await createTestTempDirectory("rea-json-input-too-large-"); + const path = join(root, "input.json"); + const size = bufferConstants.MAX_STRING_LENGTH + 1; + await writeValidOversizedObject(path, size); + + const result = await parseCliJsonInput(path, "test-input"); + if (result.ok) throw new Error("Expected oversized JSON to be rejected"); + expect(analysisCliErrorEnvelopeSchema.parse(result.error)).toEqual( + result.error, + ); + expect(result).toMatchObject({ + ok: false, + error: { + code: "resource_constraint", + input_path: path, + input_reason: "too-large", + details: { + resource: "memory", + reported_limits: { + boundary: "cli-json-input", + max_string_code_units: bufferConstants.MAX_STRING_LENGTH, + }, + }, + remediation: { + action: + "Split the JSON into smaller inputs or rerun its producer on a smaller subset, then retry.", + }, + }, + }); + + expect(isCliOperationFailure(result.error)).toBe(true); + + const characterCount = + Math.floor(bufferConstants.MAX_STRING_LENGTH / 2) + 2; + await writeValidMultibyteString(path, characterCount); + + const multibyteResult = await parseCliJsonInput(path, "test-input"); + if (!multibyteResult.ok) + throw new Error("Expected large multibyte JSON to fit the string limit"); + if (typeof multibyteResult.value !== "string") + throw new Error("Expected parsed JSON string"); + expect(multibyteResult.value.length).toBe(characterCount); + }, 20_000); + it("distinguishes malformed inline text from files and preserves bracket-prefixed paths", async () => { expect(await parseCliJsonInput("[", "test-input")).toMatchObject({ ok: false, @@ -177,3 +231,47 @@ describe("CLI JSON input", () => { }, ); }); + +const writeRepeatedBytes = async ( + handle: FileHandle, + bytes: Buffer, + length: number, +): Promise => { + let remaining = length; + while (remaining > 0) { + const chunkLength = Math.min(bytes.length, remaining); + await handle.writeFile(bytes.subarray(0, chunkLength)); + remaining -= chunkLength; + } +}; + +const writeValidOversizedObject = async ( + path: string, + size: number, +): Promise => { + const handle = await open(path, "w"); + const whitespace = Buffer.alloc(1024 * 1024, 0x20); + try { + await handle.writeFile("{"); + await writeRepeatedBytes(handle, whitespace, size - 2); + await handle.writeFile("}"); + } finally { + await handle.close(); + } +}; + +const writeValidMultibyteString = async ( + path: string, + characterCount: number, +): Promise => { + const encodedCharacters = Buffer.from("é".repeat(512 * 1024)); + const byteLength = characterCount * 2 + 2; + const handle = await open(path, "w"); + try { + await handle.writeFile('"'); + await writeRepeatedBytes(handle, encodedCharacters, byteLength - 2); + await handle.writeFile('"'); + } finally { + await handle.close(); + } +};