Files
rea/src/ghidra/GhidraFunctionValues.test.ts
T
morluto bef234d6ce fix(ghidra): preserve Unicode boundaries without breaking sessions
Validate native annotation text before mutation and escape surrogate code units in Java bridge JSON replies. Reject unsupported text with field and index diagnostics while preserving supported Unicode and line endings.

Probe real CLI/MCP annotation, transport, mapping, rollback and cleanup behavior; replace seven redundant unit tests with E2E and SDK boundary coverage.
2026-10-09 02:50:33 +08:00

476 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { JsonValue } from "../domain/jsonValue.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import {
parseGhidraFunctionResult,
type GhidraFunctionOperation,
} from "./GhidraFunctionValues.js";
import {
ghidraFunctionClassification,
ghidraFunctionBody,
ghidraFunctionDossier,
ghidraFunctionIdentity,
ghidraNativeApiBoundary,
ghidraReferenceEdge,
} from "../domain/ghidraValues.fixture.js";
describe("Ghidra function-analysis result values", () => {
it("parses provider-classified function facts and reference kinds", () => {
expect(
parseGhidraFunctionResult("read_function_instructions", {
procedure: ghidraFunctionIdentity(),
instructions: ["0x401000: push rbp"],
limitations: ["Ghidra-specific instruction text."],
}),
).toMatchObject({ ok: true });
expect(
parseGhidraFunctionResult("read_function_instructions", {
procedure: {
...ghidraFunctionIdentity(),
address: "0X401000",
},
instructions: ["0x401000: push rbp"],
limitations: ["Ghidra-specific instruction text."],
}),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
expect(
parseGhidraFunctionResult("procedure_info", {
name: "fixture_main",
entrypoint: "0x401000",
basicblock_count: 1,
length: 6,
signature: "int fixture_main(void)",
locals: [
{
description: "int local @ Stack[-0x4]:4",
provenance: "ghidra-function-database",
},
],
classification: ghidraFunctionClassification(),
body: ghidraFunctionBody(),
}),
).toMatchObject({ ok: true });
expect(
parseGhidraFunctionResult("procedure_references", {
procedure: ghidraFunctionIdentity(),
direction: "outgoing",
references: [ghidraReferenceEdge()],
}),
).toMatchObject({
ok: true,
value: {
references: [
{
kind: {
available: true,
provenance: "ghidra-reference-manager",
data: true,
},
},
],
},
});
expect(
parseGhidraFunctionResult("analyze_function", ghidraFunctionDossier()),
).toMatchObject({
ok: true,
value: {
native_api: {
available: true,
provenance: "ghidra-high-function",
return_type: {
data_type: "int",
confidence: "medium",
},
jump_tables: [
{
dispatch_address: "0x401010",
data_sources: [{ address: "0x403000" }],
mappings: [
{
target_address: "0x401020",
},
],
},
],
pseudocode: {
classification: "decompiler-generated-non-source",
compilable: false,
},
},
native_value_flow: {
available: true,
provenance: "ghidra-high-pcode",
operations: [
expect.objectContaining({ id: "0x401000#0", opcode: "COPY" }),
],
truncated: false,
},
},
});
});
it("rejects p-code relationships that refer to omitted operation IDs", () => {
const dossier = ghidraFunctionDossier();
if (
typeof dossier !== "object" ||
dossier === null ||
Array.isArray(dossier)
)
throw new TypeError("Ghidra dossier fixture is invalid");
const flow = dossier.native_value_flow;
if (typeof flow !== "object" || flow === null || Array.isArray(flow))
throw new TypeError("Ghidra p-code fixture is invalid");
expect(
parseGhidraFunctionResult("analyze_function", {
...dossier,
native_value_flow: {
...flow,
def_use: [
{
definition: "0x401000#missing",
use: "0x401000#0",
input_index: 0,
},
],
},
}),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
});
});
describe("Ghidra jump-table mapping contract", () => {
it("preserves evidenced defaults separately from cases and unresolved targets", () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const boundary = dossier.native_api;
if (boundary?.available !== true)
throw new TypeError("Ghidra native API fixture is unavailable");
const table = boundary.jump_tables[0];
const mapping = table?.mappings[0];
if (table === undefined || mapping === undefined)
throw new TypeError("Ghidra jump-table fixture is unavailable");
const defaults = [
{
target_address: mapping.target_address,
confidence: "high",
evidence: [
{
kind: "jump-table",
source: "ghidra-clang-case-token",
detail: "Default label reaches this recovered destination.",
},
],
},
];
const parsed = parseGhidraFunctionResult("analyze_function", {
...dossier,
native_api: {
...boundary,
jump_tables: [
{
...table,
default_targets: defaults,
mappings: [
mapping,
{ ...mapping, case_value: null, target_address: "0x401030" },
],
},
],
},
});
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
const output = functionDossierSchema.parse(parsed.value).native_api;
if (output?.available !== true)
throw new TypeError("Ghidra native API fixture is unavailable");
expect(output.jump_tables[0]).toMatchObject({
default_targets: defaults,
mappings: [
{ case_value: 0, target_address: "0x401020" },
{ case_value: null, target_address: "0x401030" },
],
});
});
it("normalizes legacy missing defaults without promoting unknown cases", () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const boundary = dossier.native_api;
if (boundary?.available !== true)
throw new TypeError("Ghidra native API fixture is unavailable");
const table = boundary.jump_tables[0];
const mapping = table?.mappings[0];
if (table === undefined || mapping === undefined)
throw new TypeError("Ghidra jump-table fixture is unavailable");
const { default_targets: _legacyDefaults, ...legacy } = table;
const parsed = parseGhidraFunctionResult("analyze_function", {
...dossier,
native_api: {
...boundary,
jump_tables: [
{ ...legacy, mappings: [{ ...mapping, case_value: null }] },
],
},
});
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw parsed.error;
expect(functionDossierSchema.parse(parsed.value).native_api).toMatchObject({
jump_tables: [{ default_targets: [], mappings: [{ case_value: null }] }],
});
});
it("rejects non-canonical default destinations and defaults without evidence", () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const boundary = dossier.native_api;
if (boundary?.available !== true)
throw new TypeError("Ghidra native API fixture is unavailable");
const table = boundary.jump_tables[0];
const mapping = table?.mappings[0];
if (table === undefined || mapping === undefined)
throw new TypeError("Ghidra jump-table fixture is unavailable");
for (const destination of [
{
target_address: "0X401020",
confidence: "high",
evidence: mapping.evidence,
},
{ target_address: "0x401020", confidence: "high", evidence: [] },
]) {
expect(
parseGhidraFunctionResult("analyze_function", {
...dossier,
native_api: {
...boundary,
jump_tables: [{ ...table, default_targets: [destination] }],
},
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
}
});
});
describe("Ghidra function-analysis malformed results", () => {
it.each(malformedOutputs())(
"rejects malformed %s output",
(_name, operation, value) => {
expect(parseGhidraFunctionResult(operation, value)).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
},
);
});
const malformedOutputs = (): Array<
[string, GhidraFunctionOperation, JsonValue]
> => {
const dossier = ghidraFunctionDossier();
if (typeof dossier !== "object" || dossier === null || Array.isArray(dossier))
throw new TypeError("Ghidra dossier fixture is invalid");
const edge = ghidraReferenceEdge();
const nativeApi = ghidraNativeApiBoundary();
const jumpTable = nativeApi.jump_tables[0];
const mapping = jumpTable?.mappings[0];
if (jumpTable === undefined || mapping === undefined)
throw new TypeError("Ghidra native API fixture is invalid");
return [
["non-canonical address", "procedure_callers", ["401000"]],
[
"missing classification",
"procedure_info",
{
name: "main",
entrypoint: "0x401000",
basicblock_count: 1,
length: 1,
signature: null,
locals: [],
},
],
[
"unavailable Ghidra reference kind",
"procedure_references",
{
procedure: ghidraFunctionIdentity(),
direction: "outgoing",
references: [
{ ...edge, kind: { available: false, reason: "unknown" } },
],
},
],
[
"missing native API boundary",
"analyze_function",
Object.fromEntries(
Object.entries(dossier).filter(([key]) => key !== "native_api"),
),
],
[
"non-canonical native API target",
"analyze_function",
{
...dossier,
native_api: {
...nativeApi,
jump_tables: [
{
...jumpTable,
mappings: [
{
...mapping,
target_address: "401020",
},
],
},
],
},
},
],
[
"Hopper local provenance",
"analyze_function",
{
...dossier,
procedure: {
...ghidraFunctionIdentity(),
signature: null,
locals: [
{
description: "opaque",
provenance: "hopper-public-python-api",
},
],
},
},
],
[
"inconsistent dossier bound",
"analyze_function",
{
...dossier,
instruction_scan: { scanned: -1, truncated: false },
},
],
[
"missing uncertainty limitations",
"analyze_function",
{
...dossier,
limitations: [],
},
],
];
};
// Inclusive ranges are a wire-boundary fixture, not a claim of real-provider acceptance.
describe("complete Ghidra function body range evidence", () => {
const body = () => ({
available: true,
provenance: "ghidra-function-body-address-set",
ranges: [
{ start: "0x401000", end: "0x401002" },
{ start: "0x401010", end: "0x401011" },
],
total_bytes: 5,
span_bytes: 18,
non_contiguous: true,
contains_entry: true,
});
const info = () => ({
name: "fixture_main",
entrypoint: "0x401000",
basicblock_count: 2,
length: 5,
signature: null,
locals: [],
classification: ghidraFunctionClassification(),
body: body(),
});
it("preserves inclusive disjoint ranges and distinguishes owned bytes from enclosing span", () => {
expect(parseGhidraFunctionResult("procedure_info", info())).toMatchObject({
ok: true,
value: { body: body(), length: 5 },
});
});
it("rejects omitted body evidence instead of accepting a length as complete coverage", () => {
const { body: omitted, ...withoutBody } = info();
expect(omitted.total_bytes).toBe(5);
expect(parseGhidraFunctionResult("procedure_info", withoutBody).ok).toBe(
false,
);
});
it("requires local entry coverage but permits an observed empty external body", () => {
const empty = {
...body(),
ranges: [],
total_bytes: 0,
span_bytes: 0,
non_contiguous: false,
contains_entry: false,
};
expect(
parseGhidraFunctionResult("procedure_info", {
...info(),
body: empty,
length: 0,
}).ok,
).toBe(false);
expect(
parseGhidraFunctionResult("procedure_info", {
...info(),
classification: { ...ghidraFunctionClassification(), external: true },
body: empty,
length: 0,
}).ok,
).toBe(true);
});
it("rejects a missing body on nested dossier identities", () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const { body: omitted, ...identity } = ghidraFunctionIdentity();
expect(omitted.total_bytes).toBe(6);
expect(
parseGhidraFunctionResult("analyze_function", {
...dossier,
callers: [identity],
}).ok,
).toBe(false);
});
it("requires body length and Ghidra-specific body provenance", () => {
expect(
parseGhidraFunctionResult("procedure_info", { ...info(), length: 18 }).ok,
).toBe(false);
expect(
parseGhidraFunctionResult("procedure_info", {
...info(),
body: { ...body(), provenance: "unreviewed" },
}).ok,
).toBe(false);
});
it.each([
{ total_bytes: 18 },
{ span_bytes: 5 },
{ non_contiguous: false },
{ contains_entry: false },
{
ranges: [
{ start: "0x401000", end: "0x401002" },
{ start: "0x401002", end: "0x401004" },
],
},
{ ranges: [{ start: "0x401002", end: "0x401000" }] },
])("rejects contradictory complete body facts %j", (change) => {
expect(
parseGhidraFunctionResult("procedure_info", {
...info(),
body: { ...body(), ...change },
}).ok,
).toBe(false);
});
});