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.
This commit is contained in:
N0zoM1z0 2026-10-14 05:00:22 +08:00
parent 6629c5c945
commit 2e86757063
4 changed files with 124 additions and 51 deletions

View File

@ -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",

View File

@ -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);
});

View File

@ -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}`

View File

@ -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 () => {