From fc30a6c67d6a43fd062cfb698776d6bdf4c0bdb4 Mon Sep 17 00:00:00 2001 From: qingminlong Date: Sun, 19 Jul 2026 08:47:15 +0800 Subject: [PATCH] fix(oc-path): reject oversized multibyte JSONC input (#104140) * fix(oc-path): enforce JSONC byte limit for multibyte input * fix(oc-path): surface JSONC size diagnostics in CLI * fix(oc-path): classify oversized JSONC as parse error * test(oc-path): cover JSONC byte cap boundaries --------- Co-authored-by: Peter Steinberger --- extensions/oc-path/src/cli.test.ts | 17 +++++++++ extensions/oc-path/src/cli.ts | 38 ++++++++++++++++--- extensions/oc-path/src/oc-path/jsonc/parse.ts | 13 ++++--- .../src/oc-path/tests/jsonc/parse.test.ts | 35 +++++++++++++---- 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/extensions/oc-path/src/cli.test.ts b/extensions/oc-path/src/cli.test.ts index 4529db76a33d..89e07505367b 100644 --- a/extensions/oc-path/src/cli.test.ts +++ b/extensions/oc-path/src/cli.test.ts @@ -11,6 +11,8 @@ import { Command, CommanderError } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { registerPathCli } from "./cli.js"; +const JSONC_INPUT_LIMIT_BYTES = 16 * 1024 * 1024; + type PathCommandOptions = { readonly json?: boolean; readonly human?: boolean; @@ -256,6 +258,21 @@ describe("openclaw path CLI", () => { expect(rt.exitCode).toBe(1); expect(stderrText(rt)).toContain("missing required argument"); }); + + it("rejects oversized multibyte JSONC with the typed diagnostic", async () => { + const filePath = join(workspaceDir, "oversized.json"); + const content = `"${"界".repeat(Math.floor(JSONC_INPUT_LIMIT_BYTES / 3) + 1)}"`; + writeFileSync(filePath, content, "utf-8"); + const rt = createTestRuntime(); + + await pathResolveCommand("oc://oversized.json/value", { cwd: workspaceDir, json: true }, rt); + + expect(rt.exitCode).toBe(2); + expect(stdoutText(rt)).toBe(""); + expect(JSON.parse(stderrText(rt))).toMatchObject({ + error: { code: "OC_JSONC_INPUT_TOO_LARGE" }, + }); + }); }); describe("set", () => { diff --git a/extensions/oc-path/src/cli.ts b/extensions/oc-path/src/cli.ts index e12d9bb9e6b2..6e99d8cb5a73 100644 --- a/extensions/oc-path/src/cli.ts +++ b/extensions/oc-path/src/cli.ts @@ -160,11 +160,25 @@ function catchSentinel( } } -async function loadAst(absPath: string, fileName: string): Promise { +async function loadAst( + absPath: string, + fileName: string, + runtime: OutputRuntimeEnv, + mode: OutputMode, +): Promise { const raw = await fs.readFile(absPath, "utf-8"); const kind = inferKind(fileName); if (kind === "jsonc") { - return parseJsonc(raw).ast; + const result = parseJsonc(raw); + const sizeDiagnostic = result.diagnostics.find( + (diagnostic) => diagnostic.code === "OC_JSONC_INPUT_TOO_LARGE", + ); + if (sizeDiagnostic) { + emitError(runtime, mode, sizeDiagnostic.message, sizeDiagnostic.code); + runtime.exit(2); + return null; + } + return result.ast; } if (kind === "jsonl") { return parseJsonl(raw).ast; @@ -283,7 +297,10 @@ async function pathResolveCommand( if (ocPath === null) { return; } - const ast = await loadAst(resolveFsPath(ocPath, options), ocPath.file); + const ast = await loadAst(resolveFsPath(ocPath, options), ocPath.file, runtime, mode); + if (ast === null) { + return; + } let match: OcMatch | null; try { match = resolveOcPath(ast, ocPath); @@ -333,7 +350,10 @@ async function pathSetCommand( } const fsPath = resolveFsPath(ocPath, options); const oldBytes = await fs.readFile(fsPath, "utf-8"); - const ast = await loadAst(fsPath, ocPath.file); + const ast = await loadAst(fsPath, ocPath.file, runtime, mode); + if (ast === null) { + return; + } const result = catchSentinel("set", runtime, mode, () => setOcPath(ast, ocPath, value, { valueJson: options.valueJson === true }), @@ -407,7 +427,10 @@ async function pathFindCommand( runtime.exit(2); return; } - const ast = await loadAst(resolveFsPath(pattern, options), pattern.file); + const ast = await loadAst(resolveFsPath(pattern, options), pattern.file, runtime, mode); + if (ast === null) { + return; + } const matches = findOcPaths(ast, pattern); emit( runtime, @@ -506,7 +529,10 @@ async function pathEmitCommand( ? resolvePath(options.file) : resolvePath(options.cwd ?? process.cwd(), fileArg); const fileName = fsPath.split(/[\\/]/).pop() ?? fileArg; - const ast = await loadAst(fsPath, fileName); + const ast = await loadAst(fsPath, fileName, runtime, mode); + if (ast === null) { + return; + } const bytes = catchSentinel("emit", runtime, mode, () => emitForKind(ast, fileName)); if (bytes === null) { return; diff --git a/extensions/oc-path/src/oc-path/jsonc/parse.ts b/extensions/oc-path/src/oc-path/jsonc/parse.ts index 0119deb85d63..b063cb98ca9c 100644 --- a/extensions/oc-path/src/oc-path/jsonc/parse.ts +++ b/extensions/oc-path/src/oc-path/jsonc/parse.ts @@ -41,21 +41,18 @@ type JsoncParserNode = { }; export function parseJsonc(raw: string): JsoncParseResult { - if (raw.trim().length === 0) { - return { ast: { kind: "jsonc", raw, root: null }, diagnostics: [] }; - } - // Pre-parse byte-length cap. Symmetric with the post-parse depth cap // at `nodeToJsoncValue`. Without this, `parseTree` would allocate the // full tree before our walker noticed; bounding at the source keeps // memory pressure proportional to input size. - if (raw.length > MAX_JSONC_INPUT_BYTES) { + const inputBytes = Buffer.byteLength(raw, "utf8"); + if (inputBytes > MAX_JSONC_INPUT_BYTES) { return { ast: { kind: "jsonc", raw, root: null }, diagnostics: [ { line: 1, - message: `input exceeds MAX_JSONC_INPUT_BYTES (${MAX_JSONC_INPUT_BYTES} bytes; got ${raw.length})`, + message: `input exceeds MAX_JSONC_INPUT_BYTES (${MAX_JSONC_INPUT_BYTES} bytes; got ${inputBytes})`, severity: "error", code: "OC_JSONC_INPUT_TOO_LARGE", }, @@ -63,6 +60,10 @@ export function parseJsonc(raw: string): JsoncParseResult { }; } + if (raw.trim().length === 0) { + return { ast: { kind: "jsonc", raw, root: null }, diagnostics: [] }; + } + const parseSource = raw.startsWith("\uFEFF") ? raw.slice(1) : raw; const errors: ParseError[] = []; const tree = parseTree(parseSource, errors, { diff --git a/extensions/oc-path/src/oc-path/tests/jsonc/parse.test.ts b/extensions/oc-path/src/oc-path/tests/jsonc/parse.test.ts index 82fc129bef23..800a67d2c87b 100644 --- a/extensions/oc-path/src/oc-path/tests/jsonc/parse.test.ts +++ b/extensions/oc-path/src/oc-path/tests/jsonc/parse.test.ts @@ -146,10 +146,6 @@ describe("parseJsonc — soft errors", () => { }); it("rejects input larger than MAX_JSONC_INPUT_BYTES with a typed diagnostic", () => { - // Construct an input one byte over the cap. We don't allocate the - // full 16 MiB+ string in memory; `String#repeat` on a one-byte unit - // is enough to push past the threshold without exercising the - // expensive `parseTree` path (the cap fires before parse runs). const oversized = "a".repeat(JSONC_INPUT_LIMIT_BYTES + 1); const { ast, diagnostics } = parseJsonc(oversized); expect(diagnostics).toHaveLength(1); @@ -158,10 +154,33 @@ describe("parseJsonc — soft errors", () => { expect(ast.root).toBeNull(); }); - it("accepts input up to the cap", () => { - // Reasonable-shape JSON well within the cap parses normally. - const { diagnostics, ast } = parseJsonc('{"key": "value"}'); + it("accepts valid JSONC at the exact UTF-8 byte cap", () => { + const exactBoundary = `"${"a".repeat(JSONC_INPUT_LIMIT_BYTES - 2)}"`; + expect(Buffer.byteLength(exactBoundary, "utf8")).toBe(JSONC_INPUT_LIMIT_BYTES); + + const { ast, diagnostics } = parseJsonc(exactBoundary); expect(diagnostics).toEqual([]); - expect(ast.root?.kind).toBe("object"); + expect(ast.root?.kind).toBe("string"); + }); + + it("measures the input cap in UTF-8 bytes", () => { + const oversized = `"${"\u754c".repeat(Math.floor(JSONC_INPUT_LIMIT_BYTES / 3) + 1)}"`; + expect(oversized.length).toBeLessThan(JSONC_INPUT_LIMIT_BYTES); + + const { ast, diagnostics } = parseJsonc(oversized); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain(`got ${Buffer.byteLength(oversized, "utf8")}`); + expect(diagnostics[0]?.code).toBe("OC_JSONC_INPUT_TOO_LARGE"); + expect(ast.root).toBeNull(); + }); + + it("counts a UTF-8 BOM toward the byte cap", () => { + const oversized = `\uFEFF"${"a".repeat(JSONC_INPUT_LIMIT_BYTES - 2)}"`; + expect(Buffer.byteLength(oversized, "utf8")).toBe(JSONC_INPUT_LIMIT_BYTES + 3); + + const { ast, diagnostics } = parseJsonc(oversized); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.code).toBe("OC_JSONC_INPUT_TOO_LARGE"); + expect(ast.root).toBeNull(); }); });