mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(cli): classify oversized JSON inputs
This commit is contained in:
+60
-10
@@ -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",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user