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:
parent
6629c5c945
commit
2e86757063
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue