fix(cli): classify oversized JSON inputs

This commit is contained in:
morluto
2026-10-10 01:53:59 +08:00
parent db171be6e5
commit f7cf4c890d
3 changed files with 162 additions and 12 deletions
+60 -10
View File
@@ -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",
},
};
};
+3 -1
View File
@@ -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) => {
+99 -1
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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();
}
};