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 <steipete@gmail.com>
This commit is contained in:
qingminlong
2026-07-18 17:47:15 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent aad27e2629
commit fc30a6c67d
4 changed files with 83 additions and 20 deletions
+17
View File
@@ -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", () => {
+32 -6
View File
@@ -160,11 +160,25 @@ function catchSentinel<T>(
}
}
async function loadAst(absPath: string, fileName: string): Promise<OcAst> {
async function loadAst(
absPath: string,
fileName: string,
runtime: OutputRuntimeEnv,
mode: OutputMode,
): Promise<OcAst | null> {
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;
@@ -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, {
@@ -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();
});
});