From 2e8675706352c490b8675e4a92a03055b144f9cb Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:00:22 +0800 Subject: [PATCH] fix(ghidra): measure escaped PDB locators before omission Preserve declared locators that fit the actual 1024-byte JSON UTF-8 budget. Use constant-time bounds for small and oversized inputs, and a bounded scan for escaping and Unicode before allocation. Advance PE coverage to program-info-v3 so earlier projections remain historical. Validation: 237 focused tests across 15 suites pass, with two Windows-only cases skipped on Linux. Six locator cases and the intermediate-profile replay regression fail before this fix. Typecheck and changed-file lint pass. --- src/ghidra/GhidraAnalysisProfile.ts | 2 +- src/ghidra/GhidraPeProfile.test.ts | 35 ++++++++ src/ghidra/GhidraPeProfile.ts | 41 ++++++++- src/ghidra/GhidraProvider.behavior.test.ts | 97 ++++++++++++---------- 4 files changed, 124 insertions(+), 51 deletions(-) diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index eac0c98ff..5a193ec99 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -218,7 +218,7 @@ export const resolveGhidraAnalysisProfile = async ( ].sort(), import_mode: "ephemeral-source-immutable", ...(target.format === "pe" - ? { pdb_observations: "program-info-v2" } + ? { pdb_observations: "program-info-v3" } : {}), annotation_policy: "atomic-function-entry-metadata-v1", load_image_observations: "source-mappings-entry-context-v2", diff --git a/src/ghidra/GhidraPeProfile.test.ts b/src/ghidra/GhidraPeProfile.test.ts index 1780b9eb1..36822377f 100644 --- a/src/ghidra/GhidraPeProfile.test.ts +++ b/src/ghidra/GhidraPeProfile.test.ts @@ -166,3 +166,38 @@ it("bounds an escaped inline locator while preserving its declared source value" expect(caveats).toContain("01234567-89ab-cdef-0123-456789abcdef"); expect(selected.codeView.pdbFileName).toBe(name); }); + +it.each([ + ["long ASCII", "a".repeat(171)], + ["ASCII at the byte budget", "a".repeat(1022)], + ["ASCII beyond the byte budget", "a".repeat(1023)], + ["quotes at the byte budget", '"'.repeat(511)], + ["quotes beyond the byte budget", '"'.repeat(512)], + ["short control escapes", "\n".repeat(511)], + ["short control escapes beyond the byte budget", "\n".repeat(512)], + ["six-byte control escapes", "\u0001".repeat(170)], + ["six-byte control escapes beyond the byte budget", "\u0001".repeat(171)], + ["UTF-8 BMP text", "中".repeat(340)], + ["UTF-8 BMP text beyond the byte budget", "中".repeat(341)], + ["surrogate pairs", "😀".repeat(255)], + ["surrogate pairs beyond the byte budget", "😀".repeat(256)], + ["unpaired high surrogates", "\ud800".repeat(170)], + ["unpaired high surrogates beyond the byte budget", "\ud800".repeat(171)], + ["unpaired low surrogates", "\udfff".repeat(170)], + ["unpaired low surrogates beyond the byte budget", "\udfff".repeat(171)], +])("uses actual JSON UTF-8 size for %s", (_, name) => { + const selected = { + ...target, + codeView: { ...target.codeView!, pdbFileName: name }, + }; + const caveat = ghidraPePdbLimitations(selected, undefined).join(" "); + const encoded = JSON.stringify(name); + if (Buffer.byteLength(encoded) <= 1024) { + expect(caveat).toContain(`PDB ${encoded} (`); + expect(caveat).not.toContain("locator omitted"); + } else { + expect(caveat).toContain("locator omitted"); + expect(caveat).not.toContain(encoded); + } + expect(selected.codeView.pdbFileName).toBe(name); +}); diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts index 019b1a2d3..482eabd6e 100644 --- a/src/ghidra/GhidraPeProfile.ts +++ b/src/ghidra/GhidraPeProfile.ts @@ -4,6 +4,40 @@ import { type GhidraSessionInfo, } from "./GhidraSessionValues.js"; +const PDB_LOCATOR_INLINE_BYTE_BUDGET = 1024; + +/** Measure JSON quotes, escapes and UTF-8 without allocating the encoded string. */ +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; +}; + /** Use reported Program facts first, retaining declared PE facts as a fallback. */ export const ghidraPePdbLimitations = ( target: BinaryTarget, @@ -21,10 +55,9 @@ export const ghidraPePdbLimitations = ( return reported; const { pdbFileName, guid, age } = target.codeView; // Include JSON escaping in the inline budget before allocating that representation. - const locator = - pdbFileName.length * 6 + 2 <= 1024 - ? JSON.stringify(pdbFileName) - : "(declared locator omitted from this inline caveat: 1024-byte resource budget)"; + const locator = pdbLocatorFitsInlineBudget(pdbFileName) + ? JSON.stringify(pdbFileName) + : `(declared locator omitted from this inline caveat: ${PDB_LOCATOR_INLINE_BYTE_BUDGET}-byte resource budget)`; const identity = guid === null ? `age (decimal) ${age}` diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 974901364..c27fb19b7 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -85,54 +85,59 @@ const createElfClient = async (ghidra: GhidraProvider) => { return ghidra.createClient(target, resolved.value.profile); }; -it("retains historical PE PDB Evidence without replaying its old projection", async () => { - const target = peTarget("x86_64"); - const resolved = await provider().resolveAnalysisProfile(target); - if (!resolved.ok || resolved.value.profile === null) - throw new Error("Expected admitted PE profile"); - const current = resolved.value.profile; - const prior = createAnalysisProfile(current.provider, { - ...current.parameters, - pdb_observations: "program-info-v1", - }); - const operation = "search_procedures"; - const parameters = { pattern: "wmain" }; - const limitations = ["Legacy projection collapsed unknown PDB load state."]; - const cache = new AnalysisSnapshotCache(); - cache.record({ - target, - profile: prior, - operation, - parameters, - execution: createAnalysisExecution([], prior.provider, { - subject: target, +it.each(["program-info-v1", "program-info-v2"])( + "retains historical PE PDB Evidence for %s without replaying its old projection", + async (pdbObservations) => { + const target = peTarget("x86_64"); + const resolved = await provider().resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("Expected admitted PE profile"); + const current = resolved.value.profile; + const prior = createAnalysisProfile(current.provider, { + ...current.parameters, + pdb_observations: pdbObservations, + }); + const operation = "search_procedures"; + const parameters = { pattern: "wmain" }; + const limitations = ["Legacy projection collapsed unknown PDB load state."]; + const cache = new AnalysisSnapshotCache(); + cache.record({ + target, + profile: prior, + operation, + parameters, + execution: createAnalysisExecution([], prior.provider, { + subject: target, + analysisProfile: prior, + limitations, + }), + }); + const evidence = createEvidence(target, prior.provider, { + operation, + parameters, + result: [], analysisProfile: prior, limitations, - }), - }); - const evidence = createEvidence(target, prior.provider, { - operation, - parameters, - result: [], - analysisProfile: prior, - limitations, - }); - const exported = cache.export( - target, - prior, - createEvidenceBundle([evidence]), - ); - if (!exported.ok) throw exported.error; - const imported = new AnalysisSnapshotCache(); - expect(imported.import(exported.value, undefined, () => ok(0)).ok).toBe(true); - expect( - imported.lookup(target, current, operation, parameters), - ).toBeUndefined(); - expect( - imported.lookup(target, prior, operation, parameters)?.limitations, - ).toEqual(expect.arrayContaining(limitations)); - expect(exported.value.evidence_bundle.records).toContainEqual(evidence); -}); + }); + const exported = cache.export( + target, + prior, + createEvidenceBundle([evidence]), + ); + if (!exported.ok) throw exported.error; + const imported = new AnalysisSnapshotCache(); + expect(imported.import(exported.value, undefined, () => ok(0)).ok).toBe( + true, + ); + expect( + imported.lookup(target, current, operation, parameters), + ).toBeUndefined(); + expect( + imported.lookup(target, prior, operation, parameters)?.limitations, + ).toEqual(expect.arrayContaining(limitations)); + expect(exported.value.evidence_bundle.records).toContainEqual(evidence); + }, +); describe("Ghidra provider", () => { it("commits DOS loader and real-mode language while retaining the CPU family", async () => {