* feat(ghidra): decode Swift String literals in function dossiers Swift passes a String as two words. Small literals keep their UTF-8 bytes inside those words, and immortal large literals keep a count plus a pointer to their bytes biased by 32. Ghidra creates no data reference for either, so analyze_function reported no strings for Swift code. On AArch64 programs that contain Swift metadata, the Ghidra bridge now runs constant propagation over the function and decodes register pairs where Swift consumes a String: consecutive argument registers at calls and tail calls, x0 and x1 at returns, and both registers of one STP. Large literals must resolve to terminated bytes inside one initialized block that is not executable. A pair whose registers both come from conditional selects is skipped, because Ghidra forks each select without correlating the condition and could otherwise pair words from different cases. Decoded values are a derivation from the ABI layout rather than observed references, so they appear in a new optional swift_string_literals field with their source instruction, registers and raw words, and in a matching inspect_analysis_view facet. referenced_strings is unchanged. Refs #2030 * fix(ghidra): bound Swift literal reads and compare decoded strings Large immortal literals are measured against the remaining heap before allocation, and a failed fit stays visible as a limitation. The canonical empty string is kept. Small and large literals now require matching address nullability, and function comparison treats decoded literals as string evidence without equating a missing record to an empty one. * fix(ghidra): budget Swift literal decoding and tighten use sites Large literals now draw on one heap budget per dossier, measured once before decoding, instead of a separate check per literal. A literal that does not fit is listed in a new swift_string_literal_omissions field with its address, byte length and reason, so consumers read omissions from structured data rather than from limitation text. strings_names comparison checks referenced strings and names first and reports a change there even when Swift literal coverage differs; it returns unknown only when those facts match and literal coverage is absent on one side or partial. A computed jump counts as a tail call only when it has no resolved target inside the function, so jump tables no longer qualify, and a return requires a RETURN p-code operation, so traps no longer qualify. Decoded literals keep control characters, which Swift strings may contain and JSON escapes. The Swift verifier builds the literal fixture for arm64 on any Mac host and now also covers the empty string and a literal containing a tab. * fix(ghidra): charge escaped JSON and follow copied selects The Swift literal budget now charges the worst case per UTF-8 byte: UTF-16 text, six escaped JSON characters held in Gson's growing buffer and in the returned String, and the UTF-8 bytes written to the socket. Literals made of control characters can no longer exceed the reserved heap. A jump counts as a tail call only when every resolved target lies outside the function. The conditional select check follows register copies back to their definition, so selects copied into argument registers are recognized like selects written there directly. strings_names reports a change when both dossiers decoded different literals at the same use site, even when either side has omissions; it returns unknown only when no such use site disagrees. * fix(ghidra): bound literal records and trace selects across branches Every Swift literal record now pays a fixed cost against the dossier budget in addition to its text, so many empty or small literals are bounded as well. The first record that does not fit is kept as an omission and decoding stops there, which keeps the omission record itself bounded; omissions now describe small or large literals. The conditional select check searches every predecessor path for the reaching definitions, following register copies, so selects made before a branch into the use block are recognized. A dossier records Swift literals and their omissions together. Coverage reads omissions first, and strings_names compares every use site that neither side omitted: a decoded literal is known where it was recorded, and an absence is known only in a dossier whose decoding examined every use site. * fix(ghidra): stop every flow at the literal budget and route omissions Ending one flow from the evaluator leaves other queued flows running, so the budget now records a shared stop that every flow checks before it examines another instruction. A record that exceeds the budget is routed by that capacity result rather than by whether text was decoded, so a small literal over budget becomes an omission instead of a literal. When coverage is partial, the per site comparison keeps each literal's representation as well as its value, matching the full strings_names projection. * fix(ghidra): keep unresolved Swift tail-call sites unknown --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
730 lines
22 KiB
TypeScript
730 lines
22 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
import { functionDossierSchema } from "../domain/hopperValues.js";
|
|
import {
|
|
compactReference,
|
|
compactInstruction,
|
|
compactCallTargets,
|
|
} from "../domain/native/nativeInstruction.fixture.js";
|
|
import {
|
|
parseGhidraFunctionResult,
|
|
type GhidraFunctionOperation,
|
|
} from "./GhidraFunctionValues.js";
|
|
import {
|
|
ghidraFunctionClassification,
|
|
ghidraFunctionDossier,
|
|
ghidraFunctionIdentity,
|
|
ghidraNativeApiBoundary,
|
|
ghidraReferenceEdge,
|
|
} from "../domain/ghidraValues.fixture.js";
|
|
|
|
const observationOperations: ReadonlySet<GhidraFunctionOperation> = new Set([
|
|
"analyze_function",
|
|
"procedure_assembly",
|
|
"procedure_info",
|
|
"procedure_pseudo_code",
|
|
"read_function_instructions",
|
|
]);
|
|
|
|
// Existing assertions focus on the value schema; this helper builds the current
|
|
// bridge envelope and returns its normalized value for those assertions.
|
|
const parseFunctionValueFixture = (
|
|
operation: GhidraFunctionOperation,
|
|
value: JsonValue,
|
|
) => {
|
|
const wireValue = observationOperations.has(operation)
|
|
? { value, limitations: [] }
|
|
: value;
|
|
const parsed = parseGhidraFunctionResult(operation, wireValue);
|
|
return parsed.ok ? { ok: true as const, value: parsed.value.value } : parsed;
|
|
};
|
|
|
|
describe("Ghidra function-analysis result values", () => {
|
|
it("preserves file-offset resolution in Evidence locations and rejects inexact offsets", () => {
|
|
const selection = {
|
|
file_offset: 0x210,
|
|
mapped_address: "0x140001010",
|
|
procedure_address: "0x140001010",
|
|
};
|
|
const wire = { value: "return 42;", limitations: [], selection };
|
|
expect(parseGhidraFunctionResult("procedure_assembly", wire)).toMatchObject(
|
|
{ ok: false, error: { _tag: "AnalysisOutputError" } },
|
|
);
|
|
const parsed = parseGhidraFunctionResult("procedure_pseudo_code", wire);
|
|
if (!parsed.ok) throw parsed.error;
|
|
expect(parsed.value.locations).toEqual([
|
|
{ kind: "file-offset", offset: 0x210 },
|
|
{ kind: "address", address: "0x140001010" },
|
|
{ kind: "address", address: "0x140001010" },
|
|
]);
|
|
expect(
|
|
parseGhidraFunctionResult("procedure_pseudo_code", {
|
|
...wire,
|
|
selection: { ...selection, file_offset: Number.MAX_SAFE_INTEGER + 1 },
|
|
}),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
|
});
|
|
it.each([...observationOperations])(
|
|
"requires the function-observation limitations envelope for %s",
|
|
(operation) => {
|
|
expect(parseGhidraFunctionResult(operation, null)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisOutputError" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves a named function-observation limitation", () => {
|
|
expect(
|
|
parseGhidraFunctionResult("procedure_pseudo_code", "void f() {}"),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
|
const limitation =
|
|
"Ghidra marks a call to external __tls_get_addr terminal; return behavior is not independently verified.";
|
|
expect(
|
|
parseGhidraFunctionResult("procedure_pseudo_code", {
|
|
value: "void f() {}",
|
|
limitations: [limitation],
|
|
}),
|
|
).toEqual({
|
|
ok: true,
|
|
value: { value: "void f() {}", limitations: [limitation] },
|
|
});
|
|
});
|
|
|
|
it.each(["reference_kinds_available", "unresolved_calls"])(
|
|
"rejects omitted %s instead of inventing complete reference observations",
|
|
(field) => {
|
|
const output = {
|
|
procedure: ghidraFunctionIdentity(),
|
|
direction: "outgoing",
|
|
references: [ghidraReferenceEdge()],
|
|
reference_kinds_available: true,
|
|
unresolved_calls: [],
|
|
};
|
|
const incomplete = Object.fromEntries(
|
|
Object.entries(output).filter(([key]) => key !== field),
|
|
);
|
|
expect(
|
|
parseFunctionValueFixture("procedure_references", output),
|
|
).toMatchObject({ ok: true });
|
|
expect(
|
|
parseFunctionValueFixture("procedure_references", incomplete),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
|
},
|
|
);
|
|
|
|
it.each(["block_membership", "parameters", "parameter_uses"])(
|
|
"rejects a value-flow result missing %s",
|
|
(field) => {
|
|
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");
|
|
if (!Array.isArray(flow.operations))
|
|
throw new TypeError("Ghidra operation fixture is invalid");
|
|
const incompleteFlow =
|
|
field === "block_membership"
|
|
? {
|
|
...flow,
|
|
operations: flow.operations.map((operation, index) =>
|
|
index !== 0 ||
|
|
typeof operation !== "object" ||
|
|
operation === null ||
|
|
Array.isArray(operation)
|
|
? operation
|
|
: Object.fromEntries(
|
|
Object.entries(operation).filter(
|
|
([key]) => key !== field,
|
|
),
|
|
),
|
|
),
|
|
}
|
|
: Object.fromEntries(
|
|
Object.entries(flow).filter(([key]) => key !== field),
|
|
);
|
|
|
|
expect(
|
|
parseFunctionValueFixture(
|
|
"analyze_function",
|
|
jsonValueSchema.parse({
|
|
...dossier,
|
|
native_value_flow: incompleteFlow,
|
|
}),
|
|
),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
|
},
|
|
);
|
|
|
|
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(
|
|
parseFunctionValueFixture("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 Swift String literals", () => {
|
|
it("keeps a small Swift literal without storage apart from a large literal's bytes", () => {
|
|
const dossier = ghidraFunctionDossier();
|
|
if (
|
|
typeof dossier !== "object" ||
|
|
dossier === null ||
|
|
Array.isArray(dossier)
|
|
)
|
|
throw new TypeError("Ghidra dossier fixture is invalid");
|
|
const literals = [
|
|
{
|
|
source_address: "0x401004",
|
|
value: "The user name ",
|
|
representation: "small",
|
|
address: null,
|
|
registers: ["x0", "x1"],
|
|
words: ["0x7265737520656854", "0xee0020656d616e20"],
|
|
},
|
|
{
|
|
source_address: "0x401006",
|
|
value: "",
|
|
representation: "small",
|
|
address: null,
|
|
registers: ["x0", "x1"],
|
|
words: ["0x0", "0xe000000000000000"],
|
|
},
|
|
{
|
|
source_address: "0x401008",
|
|
value: " cannot be written into a sudoers rule safely.",
|
|
representation: "large",
|
|
address: "0x402270",
|
|
registers: ["x0", "x1"],
|
|
words: ["0xd00000000000002e", "0x8000000000402250"],
|
|
},
|
|
];
|
|
expect(
|
|
parseFunctionValueFixture("analyze_function", {
|
|
...dossier,
|
|
swift_string_literals: literals,
|
|
swift_string_literal_omissions: [],
|
|
}),
|
|
).toMatchObject({ ok: true, value: { swift_string_literals: literals } });
|
|
});
|
|
});
|
|
|
|
describe("Ghidra compact reference provenance", () => {
|
|
it("distinguishes incoming references, examined emptiness and unavailable coverage at data addresses", () => {
|
|
const instruction = {
|
|
...compactInstruction(),
|
|
address: "ram:00402000",
|
|
status: "data",
|
|
procedure: null,
|
|
};
|
|
const reference = {
|
|
...compactReference(),
|
|
source_address: "ram:00401000",
|
|
target_address: instruction.address,
|
|
type: "WRITE",
|
|
call: false,
|
|
data: true,
|
|
write: true,
|
|
};
|
|
for (const incoming of [
|
|
{},
|
|
{ incoming_references: null },
|
|
{ incoming_references: [] },
|
|
{ incoming_references: [reference] },
|
|
{ incoming_references: [{ ...reference, primary: null, source: null }] },
|
|
]) {
|
|
const expected = { ...instruction, ...incoming };
|
|
const parsed = parseFunctionValueFixture(
|
|
"inspect_native_instruction",
|
|
expected,
|
|
);
|
|
if (!parsed.ok) throw parsed.error;
|
|
expect(parsed.value).toEqual(expected);
|
|
}
|
|
expect(
|
|
parseFunctionValueFixture("inspect_native_instruction", {
|
|
...instruction,
|
|
incoming_references: [{ ...reference, primary: "false" }],
|
|
}),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
|
});
|
|
|
|
const operations = [
|
|
["inspect_native_instruction", compactInstruction],
|
|
["resolve_native_call_targets", compactCallTargets],
|
|
] as const;
|
|
|
|
it.each(operations)(
|
|
"preserves complete, legacy and unknown facts in %s",
|
|
(operation, output) => {
|
|
const complete = compactReference();
|
|
const legacy = Object.fromEntries(
|
|
Object.entries(complete).filter(
|
|
([key]) =>
|
|
![
|
|
"source_address",
|
|
"data",
|
|
"read",
|
|
"write",
|
|
"primary",
|
|
"provenance",
|
|
"source",
|
|
].includes(key),
|
|
),
|
|
);
|
|
const unknown = {
|
|
...legacy,
|
|
source_address: null,
|
|
data: null,
|
|
read: null,
|
|
write: null,
|
|
primary: null,
|
|
provenance: null,
|
|
source: null,
|
|
};
|
|
for (const reference of [complete, legacy, unknown]) {
|
|
const expected = output([reference]);
|
|
const parsed = parseFunctionValueFixture(operation, expected);
|
|
if (!parsed.ok) throw parsed.error;
|
|
expect(parsed.value).toEqual(expected);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(operations)(
|
|
"rejects malformed present provenance in %s",
|
|
(operation, output) => {
|
|
for (const [field, value] of [
|
|
["source_address", 1],
|
|
["data", "true"],
|
|
["read", 1],
|
|
["write", 0],
|
|
["primary", "false"],
|
|
["provenance", ""],
|
|
["source", false],
|
|
] as const) {
|
|
expect(
|
|
parseFunctionValueFixture(
|
|
operation,
|
|
output([{ ...compactReference(), [field]: value }]),
|
|
),
|
|
).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 = parseFunctionValueFixture("analyze_function", {
|
|
...dossier,
|
|
native_api: {
|
|
...boundary,
|
|
jump_tables: [
|
|
{
|
|
...table,
|
|
default_targets: defaults,
|
|
mappings: [
|
|
mapping,
|
|
{ ...mapping, case_value: null, target_address: "0x401030" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
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("rejects omitted default observations instead of repairing obsolete bridge output", () => {
|
|
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: omittedDefaults, ...withoutDefaults } = table;
|
|
expect(omittedDefaults).toHaveLength(1);
|
|
const parsed = parseFunctionValueFixture("analyze_function", {
|
|
...dossier,
|
|
native_api: {
|
|
...boundary,
|
|
jump_tables: [
|
|
{ ...withoutDefaults, mappings: [{ ...mapping, case_value: null }] },
|
|
],
|
|
},
|
|
});
|
|
expect(parsed).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisOutputError" },
|
|
});
|
|
});
|
|
|
|
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(
|
|
parseFunctionValueFixture("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(parseFunctionValueFixture(operation, value)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisOutputError" },
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
const malformedSwiftStringLiterals = (
|
|
dossier: Record<string, JsonValue>,
|
|
): Array<[string, GhidraFunctionOperation, JsonValue]> =>
|
|
(
|
|
[
|
|
["non-canonical Swift literal address", "large", "402240"],
|
|
["small Swift literal with storage", "small", "0x402270"],
|
|
["large Swift literal without storage", "large", null],
|
|
] as const
|
|
).map(([name, representation, address]) => [
|
|
name,
|
|
"analyze_function",
|
|
{
|
|
...dossier,
|
|
swift_string_literal_omissions: [],
|
|
swift_string_literals: [
|
|
{
|
|
source_address: "0x401004",
|
|
value: "The power setting could not be changed.",
|
|
representation,
|
|
address,
|
|
registers: ["x0", "x1"],
|
|
words: ["0xd000000000000027", "0x8000000000402220"],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
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"]],
|
|
[
|
|
"Swift literals without their omission record",
|
|
"analyze_function",
|
|
{ ...dossier, swift_string_literals: [] },
|
|
],
|
|
[
|
|
"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"),
|
|
),
|
|
],
|
|
...malformedSwiftStringLiterals(dossier),
|
|
[
|
|
"non-canonical Swift literal omission address",
|
|
"analyze_function",
|
|
{
|
|
...dossier,
|
|
swift_string_literals: [],
|
|
swift_string_literal_omissions: [
|
|
{
|
|
source_address: "0x401004",
|
|
representation: "large",
|
|
address: "402240",
|
|
byte_length: 39,
|
|
reason: "capacity",
|
|
registers: ["x0", "x1"],
|
|
words: ["0xd000000000000027", "0x8000000000402220"],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
[
|
|
"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(parseFunctionValueFixture("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(parseFunctionValueFixture("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(
|
|
parseFunctionValueFixture("procedure_info", {
|
|
...info(),
|
|
body: empty,
|
|
length: 0,
|
|
}).ok,
|
|
).toBe(false);
|
|
expect(
|
|
parseFunctionValueFixture("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(
|
|
parseFunctionValueFixture("analyze_function", {
|
|
...dossier,
|
|
callers: [identity],
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
it("requires body length and Ghidra-specific body provenance", () => {
|
|
expect(
|
|
parseFunctionValueFixture("procedure_info", { ...info(), length: 18 }).ok,
|
|
).toBe(false);
|
|
expect(
|
|
parseFunctionValueFixture("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(
|
|
parseFunctionValueFixture("procedure_info", {
|
|
...info(),
|
|
body: { ...body(), ...change },
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
});
|