mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
co-authored by
Peter Steinberger
parent
aad27e2629
commit
fc30a6c67d
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user