fix(oc-path): use truncateUtf16Safe for error message path truncation (#102527)

* fix(oc-path): use truncateUtf16Safe for error message path truncation

Replace naive .slice(0, 80) with truncateUtf16Safe() at three
error-reporting call sites to prevent surrogate pair splitting
in oc:// path validation error messages.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(oc-path): cover utf16 error truncation

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
lsr911 2026-07-09 16:35:17 +08:00 committed by GitHub
parent ed9ce1c584
commit 145b71f57e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 41 additions and 3 deletions

View file

@ -10,6 +10,7 @@
* @module @openclaw/oc-path/oc-path
*/
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { OcEmitSentinelError, REDACTED_SENTINEL } from "./sentinel.js";
const OC_SCHEME = "oc://";
@ -134,7 +135,7 @@ export function parseOcPath(input: string): OcPath {
if (input.length > MAX_PATH_LENGTH) {
fail(
`oc:// path exceeds ${MAX_PATH_LENGTH} bytes (length: ${input.length})`,
input.slice(0, 80) + "…",
truncateUtf16Safe(input, 80) + "…",
"OC_PATH_TOO_LONG",
);
}
@ -147,7 +148,7 @@ export function parseOcPath(input: string): OcPath {
if (normalized.length > MAX_PATH_LENGTH) {
fail(
`oc:// path exceeds ${MAX_PATH_LENGTH} bytes after NFC (length: ${normalized.length})`,
input.slice(0, 80) + "…",
truncateUtf16Safe(input, 80) + "…",
"OC_PATH_TOO_LONG",
);
}
@ -311,7 +312,7 @@ export function formatOcPath(path: OcPath): string {
if (out.length > MAX_PATH_LENGTH) {
fail(
`Formatted oc:// exceeds ${MAX_PATH_LENGTH} bytes (length: ${out.length})`,
out.slice(0, 80) + "…",
truncateUtf16Safe(out, 80) + "…",
"OC_PATH_TOO_LONG",
);
}

View file

@ -13,6 +13,17 @@ import {
import { parseJsonc } from "../../jsonc/parse.js";
import { parseJsonl } from "../../jsonl/parse.js";
function expectUtf16SafeLimitError(run: () => unknown, expectedInput: string): void {
try {
run();
expect.fail("expected an OC_PATH_TOO_LONG error");
} catch (err) {
expect(err).toBeInstanceOf(OcPathError);
expect((err as OcPathError).code).toBe("OC_PATH_TOO_LONG");
expect((err as OcPathError).input).toBe(expectedInput);
}
}
describe("encoding edges", () => {
it("strips leading UTF-8 BOM from path string", () => {
expect(parseOcPath("oc://X/Y").file).toBe("X");
@ -77,6 +88,23 @@ describe("path-string and traversal caps", () => {
expect(() => parseOcPath("oc://X/" + "a".repeat(MAX_PATH_LENGTH))).toThrow(/exceeds .* bytes/);
});
it("keeps overlong parse input UTF-16 safe", () => {
const prefix = `oc://${"a".repeat(74)}`;
expectUtf16SafeLimitError(
() => parseOcPath(`${prefix}😀${"b".repeat(MAX_PATH_LENGTH)}`),
`${prefix}`,
);
});
it("keeps post-NFC overlong parse input UTF-16 safe", () => {
const prefix = `oc://${"a".repeat(74)}`;
const input = `${prefix}😀${"\u0344".repeat(MAX_PATH_LENGTH - prefix.length - 2)}`;
expect(input).toHaveLength(MAX_PATH_LENGTH);
expect(input.normalize("NFC").length).toBeGreaterThan(MAX_PATH_LENGTH);
expectUtf16SafeLimitError(() => parseOcPath(input), `${prefix}`);
});
it("parseOcPath accepts a path right at the cap", () => {
const justUnder = "oc://X/" + "a".repeat(MAX_PATH_LENGTH - "oc://X/".length);
expect(() => parseOcPath(justUnder)).not.toThrow();
@ -88,6 +116,15 @@ describe("path-string and traversal caps", () => {
);
});
it("keeps overlong formatted paths UTF-16 safe", () => {
const sectionPrefix = "a".repeat(72);
expectUtf16SafeLimitError(
() =>
formatOcPath({ file: "X", section: `${sectionPrefix}😀${"b".repeat(MAX_PATH_LENGTH)}` }),
`oc://X/${sectionPrefix}`,
);
});
it("walker depth cap fires on synthetic deeply-nested AST", () => {
// Bypasses parser depth cap so the walker defense fires in isolation.
type V = import("../../jsonc/ast.js").JsoncValue;