diff --git a/src/application/frida/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts index 030e8f9420..0d178a8400 100644 --- a/src/application/frida/FridaInstrumentationManager.test.ts +++ b/src/application/frida/FridaInstrumentationManager.test.ts @@ -10,6 +10,7 @@ import type { Session, Signal, } from "frida"; +import { jsonObjectSchema } from "../../domain/jsonValue.js"; import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js"; import { AnalysisCapabilityUnavailableError, @@ -1252,6 +1253,70 @@ it("bounds oversized Frida message metadata before retaining it", async () => { await manager.closeAll(); }); +it.each([ + { + name: "ASCII at the limit", + input: "x".repeat(32_768), + retained: "x".repeat(32_768), + truncated: false, + }, + { + name: "a trailing unpaired high surrogate", + input: "x".repeat(32_765) + "\ud800", + retained: "x".repeat(32_765), + truncated: true, + }, + { + name: "a trailing unpaired low surrogate", + input: "x".repeat(32_765) + "\udc00", + retained: "x".repeat(32_765), + truncated: true, + }, + { + name: "a valid surrogate pair at the limit", + input: "x".repeat(32_764) + "\ud800\udc00", + retained: "x".repeat(32_764) + "\ud800\udc00", + truncated: false, + }, + { + name: "repeated unpaired high surrogates", + input: "\ud800".repeat(10_922), + retained: "\ud800".repeat(5_461), + truncated: true, + }, +])( + "bounds JSON metadata bytes for $name", + async ({ input, retained, truncated }) => { + const { bindings, manager, started } = await startLocalAttachedSession(); + onTestFinished(() => manager.closeAll()); + if (!started.ok) throw started.error; + bindings.device.session.nextMessage = { + type: "error", + description: input, + stack: input, + fileName: input, + }; + const loaded = await manager.loadScript(started.value.sessionId, { + sourceKind: "inline", + source: "// inert fixture", + }); + if (!loaded.ok) throw loaded.error; + const message = jsonObjectSchema.parse(loaded.value.messages[0]); + for (const field of ["description", "stack", "file_name"]) { + expect( + Buffer.byteLength(JSON.stringify(message[field])) - 2, + ).toBeLessThanOrEqual(32_768); + expect(message[field]).toBe(retained); + expect(message[`${field}_truncated`] ?? false).toBe(truncated); + } + expect(loaded.value.messagesTruncated).toBe(truncated); + expect(manager.status(started.value.sessionId)).toMatchObject({ + messages: [message], + messagesTruncated: truncated, + }); + }, +); + it("serializes script loading against concurrent close and unloads the loaded script", async () => { const bindings = new FakeDeviceManager(); let finishScriptLoad: (() => void) | undefined; diff --git a/src/domain/jsonStringByteLength.test.ts b/src/domain/jsonStringByteLength.test.ts new file mode 100644 index 0000000000..0d3ca93c78 --- /dev/null +++ b/src/domain/jsonStringByteLength.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from "vitest"; + +import { jsonStringByteLength } from "./jsonStringByteLength.js"; + +it("matches native JSON encoding for every individual UTF-16 code unit", () => { + const mismatches: number[] = []; + for (let unit = 0; unit <= 0xffff; unit += 1) { + const value = String.fromCharCode(unit); + if ( + jsonStringByteLength(value) !== Buffer.byteLength(JSON.stringify(value)) + ) + mismatches.push(unit); + } + expect(mismatches).toEqual([]); +}); + +it("counts adjacent surrogates, escapes and multibyte text as native JSON encodes them", () => { + const fragments = [ + "", + "a", + "\u0000", + "\b\t\n\f\r", + '"\\', + "é", + "€", + "\ud7ff", + "\ue000", + "\ud800", + "\udbff", + "\udc00", + "\udfff", + "😀", + ]; + const mismatches: string[] = []; + for (const left of fragments) + for (const right of fragments) { + const value = left + right; + if ( + jsonStringByteLength(value) !== Buffer.byteLength(JSON.stringify(value)) + ) + mismatches.push(value); + } + expect(mismatches).toEqual([]); +}); diff --git a/src/domain/jsonStringByteLength.ts b/src/domain/jsonStringByteLength.ts new file mode 100644 index 0000000000..906a3869d1 --- /dev/null +++ b/src/domain/jsonStringByteLength.ts @@ -0,0 +1,28 @@ +/** Exact UTF-8 bytes of a JSON string literal, counted without serializing it. */ +export const jsonStringByteLength = (value: string): number => { + let bytes = 2; + for (let index = 0; index < value.length; index++) { + const unit = value.charCodeAt(index); + if (unit === 0x22 || unit === 0x5c) bytes += 2; + else if (unit < 0x20) + bytes += + unit === 0x08 || + unit === 0x09 || + unit === 0x0a || + unit === 0x0c || + unit === 0x0d + ? 2 + : 6; + else if (unit < 0x80) bytes += 1; + else if (unit < 0x800) bytes += 2; + else if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index++; + } else bytes += 6; + } else if (unit >= 0xdc00 && unit <= 0xdfff) bytes += 6; + else bytes += 3; + } + return bytes; +}; diff --git a/src/dotnet/ManagedDecodeBudget.ts b/src/dotnet/ManagedDecodeBudget.ts index fba6229a27..aca84f254f 100644 --- a/src/dotnet/ManagedDecodeBudget.ts +++ b/src/dotnet/ManagedDecodeBudget.ts @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; + import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { managedFailure } from "./ManagedReaderFailure.js"; @@ -124,32 +126,10 @@ export const managedDecodeBudget = ( return budget; }; -/** Exact UTF-8 bytes of a JSON string literal, counted without serializing it. */ -export const managedJsonStringBytes = (value: string): number => { - let bytes = 2; - for (let index = 0; index < value.length; index++) { - const unit = value.charCodeAt(index); - if (unit === 0x22 || unit === 0x5c) bytes += 2; - else if (unit < 0x20) - bytes += [0x08, 0x09, 0x0a, 0x0c, 0x0d].includes(unit) ? 2 : 6; - else if (unit < 0x80) bytes += 1; - else if (unit < 0x800) bytes += 2; - else if (unit >= 0xd800 && unit <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - index++; - } else bytes += 6; - } else if (unit >= 0xdc00 && unit <= 0xdfff) bytes += 6; - else bytes += 3; - } - return bytes; -}; - /** Exact UTF-8 JSON bytes of a decoded scalar or key, including escapes. */ const jsonBytes = (item: unknown): number => typeof item === "string" - ? managedJsonStringBytes(item) + ? jsonStringByteLength(item) : Buffer.byteLength(JSON.stringify(item) ?? "null"); /** diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 1a5af8f0bd..1e69d3d298 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -1,7 +1,7 @@ +import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; import { admitManagedProjection, managedDecodeBudget, - managedJsonStringBytes, type ManagedDecodeBudget, } from "./ManagedDecodeBudget.js"; import type { ManagedPeLayout } from "./ManagedPeReader.js"; @@ -481,9 +481,9 @@ const callerName = (method: ManagedMethod): string => /** JSON bytes of `callerName`, from its parts: the joining dot adds one byte. */ const callerNameBytes = (method: ManagedMethod): number => method.declaring_type === null - ? managedJsonStringBytes(method.name) - : managedJsonStringBytes(method.declaring_type) + - managedJsonStringBytes(method.name) - + ? jsonStringByteLength(method.name) + : jsonStringByteLength(method.declaring_type) + + jsonStringByteLength(method.name) - 1; const budgetedSignature = ( diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 5056c9d469..f41d2807c1 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -33,6 +33,7 @@ import { } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; import { err, ok, type Result } from "../domain/result.js"; import { unknownFridaScript, @@ -1233,7 +1234,7 @@ const boundedRedactedString = ( } if (nextSecretStart === cursor && nextSecret !== undefined) { const replacement = "[redacted]"; - const replacementBytes = jsonStringBytes(replacement) - 2; + const replacementBytes = jsonStringByteLength(replacement) - 2; if (outputBytes + replacementBytes > maximumBytes) { truncated = true; break; @@ -1248,7 +1249,7 @@ const boundedRedactedString = ( const codePoint = input.codePointAt(cursor); if (codePoint === undefined) break; const character = String.fromCodePoint(codePoint); - const characterBytes = jsonStringBytes(character) - 2; + const characterBytes = jsonStringByteLength(character) - 2; if ( cursor + character.length > nextSecretStart || outputBytes + characterBytes > maximumBytes @@ -1343,7 +1344,7 @@ const boundedJsonValue = ( break; } const key = boundedKey.value; - const keyBytes = jsonStringBytes(key) + 1; + const keyBytes = jsonStringByteLength(key) + 1; const remaining = budget - usedBytes - separatorBytes - keyBytes - 1; if (remaining < 8) { truncated = true; @@ -1378,40 +1379,6 @@ const boundedJsonValue = ( : { ...result, bytes }; }; -const jsonStringBytes = (value: string): number => { - let bytes = Buffer.byteLength(value) + 2; - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if ( - code === 34 || - code === 92 || - code === 8 || - code === 9 || - code === 10 || - code === 12 || - code === 13 - ) - bytes += 1; - else if (code < 32) bytes += 5; - else if ( - code >= 0xd800 && - code <= 0xdbff && - (value.charCodeAt(index + 1) < 0xdc00 || - value.charCodeAt(index + 1) > 0xdfff) - ) - bytes += 3; - else if ( - code >= 0xdc00 && - code <= 0xdfff && - (index === 0 || - value.charCodeAt(index - 1) < 0xd800 || - value.charCodeAt(index - 1) > 0xdbff) - ) - bytes += 3; - } - return bytes; -}; - const remoteOptions = (remote: FridaRemoteConnection): RemoteDeviceOptions => ({ ...(remote.token === undefined ? {} : { token: remote.token }), ...(remote.certificate === undefined diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts index 7f60edb912..d85bc17cf1 100644 --- a/src/ghidra/GhidraPeProfile.ts +++ b/src/ghidra/GhidraPeProfile.ts @@ -1,3 +1,4 @@ +import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; import type { BinaryTarget, PeCodeViewRecord, @@ -46,32 +47,7 @@ const reportedPdbMatchesDeclaration = ( const pdbLocatorFitsInlineBudget = (value: string): boolean => { if (value.length * 6 + 2 <= PDB_LOCATOR_INLINE_BYTE_BUDGET) return true; if (value.length + 2 > PDB_LOCATOR_INLINE_BYTE_BUDGET) return false; - let bytes = 2; - for (let index = 0; index < value.length; index += 1) { - const point = value.codePointAt(index) ?? 0; - if (point > 0xffff) { - bytes += 4; - index += 1; - } else if (point >= 0xd800 && point <= 0xdfff) { - // JSON.stringify escapes unpaired UTF-16 surrogates as six ASCII bytes. - bytes += 6; - } else if (point < 0x20) { - bytes += - point === 8 || - point === 9 || - point === 10 || - point === 12 || - point === 13 - ? 2 - : 6; - } else if (point === 0x22 || point === 0x5c) { - bytes += 2; - } else { - bytes += point < 0x80 ? 1 : point < 0x800 ? 2 : 3; - } - if (bytes > PDB_LOCATOR_INLINE_BYTE_BUDGET) return false; - } - return true; + return jsonStringByteLength(value) <= PDB_LOCATOR_INLINE_BYTE_BUDGET; }; /** Use reported Program facts first, retaining declared PE facts as a fallback. */ diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index 74c0c629ea..d61ba68c18 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -76,6 +76,7 @@ const nativeFixture = () => { resumes: 0, remoteRemovals: 0, removalFailures: 0, + message: undefined as FridaMessage | undefined, }; let detached = false; const nativeSession: Session = { @@ -112,7 +113,10 @@ const nativeFixture = () => { async load() { await controls.load(); loaded.push(source); - listener?.({ type: "send", payload: source }, null); + listener?.( + controls.message ?? { type: "send", payload: source }, + null, + ); controls.afterLoad(); }, async unload() { @@ -231,6 +235,42 @@ const connectedFixture = async () => { return { ...native, manager, session, server, client, start, load }; }; +it.each([ + ["a trailing high surrogate", "x".repeat(32_765) + "\ud800"], + ["repeated high surrogates", "\ud800".repeat(10_922)], +])( + "retains bounded Frida error text through MCP for %s", + async (_name, description) => { + const { controls, client, start, load } = await connectedFixture(); + controls.message = { type: "error", description }; + const sessionId = await start(); + const response = await load(sessionId, "// inert fixture"); + expect(response.isError).not.toBe(true); + const evidence = toolContract("load_frida_script").outputSchema.parse( + response.structuredContent, + ); + const result = evidence.normalized_result; + expect(result.messages_truncated).toBe(true); + const retained = jsonObjectSchema.parse(result.messages[0]); + expect(retained.description_truncated).toBe(true); + expect( + Buffer.byteLength(JSON.stringify(retained.description)) - 2, + ).toBeLessThanOrEqual(32_768); + const status = await client.callTool({ + name: "frida_session_status", + arguments: { session_id: sessionId }, + }); + expect(status.isError).not.toBe(true); + expect( + toolContract("frida_session_status").outputSchema.parse( + status.structuredContent, + ), + ).toMatchObject({ + normalized_result: { messages: [retained], messages_truncated: true }, + }); + }, +); + it("declares non-destructive session mutation when status retains Evidence", async () => { const { client, start } = await connectedFixture(); const id = await start();