Files
rea/src/application/Utf8JsonInput.ts
T
Kaoru0822andClaude Opus 5.5 a936303723 fix(json): name a leading byte-order mark in rejected JSON input (#1510)
Strict JSON file inputs reject a leading UTF-8 byte-order mark, but the
diagnostic was the parser's own message, which quotes the invisible U+FEFF
as the unexpected token: Unexpected token '', "{"invento"... is not
valid JSON. Editors such as Windows PowerShell 5.1 write that mark by
default, and nothing in the message showed it.

CLI JSON files (whole and streamed), Evidence and snapshot JSON files, and
process scenario files now report that the input begins with a UTF-8
byte-order mark and how to save it. Acceptance is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:37:41 -07:00

91 lines
3.4 KiB
TypeScript

import { constants } from "node:buffer";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { safeParseJson, type SafeJsonParseResult } from "../domain/safeJson.js";
/** Recovery that preserves complete, authenticated Evidence workflow inputs. */
export const JSON_INPUT_RESOURCE_REMEDIATION =
"Provide a smaller valid JSON value. For Evidence workflows, re-analyze a smaller selection of the original target and use its complete Evidence; splitting JSON text or trimming Evidence fields does not produce valid workflow input.";
/**
* Strict JSON input keeps rejecting a leading byte-order mark, but names it:
* the parser's own message quotes the invisible U+FEFF as the bad token.
*/
export const JSON_BYTE_ORDER_MARK_MESSAGE =
"JSON input begins with a UTF-8 byte-order mark (EF BB BF), which strict JSON input does not accept. Save the file as UTF-8 without a byte-order mark.";
/** Decode JSON bytes without confusing a runtime string limit with malformed input. */
export const parseUtf8Json = (
bytes: Uint8Array,
operation: string,
path: string,
boundary = "json-input",
): SafeJsonParseResult => {
const stringLimitFailure = (
cause?: unknown,
decodedUnits?: number,
): AnalysisResourceConstraintError =>
new AnalysisResourceConstraintError(
operation,
"memory",
"JSON input exceeds the runtime's maximum decoded string length",
{
boundary,
input_path: path,
input_bytes: bytes.byteLength,
max_string_code_units: constants.MAX_STRING_LENGTH,
...(decodedUnits === undefined
? {}
: { decoded_string_code_units_at_least: decodedUnits }),
},
{
...(cause === undefined ? {} : { cause }),
remediationAction: JSON_INPUT_RESOURCE_REMEDIATION,
},
);
let text: string;
try {
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
if (bytes.byteLength <= constants.MAX_STRING_LENGTH) {
text = decoder.decode(bytes);
} else {
// Node 22's UTF-8 fast path bounds encoded bytes before decoding. Decode
// large inputs in chunks so valid multibyte text is bounded by its actual
// UTF-16 length. Streaming also preserves characters spanning chunks.
const parts: string[] = [];
let decodedUnits = 0;
const append = (part: string): void => {
decodedUnits += part.length;
if (decodedUnits > constants.MAX_STRING_LENGTH)
throw stringLimitFailure(undefined, decodedUnits);
parts.push(part);
};
const chunkBytes = 1024 * 1024;
for (let offset = 0; offset < bytes.byteLength; offset += chunkBytes)
append(
decoder.decode(bytes.subarray(offset, offset + chunkBytes), {
stream: true,
}),
);
append(decoder.decode());
text = parts.join("");
}
} catch (cause: unknown) {
if (cause instanceof AnalysisResourceConstraintError) throw cause;
const code =
typeof cause === "object" && cause !== null && "code" in cause
? cause.code
: undefined;
if (code === "ERR_STRING_TOO_LONG") throw stringLimitFailure(cause);
if (code !== "ERR_ENCODING_INVALID_ENCODED_DATA") throw cause;
return {
ok: false,
error: "JSON input is not valid UTF-8",
cause,
};
}
if (text.startsWith("\uFEFF"))
return { ok: false, error: JSON_BYTE_ORDER_MARK_MESSAGE, cause: undefined };
return safeParseJson(text);
};