476 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|